Dwayne Harris 5 years ago
parent
commit
f8fb7b0249
  1. 12
      src/lib/crypto.ts
  2. 2
      src/lib/http.ts
  3. 2
      src/lib/util.ts
  4. 27
      src/plugins/api/authentication.ts
  5. 12
      src/plugins/api/groups.ts
  6. 3
      src/plugins/api/index.ts
  7. 88
      src/plugins/api/posts.ts
  8. 8
      src/plugins/api/profile.ts
  9. 4
      src/server.ts
  10. 19
      src/types/collections.ts
  11. 4
      tsconfig.json

12
src/lib/crypto.ts

@ -15,9 +15,9 @@ export namespace JWT {
exp?: number
}
export function sign(data: IJWTData, options?: SignOptions): Promise<string> {
export function sign(data: IJWTData, options: SignOptions = {}): Promise<string> {
return new Promise<string>((resolve, reject) => {
jwt.sign(data, process.env.TOKEN_SECRET, options, (err, token) => {
jwt.sign(data, process.env.TOKEN_SECRET!, options, (err, token) => {
if (err) return reject(err)
resolve(token)
})
@ -26,10 +26,12 @@ export namespace JWT {
export function verify(token: string, options?: VerifyOptions): Promise<IJWTData> {
return new Promise<IJWTData>((resolve, reject) => {
jwt.verify(token, process.env.TOKEN_SECRET, options, (err, data: IJWTData) => {
jwt.verify(token, process.env.TOKEN_SECRET!, options, (err, decoded: IJWTData | string) => {
if (err) return reject(err)
resolve(data)
if (typeof decoded === 'string') return reject('Invalid token')
resolve(decoded)
})
})
}
}
}

2
src/lib/http.ts

@ -1,4 +1,4 @@
export function tokenFromHeader(authorization: string): string {
export function tokenFromHeader(authorization: string): string | undefined {
if (!authorization) return
const [type, token] = authorization.split(' ')

2
src/lib/util.ts

@ -1,6 +1,6 @@
import { v1 } from 'uuid'
export function trimContent(content: string, length: number = 128): string {
export function trimContent(content?: string, length: number = 128): string {
if (!content) return ''
if (content.length < length) return content.trim()

27
src/plugins/api/authentication.ts

@ -14,10 +14,10 @@ import { Server, IncomingMessage, ServerResponse } from 'http'
import { createAccessToken, createRefreshToken } from '../../lib/authentication'
import { hashPassword, verifyPassword, JWT } from '../../lib/crypto'
import { containerFor, normalize } from '../../lib/database'
import { errorSchema, badRequestError, unauthorizedError } from '../../lib/errors'
import { errorSchema, badRequestError, unauthorizedError, serverError } from '../../lib/errors'
import { tokenFromHeader } from '../../lib/http'
import { IUser, IUserToken, IGroup } from '../../types/collections'
import { IUser, IUserToken, IGroup, IGroupPartial } from '../../types/collections'
interface PluginOptions {
@ -62,6 +62,8 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/register', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const { name, email, password, groupId } = request.body
const id = normalize(request.body.id)
@ -78,6 +80,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
if (existingUser) return badRequestError(reply, 'User id already taken', 'id')
let userPending = false
let groupPartial: IGroupPartial | undefined
if (groupId) {
const groupContainer = await containerFor(server.database.client, 'Groups')
@ -86,13 +89,20 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
if (!group) return badRequestError(reply, 'Group not found', 'groupId')
if (!group.open) return badRequestError(reply, 'Group registration closed', 'groupId')
if (group.requiresApproval) userPending = true
groupPartial = {
id: group.id,
name: group.name,
imageUrl: group.imageUrl,
coverImageUrl: group.coverImageUrl,
}
}
const user: IUser = {
id,
partitionKey: id,
type: 'user',
group: groupId,
group: groupPartial,
name,
email,
emailVerified: false,
@ -123,8 +133,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
function authenticateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
id?: string
email?: string
id: string
password: string
}
@ -146,6 +155,8 @@ function authenticateRoute(server: FastifyInstance<Server, IncomingMessage, Serv
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/authenticate', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const { password } = request.body
const id = normalize(request.body.id)
const container = await containerFor(server.database.client, 'Users')
@ -201,6 +212,8 @@ function refreshRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
}
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')
@ -210,6 +223,9 @@ function refreshRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
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
@ -254,6 +270,7 @@ function selfRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespon
}
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 container = await containerFor(server.database.client, 'Users')

12
src/plugins/api/groups.ts

@ -8,7 +8,7 @@ import fastify, {
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, badRequestError } from '../../lib/errors'
import { unauthorizedError, badRequestError, serverError } from '../../lib/errors'
import { containerFor, normalize } from '../../lib/database'
import { IUser, IGroup, IGroupMembership } from '../../types/collections'
@ -48,6 +48,7 @@ function createRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/group', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const userContainer = await containerFor(server.database.client, 'Users')
@ -56,7 +57,7 @@ function createRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
const groupContainer = await containerFor(server.database.client, 'Groups')
if (viewer.group !== '') return badRequestError(reply, 'Invalid operation')
if (viewer.group) return badRequestError(reply, 'Invalid operation')
const { name, about, open, requiresApproval } = request.body
const normalizedName = normalize(name)
@ -88,7 +89,12 @@ function createRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
await viewerItem.replace<IUser>({
...viewer,
group: group.id,
group: {
id: group.id,
name: group.name,
imageUrl: group.imageUrl,
coverImageUrl: group.coverImageUrl,
},
})
return {

3
src/plugins/api/index.ts

@ -35,7 +35,7 @@ interface PluginOptions {
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async (server, options) => {
const database: IDatabase = {
client: new CosmosClient({
endpoint: process.env.DATABASE_ENDPOINT,
endpoint: process.env.DATABASE_ENDPOINT!,
key: process.env.DATABASE_PRIMARY_KEY,
}),
}
@ -49,6 +49,7 @@ const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = a
try {
const data: JWT.IJWTData = await JWT.verify(token)
if (!data.sub) throw new Error('Invalid token')
req.viewer = {
id: data.sub

88
src/plugins/api/posts.ts

@ -9,7 +9,9 @@ import fastify, {
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, badRequestError, notFoundError } from '../../lib/errors'
import difference from 'lodash/difference'
import { unauthorizedError, serverError, badRequestError, notFoundError } from '../../lib/errors'
import { trimContent, createPostId } from '../../lib/util'
import { getUsers } from '../../lib/collections'
@ -27,6 +29,7 @@ import {
IUserSubscription,
IUserTimelinePost,
IUserBlock,
IGroupBlock,
IPostRelationship,
IStatus,
} from '../../types/collections'
@ -112,9 +115,10 @@ function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/post', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
let newPostRelationship: IPostRelationship = null
let newPostRelationship: IPostRelationship | undefined
const postContainer = await containerFor(server.database.client, 'Posts')
const postRelationshipContainer = await containerFor(server.database.client, 'PostRelationships')
const userContainer = await containerFor(server.database.client, 'Users')
@ -122,7 +126,7 @@ function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>()
if (viewer.pending) return badRequestError(reply, 'User requires approval')
if (viewer.group === '') return badRequestError(reply, 'User must belong to a group')
if (!viewer.group) return badRequestError(reply, 'User must belong to a group')
const postId = createPostId()
@ -175,7 +179,7 @@ function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
for (const subscriber of subscribers) {
await userContainer.items.create<IUserTimelinePost>({
postId,
partitionKey: subscriber.id,
partitionKey: subscriber.id!,
type: 'timeline',
created: Date.now(),
})
@ -217,6 +221,8 @@ function postsByUserRoute(server: fastify.FastifyInstance<Server, IncomingMessag
}
server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/user/:id/posts', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const id = normalize(request.params.id)
const userContainer = await containerFor(server.database.client, 'Users')
const postContainer = await containerFor(server.database.client, 'Posts')
@ -250,6 +256,8 @@ function postsByUserRoute(server: fastify.FastifyInstance<Server, IncomingMessag
if (request.viewer) {
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>()
if (!viewer.group) return unauthorizedError(reply)
const query = createQuerySpec(`
SELECT u.id FROM Users u WHERE
(u.blockedId = @viewer OR u.blockedId = @viewerGroup)
@ -257,7 +265,7 @@ function postsByUserRoute(server: fastify.FastifyInstance<Server, IncomingMessag
AND u.type = 'block'
`, {
viewer: viewer.id,
viewerGroup: viewer.group,
viewerGroup: viewer.group.id,
user: user.id,
})
@ -268,13 +276,9 @@ function postsByUserRoute(server: fastify.FastifyInstance<Server, IncomingMessag
const userPostsQuery = createQuerySpec(`SELECT p.id FROM Users p WHERE p.partitionKey = @user AND p.type = 'post'`, { user: id })
const { resources: userPosts } = await userContainer.items.query<IUserPost>(userPostsQuery, {}).fetchAll()
const { resources: posts } = await postContainer.items.query<IPost>({
query: 'SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@posts, p.id)',
parameters: [{
name: '@posts',
value: userPosts.map(p => p.id),
}]
}, {}).fetchAll()
const { resources: posts } = await postContainer.items.query<IPost>(createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@posts, p.id)', {
posts: userPosts.map(p => p.id!),
}), {}).fetchAll()
return {
user,
@ -320,6 +324,8 @@ function postRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Serv
}
server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/post/:id', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const postContainer = await containerFor(server.database.client, 'Posts')
const { resource: post } = await postContainer.item(request.params.id, request.params.id).read<IPost>()
@ -331,29 +337,55 @@ function postRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Serv
const { resources: descendantRelationships } = await postRelationshipContainer.items.query<IPostRelationship>(query, {}).fetchAll()
const { resources: descendants } = await postContainer.items.query<IPost>({
query: 'SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@descendants, p.id)',
parameters: [{
name: '@descendants',
value: descendantRelationships.map(r => r.id),
}]
}, {}).fetchAll()
const { resources: descendants } = await postContainer.items.query<IPost>(createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@descendants, p.id)', {
descendants: descendantRelationships.map(r => r.id),
}), {}).fetchAll()
const { resources: ancestors } = await postContainer.items.query<IPost>({
query: 'SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@parents, p.id)',
parameters: [{
name: '@parents',
value: post.parents,
}]
}, {}).fetchAll()
const { resources: ancestors } = await postContainer.items.query<IPost>(createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@parents, p.id)', {
parents: post.parents,
}), {}).fetchAll()
const getUserId = (post: IPost) => post.userId
const users = await getUsers(server.database.client, [
const userIds = [
...descendants.map(getUserId),
...ancestors.map(getUserId),
getUserId(post),
])
]
const users = await getUsers(server.database.client, userIds)
if (request.viewer) {
const userContainer = await containerFor(server.database.client, 'Users')
const groupContainer = await containerFor(server.database.client, 'Groups')
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>()
if (!viewer.group) return unauthorizedError(reply)
const blockQuery = createQuerySpec(`
SELECT g.userId FROM Groups g WHERE
g.partitionKey = @viewerGroup AND
g.type = 'block' AND
(g.blockedId = @viewer OR g.blockedId = @viewerGroup)
ARRAY_CONTAINS(@ids, g.userId)
`, {
viewer: viewer.id,
viewerGroup: viewer.group.id,
ids: userIds,
})
const { resources: blocks } = await groupContainer.items.query<IGroupBlock>(blockQuery, {}).fetchAll()
const blockedUserIds = blocks.map(b => b.userId)
if (blockedUserIds.includes(post.userId)) return unauthorizedError(reply)
return {
post,
descendants: descendants.filter(p => !blockedUserIds.includes(p.userId)),
ancestors: ancestors.filter(p => !blockedUserIds.includes(p.userId)),
users: users.filter(u => !blockedUserIds.includes(u.id)),
}
}
return {
post,

8
src/plugins/api/profile.ts

@ -54,6 +54,7 @@ function updateRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
}
server.put<DefaultQuery, DefaultParams, Headers, Body>('/api/self', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const container = await containerFor(server.database.client, 'Users')
@ -102,6 +103,7 @@ function subscribeRoute(server: fastify.FastifyInstance<Server, IncomingMessage,
}
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/user/:id/subscribe', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const userContainer = await containerFor(server.database.client, 'Users')
@ -113,6 +115,7 @@ function subscribeRoute(server: fastify.FastifyInstance<Server, IncomingMessage,
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>()
if (!user) return notFoundError(reply)
if (!viewer.group) return unauthorizedError(reply)
const subscriptionQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.subscriberId = @user AND u.partitionKey = @viewer AND u.type = 'subscription'`, {
user: user.id,
@ -142,7 +145,7 @@ function subscribeRoute(server: fastify.FastifyInstance<Server, IncomingMessage,
(g.blockedId = @viewer OR g.blockedId = @viewerGroup)
`, {
user: user.id,
viewerGroup: viewer.group
viewerGroup: viewer.group.id,
})
const { resources: blocks } = await groupContainer.items.query<IGroupBlock>(blockQuery, {}).fetchAll()
@ -177,6 +180,7 @@ function unsubscribeRoute(server: fastify.FastifyInstance<Server, IncomingMessag
}
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/user/:id/unsubscribe', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const userContainer = await containerFor(server.database.client, 'Users')
@ -193,7 +197,7 @@ function unsubscribeRoute(server: fastify.FastifyInstance<Server, IncomingMessag
const { resources: subscriptions } = await userContainer.items.query<IUserSubscription>(subscriptionQuery, {}).fetchAll()
for (const subscription of subscriptions) {
await userContainer.item(subscription.id, viewer.id).delete()
await userContainer.item(subscription.id!, viewer.id).delete()
}
reply.code(204)

4
src/server.ts

@ -18,11 +18,11 @@ server.register(api)
const start = async () => {
try {
await server.listen(process.env.PORT)
await server.listen(parseInt(process.env.PORT!))
} catch (err) {
server.log.error(err)
process.exit(1)
}
}
start()
start()

19
src/types/collections.ts

@ -22,13 +22,22 @@ export interface IGroup {
type: 'group'
userId: string
name: string // Prenormalized ID
about: string
about?: string
imageUrl?: string
coverImageUrl?: string
open: boolean
requiresApproval: boolean
members: number
created: number
}
export interface IGroupPartial {
id: string
name: string
imageUrl?: string
coverImageUrl?: string
}
export interface IGroupMembership {
id: string
partitionKey: string // Group ID
@ -45,7 +54,7 @@ export interface IGroupReport {
type: 'report'
postId: string
reportedById: string
description: string
description?: string
status: string
created: number
}
@ -63,8 +72,11 @@ export interface IUser {
id: string
partitionKey: string // ID
type: 'user'
group: string
group?: IGroupPartial
name: string
about?: string
imageUrl?: string
coverImageUrl?: string
email: string
emailVerified: boolean
passwordHash: string
@ -75,7 +87,6 @@ export interface IUser {
pending: boolean
privacy: IUserPrivacyType
paid: boolean
about: string
created: number // Timestamp
}

4
tsconfig.json

@ -4,11 +4,11 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"strict": true,
"paths": {
"*": [
"node_modules/*",
@ -19,4 +19,4 @@
"include": [
"src/**/*",
],
}
}
Loading…
Cancel
Save