Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: admin api v1 - create url #2213

Merged
merged 19 commits into from
May 29, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ After these have been set up, set the environment variables according to the tab
|JOB_POLL_INTERVAL|No|Interval of time between attempts for long polling of job status in ms. Defaults to 5000ms (5s)|
|API_LINK_RANDOM_STR_LENGTH|No|String length of randomly generated shortUrl in API created links. Defaults to 8|
|FF_EXTERNAL_API|No|Boolean, feature flag for enabling the external API. Defaults to false|
|ADMIN_API_EMAIL|No|Email with admin API access. Defaults to none.|
|ADMIN_API_EMAILS|No|Emails with admin API access, separated by commas without spaces. Defaults to none.|

#### Serverless functions for link migration

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ services:
- API_LINK_RANDOM_STR_LENGTH=8
- API_KEY_SALT=$$2b$$10$$9rBKuE4Gb5ravnvP4xjoPu
- FF_EXTERNAL_API=true
- ADMIN_API_EMAIL
- [email protected]
volumes:
- ./public:/usr/src/gogovsg/public
- ./src:/usr/src/gogovsg/src
Expand Down
30 changes: 30 additions & 0 deletions src/server/admin/admin-v1/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Express from 'express'
halfwhole marked this conversation as resolved.
Show resolved Hide resolved
import { createValidator } from 'express-joi-validation'
import { container } from '../../util/inversify'
import jsonMessage from '../../util/json'
import { DependencyIds } from '../../constants'
import { AdminApiV1Controller } from '../../modules/admin/admin-v1'
import { UrlCheckController } from '../../modules/threat'
import { urlSchema } from './validators'

const adminApiV1Controller = container.get<AdminApiV1Controller>(
DependencyIds.adminApiV1Controller,
)
const urlCheckController = container.get<UrlCheckController>(
DependencyIds.urlCheckController,
)
const validator = createValidator({ passError: true })
const router = Express.Router()

router.post(
'/urls',
validator.body(urlSchema),
urlCheckController.singleUrlCheck,
adminApiV1Controller.createUrl,
)

router.use((_, res) => {
res.status(404).send(jsonMessage('Resource not found.'))
})

export = router
45 changes: 45 additions & 0 deletions src/server/admin/admin-v1/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as Joi from '@hapi/joi'
import {
isBlacklisted,
isCircularRedirects,
isHttps,
isValidShortUrl,
isValidUrl,
} from '../../../shared/util/validation'
import { ogHostname } from '../../config'

export const urlSchema = Joi.object({
userId: Joi.number().required(),
shortUrl: Joi.string()
.custom((url: string, helpers) => {
if (!isValidShortUrl(url)) {
return helpers.message({ custom: 'Short URL format is invalid.' })
}
return url
})
.optional(),
longUrl: Joi.string()
.custom((url: string, helpers) => {
if (!isHttps(url)) {
return helpers.message({ custom: 'Only HTTPS URLs are allowed.' })
}
if (!isValidUrl(url)) {
return helpers.message({ custom: 'Long URL format is invalid.' })
}
if (isCircularRedirects(url, ogHostname)) {
return helpers.message({
custom: 'Circular redirects are not allowed.',
})
}
if (isBlacklisted(url)) {
return helpers.message({
custom: 'Creation of URLs to link shortener sites are not allowed.',
})
}
return url
})
.required(),
email: Joi.string().email().required(),
})

export default urlSchema
92 changes: 92 additions & 0 deletions src/server/admin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Express from 'express'
import { MessageType } from '../../shared/util/messages'
import jsonMessage from '../util/json'
import { DependencyIds, ERROR_404_PATH } from '../constants'
import { displayHostname, ffExternalApi } from '../config'
import assetVariant from '../../shared/util/asset-variant'
import { container } from '../util/inversify'
import ApiKeyAuthService from '../modules/user/services/ApiKeyAuthService'

const BEARER_STRING = 'Bearer'
const BEARER_SEPARATOR = ' '
const apiKeyAuthService = container.get<ApiKeyAuthService>(
DependencyIds.apiKeyAuthService,
)
const router = Express.Router()

/**
* To add guard for admin-user only api routes.
* */
async function apiKeyAdminAuthMiddleware(
halfwhole marked this conversation as resolved.
Show resolved Hide resolved
req: Express.Request,
res: Express.Response,
next: Express.NextFunction,
) {
const authorizationHeader = req.headers.authorization
if (!authorizationHeader) {
res.unauthorized(jsonMessage('Authorization header is missing'))
return
}

const [bearerString, apiKey] = authorizationHeader.split(BEARER_SEPARATOR)
if (bearerString !== BEARER_STRING) {
res.unauthorized(jsonMessage('Invalid authorization header format'))
return
}
try {
const user = await apiKeyAuthService.getUserByApiKey(apiKey)
if (!user) {
res.unauthorized(jsonMessage('Invalid API Key'))
return
}
if (!(await apiKeyAuthService.isAdmin(user.id))) {
res.unauthorized(
jsonMessage(
`Email ${user.email} is not white listed`,
halfwhole marked this conversation as resolved.
Show resolved Hide resolved
MessageType.ShortUrlError,
),
)
return
}
req.body.userId = user.id
next()
} catch {
res.unauthorized(jsonMessage('Invalid API Key'))
return
}
}

/**
* Preprocess request parameters.
* */
function preprocess(
req: Express.Request,
_: Express.Response,
next: Express.NextFunction,
) {
if (req.body.email && typeof req.body.email === 'string') {
req.body.email = req.body.email.trim().toLowerCase()
}

next()
}

/* Register APIKey protected endpoints */
if (ffExternalApi) {
halfwhole marked this conversation as resolved.
Show resolved Hide resolved
router.use(
'/v1',
apiKeyAdminAuthMiddleware,
preprocess,
// eslint-disable-next-line global-require
require('./admin-v1'),
)
}

router.use((_, res) => {
res.status(404).render(ERROR_404_PATH, {
assetVariant,
displayHostname,
})
})

export default router
4 changes: 3 additions & 1 deletion src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,4 +269,6 @@ export const apiKeySalt = process.env.API_KEY_SALT as string
export const apiLinkRandomStrLength: number =
Number(process.env.API_LINK_RANDOM_STR_LENGTH) || 8
export const ffExternalApi: boolean = process.env.FF_EXTERNAL_API === 'true'
export const apiAdmin: string = process.env.ADMIN_API_EMAIL || ''
export const apiAdmins: string[] = process.env.ADMIN_API_EMAILS
? process.env.ADMIN_API_EMAILS.split(',')
: []
1 change: 1 addition & 0 deletions src/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const DependencyIds = {
bulkService: Symbol.for('bulkService'),
apiKeyAuthService: Symbol.for('apiKeyAuthService'),
apiV1Controller: Symbol.for('apiV1Controller'),
adminApiV1Controller: Symbol.for('adminApiV1Controller'),
}

export const ERROR_404_PATH = '404.error.ejs'
Expand Down
2 changes: 2 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ bindInversifyDependencies()

// Routes
import api from './api'
import adminApi from './admin'

// Logger configuration
import {
Expand Down Expand Up @@ -193,6 +194,7 @@ initDb()

// API configuration
app.use('/api', ...apiSpecificMiddleware, api) // Attach all API endpoints
app.use('/admin/api', ...apiSpecificMiddleware, adminApi) // Attach admin API endpoints
app.get(
'/assets/transition-page/js/redirect.js',
redirectController.gtagForTransitionPage,
Expand Down
2 changes: 2 additions & 0 deletions src/server/inversify.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
} from './modules/analytics/services'
import { LinkStatisticsRepository } from './modules/analytics/repositories/LinkStatisticsRepository'
import { ApiV1Controller } from './modules/api/external-v1'
import { AdminApiV1Controller } from './modules/admin/admin-v1'
import { LinkAuditController } from './modules/audit'
import { LinkAuditService } from './modules/audit/services'
import { UrlHistoryRepository } from './modules/audit/repositories'
Expand Down Expand Up @@ -142,6 +143,7 @@ export default () => {
bindIfUnbound(DependencyIds.directoryController, DirectoryController)
bindIfUnbound(DependencyIds.deviceCheckService, DeviceCheckService)
bindIfUnbound(DependencyIds.apiV1Controller, ApiV1Controller)
bindIfUnbound(DependencyIds.adminApiV1Controller, AdminApiV1Controller)

container
.bind(DependencyIds.allowedFileExtensions)
Expand Down
5 changes: 4 additions & 1 deletion src/server/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export const User = <UserTypeStatic>sequelize.define(
validate: {
isEmail: true,
isLowercase: true,
is: emailValidator.makeRe(),
is: {
args: emailValidator.makeRe(),
msg: 'Email domain is not white-listed.',
halfwhole marked this conversation as resolved.
Show resolved Hide resolved
},
},
set(this: Settable, email: string) {
// must save email as lowercase
Expand Down
87 changes: 87 additions & 0 deletions src/server/modules/admin/admin-v1/AdminApiV1Controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Express from 'express'
import { inject, injectable } from 'inversify'
import Sequelize from 'sequelize'

import { logger } from '../../../config'
import { DependencyIds } from '../../../constants'
import jsonMessage from '../../../util/json'
import { AlreadyExistsError, NotFoundError } from '../../../util/error'

import { UrlManagementService } from '../../user/interfaces'
import { MessageType } from '../../../../shared/util/messages'
import { StorableUrlSource } from '../../../repositories/enums'

import { UrlCreationRequest } from '.'
import { UrlV1Mapper } from '../../../mappers/UrlV1Mapper'
import { UserRepositoryInterface } from '../../../repositories/interfaces/UserRepositoryInterface'

@injectable()
export class AdminApiV1Controller {
private userRepository: UserRepositoryInterface

private urlManagementService: UrlManagementService

private urlV1Mapper: UrlV1Mapper

public constructor(
@inject(DependencyIds.userRepository)
userRepository: UserRepositoryInterface,
@inject(DependencyIds.urlManagementService)
urlManagementService: UrlManagementService,
@inject(DependencyIds.urlV1Mapper)
urlV1Mapper: UrlV1Mapper,
) {
this.userRepository = userRepository
this.urlManagementService = urlManagementService
this.urlV1Mapper = urlV1Mapper
}

public createUrl: (
req: Express.Request,
res: Express.Response,
) => Promise<void> = async (req, res) => {
const { userId, shortUrl, longUrl, email }: UrlCreationRequest = req.body

try {
const targetUser = await this.userRepository.findOrCreateWithEmail(email)
const newUrl = await this.urlManagementService.createUrl(
userId,
StorableUrlSource.Api,
halfwhole marked this conversation as resolved.
Show resolved Hide resolved
shortUrl,
longUrl,
)

if (userId !== targetUser.id) {
const url = await this.urlManagementService.changeOwnership(
userId,
newUrl.shortUrl,
targetUser.email,
)
const apiUrl = this.urlV1Mapper.persistenceToDto(url)
res.ok(apiUrl)
return
}
const apiUrl = this.urlV1Mapper.persistenceToDto(newUrl)
res.ok(apiUrl)
return
} catch (error) {
if (error instanceof NotFoundError) {
res.notFound(jsonMessage(error.message))
return
}
if (error instanceof AlreadyExistsError) {
res.badRequest(jsonMessage(error.message, MessageType.ShortUrlError))
return
}
if (error instanceof Sequelize.ValidationError) {
res.badRequest(jsonMessage(error.message))
return
}
logger.error(`Error creating short URL:\t${error}`)
res.badRequest(jsonMessage('Server error.'))
return
}
}
}

export default AdminApiV1Controller
Loading