[ABANDONED] API server for Flexor social network.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

393 lines
13 KiB

import {
FastifyInstance,
Plugin,
DefaultQuery,
DefaultParams,
DefaultHeaders,
DefaultBody,
RouteShorthandOptions,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { MIN_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH, MIN_PASSWORD_LENGTH, INSTALLATION_PARTITION_KEY } from '../../constants'
import { tokenResponseSchema, selfSchema, errorSchema } from '../../schemas'
import { createAccessToken, createRefreshToken } from '../../lib/authentication'
import { getUser } from '../../lib/collections'
import { hashPassword, comparePassword, JWT } from '../../lib/crypto'
import { containerFor, getItem, queryItems, normalize, createQuerySpec } from '../../lib/database'
import { badRequestError, badRequestFormError, unauthorizedError, serverError } from '../../lib/errors'
import { tokenFromHeader } from '../../lib/http'
import { attachMedia } from '../../lib/media'
import { createInstallationId } from '../../lib/utils'
import {
User,
UserToken,
Group,
GroupInvitation,
GroupMembership,
UserItemType,
UserPrivacyType,
GroupItemType,
GroupMembershipType,
GroupRegistrationType,
App,
Installation,
} from '../../types/collections'
import { PluginOptions } from '../../types'
function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
id: string
name: string
email: string
password: string
requiresApproval: boolean
privacy: string
about?: string
imageUrl?: string
coverImageUrl?: string
theme: string
group?: string
invitation?: string
}
const options: RouteShorthandOptions = {
schema: {
body: {
type: 'object',
required: ['id', 'email', 'password', 'theme'],
properties: {
id: {
type: 'string',
minLength: MIN_ID_LENGTH,
maxLength: MAX_ID_LENGTH,
},
name: {
type: 'string',
maxLength: MAX_NAME_LENGTH,
},
email: {
type: 'string',
format: 'email',
},
password: {
type: 'string',
minLength: MIN_PASSWORD_LENGTH,
},
requiresApproval: { type: 'boolean' },
privacy: {
type: 'string',
enum: ['public', 'group', 'subscribers', 'private'],
},
about: { type: 'string' },
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
theme: { type: 'string' },
group: { type: 'string' },
invitation: { type: 'string' },
},
},
response: {
201: tokenResponseSchema,
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/register', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const { name, email, password, requiresApproval, privacy, about, imageUrl, coverImageUrl, theme, invitation: code } = request.body
const id = normalize(request.body.id)
const userContainer = containerFor(server.database.client, 'Users')
const groupContainer = containerFor(server.database.client, 'Groups')
const existingUser = await getItem<User>({ container: userContainer, id })
if (existingUser) return badRequestFormError(reply, 'id', 'User id already taken')
let userPending = false
let invitation: GroupInvitation | undefined
let group: Group | undefined
if (request.body.group) {
group = await getItem<Group>({ container: groupContainer, id: request.body.group })
if (!group) return badRequestFormError(reply, 'group', 'Group not found')
if (code) {
const invitationQuery = createQuerySpec(`
SELECT * FROM Groups g WHERE
g.id = @code
g.pk = @group AND
g.t = @type AND
g.active = true AND
g.expiration < GETCURRENTTIMESTAMP()
`, {
code,
group: group.id,
type: GroupItemType.Invitation,
})
const invitations = await queryItems<GroupInvitation>({
container: groupContainer,
query: invitationQuery,
logger: request.log,
})
if (invitations.length === 0) return badRequestFormError(reply, 'invitation', 'Invalid invitation code')
invitation = invitations[0]
}
if (group.registration === GroupRegistrationType.Closed && !invitation) return badRequestFormError(reply, 'group', 'Group registration closed')
if (group.registration === GroupRegistrationType.Approval) userPending = true
}
const appContainer = containerFor(server.database.client, 'Apps')
const apps = await queryItems<App>({
container: appContainer,
query: 'SELECT * FROM Apps a WHERE a.active = true AND a.preinstall = true',
logger: request.log,
})
const installations: string[] = []
for (const app of apps) {
const installation: Installation = {
id: createInstallationId(),
pk: INSTALLATION_PARTITION_KEY,
userId: id,
appId: app.id,
settings: {},
created: Date.now(),
}
await appContainer.items.create<Installation>(installation)
installations.push(installation.id)
}
const user: User = {
id,
pk: id,
t: UserItemType.User,
groupId: group ? group.id : undefined,
name,
about,
email,
emailVerified: false,
passwordHash: await hashPassword(password),
imageUrl,
coverImageUrl,
theme,
installations,
awards: 0,
points: 0,
balance: 0,
posts: 0,
subscriberCount: 0,
subscribedCount: 0,
pending: userPending,
requiresApproval,
privacy: privacy as UserPrivacyType,
paid: false,
active: true,
created: Date.now(),
}
const refreshToken = createRefreshToken(id, request.headers['user-agent'], request.ip)
await userContainer.items.create<User>(user)
await userContainer.items.create<UserToken>(refreshToken)
if (group) {
await groupContainer.items.create<GroupMembership>({
pk: group.id,
t: GroupItemType.Membership,
userId: user.id,
pending: userPending,
membership: GroupMembershipType.Member,
invitation: code,
created: Date.now(),
})
if (invitation) {
const invitationItem = groupContainer.item(invitation.id, group.id)
const uses = invitation.uses + 1
await invitationItem.replace<GroupInvitation>({
...invitation,
uses,
active: invitation.limit ? uses > invitation.limit : true,
})
}
}
const mediaContainer = containerFor(server.database.client, 'Media')
if (imageUrl) await attachMedia(mediaContainer, imageUrl)
if (coverImageUrl) await attachMedia(mediaContainer, coverImageUrl)
return {
id,
access: await createAccessToken(id),
refresh: refreshToken.id,
}
})
}
function authenticateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
id: string
password: string
}
const options: RouteShorthandOptions = {
schema: {
body: {
type: 'object',
required: ['id', 'password'],
properties: {
id: { type: 'string' },
password: { type: 'string' },
},
},
response: {
200: tokenResponseSchema,
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/authenticate', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const container = containerFor(server.database.client, 'Users')
const id = normalize(request.body.id)
const user = await getItem<User>({ container, id })
if (!user) return badRequestFormError(reply, 'id', 'User not found')
const result = await comparePassword(request.body.password, user.passwordHash)
if (!result) return badRequestFormError(reply, 'password', 'Incorrect password')
const refreshToken = createRefreshToken(user.id, request.headers['user-agent'], request.ip)
await container.items.create(refreshToken)
return {
id,
access: await createAccessToken(user.id),
refresh: refreshToken.id,
}
})
}
function refreshRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Headers {
[key: string]: string
authorization: string
}
interface Body {
refresh: string
}
const options: RouteShorthandOptions = {
schema: {
headers: {
type: 'object',
properties: {
authorization: { type: 'string' },
},
},
body: {
type: 'object',
required: ['refresh'],
properties: {
refresh: { type: 'string' },
},
},
response: {
201: tokenResponseSchema,
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, Headers, Body>('/api/refresh', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const tokenString = tokenFromHeader(request.headers.authorization)
if (!tokenString) return badRequestError(reply, 'Access token required')
let userId: string | undefined
try {
const tokenData = await JWT.verify(tokenString, {
ignoreExpiration: true,
})
if (!tokenData.sub) return badRequestError(reply, 'Invalid token')
if (!tokenData.exp) return badRequestError(reply, 'Invalid token')
if ((tokenData.exp * 1000) > Date.now()) return badRequestError(reply, 'Token must be expired')
userId = tokenData.sub
} catch (err) {
request.log.error(err)
return badRequestError(reply, 'Invalid token')
}
const container = containerFor(server.database.client, 'Users')
const tokenItem = container.item(request.body.refresh, userId)
const { resource: token } = await tokenItem.read<UserToken>()
if (!token) return badRequestError(reply, 'Invalid refresh token')
if (token.expires < Date.now()) return badRequestError(reply, 'Refresh token expired')
await tokenItem.delete()
const newRefreshToken = createRefreshToken(userId, request.headers['user-agent'], request.ip)
await container.items.create(newRefreshToken)
reply.code(201)
return {
id: userId,
access: await createAccessToken(userId),
refresh: newRefreshToken.id,
expires: newRefreshToken.expires,
}
})
}
function selfRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
const options: RouteShorthandOptions = {
schema: {
response: {
200: selfSchema,
400: errorSchema,
},
},
}
server.get<DefaultQuery, DefaultParams, DefaultHeaders, DefaultBody>('/api/self', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const viewer = await getUser(server.database.client, request.viewer.id)
if (!viewer) return unauthorizedError(reply)
return viewer
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
registerRoute(server)
authenticateRoute(server)
refreshRoute(server)
selfRoute(server)
}
export default plugin