Dwayne Harris 5 years ago
parent
commit
3a992d7481
  1. 42
      src/lib/collections.ts
  2. 17
      src/lib/database.ts
  3. 4
      src/lib/errors.ts
  4. 54
      src/plugins/api/authentication.ts
  5. 146
      src/plugins/api/groups.ts
  6. 6
      src/plugins/api/index.ts
  7. 137
      src/plugins/api/posts.ts
  8. 213
      src/plugins/api/profile.ts
  9. 339
      src/plugins/api/users.ts
  10. 43
      src/schemas.ts
  11. 4
      src/types/collections.ts

42
src/lib/collections.ts

@ -1,21 +1,45 @@
import { CosmosClient } from '@azure/cosmos'
import uniq from 'lodash/uniq'
import { containerFor, IDatabaseItem } from './database'
import { IUser } from '../types/collections'
import { containerFor, createQuerySpec, IDatabaseItem } from './database'
import { IUser, IUserSubscription, IUserBlock } from '../types/collections'
export async function getUsers(client: CosmosClient, ids: string[]): Promise<IUser[]> {
const container = await containerFor(client, 'Users')
const { resources: users } = await container.items.query<IUser>({
query: 'SELECT u.id, u.name, u.created FROM Users u WHERE ARRAY_CONTAINS(@ids, u.id)',
parameters: [{
name: '@ids',
value: uniq(ids),
}],
}, {}).fetchAll()
const { resources: users } = await container.items.query<IUser>(createQuerySpec(
'SELECT u.id, u.name, u.imageUrl, u.coverImageUrl, u.group, u.created FROM Users u WHERE ARRAY_CONTAINS(@ids, u.id)', {
ids: uniq(ids),
}), {}).fetchAll()
return users
}
export async function getUsersFromItems<T extends IDatabaseItem>(client: CosmosClient, items: T[]): Promise<IUser[]> {
return await getUsers(client, items.map(i => i.id))
}
export async function getSubscriptions(client: CosmosClient, from: string, to: string): Promise<IUserSubscription[]> {
const query = createQuerySpec(`
SELECT u.id FROM Users u WHERE
u.subscriberId = @to
u.partitionKey = @from AND
u.type = 'subscription' AND
u.pending = false
`, { from, to })
const { resources: subscriptions } = await containerFor(client, 'Users').items.query<IUserSubscription>(query, {}).fetchAll()
return subscriptions
}
export async function getUserBlocks(client: CosmosClient, from: string, to: string[]): Promise<IUserBlock[]> {
const query = createQuerySpec(`
SELECT u.id FROM Users u WHERE
u.partitionKey = @from
AND u.type = 'block'
AND ARRAY_CONTAINS(@to, u.blockedId)
`, { from, to })
const { resources: blocks } = await containerFor(client, 'Users').items.query<IUserBlock>(query, {}).fetchAll()
return blocks
}

17
src/lib/database.ts

@ -8,11 +8,8 @@ export interface IDatabaseItem {
id: string
}
export async function containerFor(client: CosmosClient, containerId: string): Promise<Container> {
const { database } = await client.databases.createIfNotExists({ id: 'Flexor' })
const { container } = await database.containers.createIfNotExists({ id: containerId })
return container
export function containerFor(client: CosmosClient, containerId: string): Container {
return client.database('Flexor').container(containerId)
}
export function createQuerySpec(query: string, params: IQueryParams = {}): SqlQuerySpec {
@ -27,6 +24,16 @@ export function createQuerySpec(query: string, params: IQueryParams = {}): SqlQu
}
}
export async function queryItems<T>(container: Container, querySpec: SqlQuerySpec): Promise<T[]> {
const { resources } = await container.items.query<T>(querySpec, {}).fetchAll()
return resources
}
export async function getItem<T>(container: Container, id: string, partitionKey?: string): Promise<T | undefined> {
const { resource } = await container.item(id, partitionKey || id).read<T>()
return resource
}
export function normalize(text: string): string {
return text.replace(/[^A-Za-z0-9]/g, '-').toLowerCase()
}

4
src/lib/errors.ts

@ -16,7 +16,7 @@ export const errorSchema: JSONSchema = {
}
}
export function badRequestError(reply: FastifyReply<ServerResponse>, error: string, field?: string): IHttpError {
export function badRequestError(reply: FastifyReply<ServerResponse>, error: string = 'Bad Request', field?: string): IHttpError {
const statusCode = 400
reply.code(statusCode)
@ -43,7 +43,7 @@ export function notFoundError(reply: FastifyReply<ServerResponse>): IHttpError {
return {
statusCode,
error: 'Not found'
error: 'Not Found'
}
}

54
src/plugins/api/authentication.ts

@ -6,31 +6,20 @@ import {
DefaultHeaders,
DefaultBody,
RouteShorthandOptions,
JSONSchema,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { tokenResponseSchema, userSchema } from '../../schemas'
import { createAccessToken, createRefreshToken } from '../../lib/authentication'
import { hashPassword, verifyPassword, JWT } from '../../lib/crypto'
import { containerFor, normalize } from '../../lib/database'
import { containerFor, getItem, normalize } from '../../lib/database'
import { errorSchema, badRequestError, unauthorizedError, serverError } from '../../lib/errors'
import { tokenFromHeader } from '../../lib/http'
import { IUser, IUserToken, IGroup, IGroupPartial } from '../../types/collections'
interface PluginOptions {
}
const tokenResponseSchema: JSONSchema = {
type: 'object',
properties: {
id: { type: 'string' },
access: { type: 'string' },
refresh: { type: 'string' },
},
}
interface PluginOptions {}
function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
@ -75,19 +64,18 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
if (!password || password === '') return badRequestError(reply, 'password is required', 'password')
if (password.length < 5) return badRequestError(reply, 'password must be at least 5 characters', 'password')
const userContainer = await containerFor(server.database.client, 'Users')
const { resource: existingUser } = await userContainer.item(id, id).read<IUser>()
const userContainer = containerFor(server.database.client, 'Users')
const existingUser = await getItem<IUser>(userContainer, id)
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')
const { resource: group } = await groupContainer.item(groupId, groupId).read<IGroup>()
const group = await getItem<IGroup>(containerFor(server.database.client, 'Groups'), groupId)
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 = {
@ -157,10 +145,10 @@ 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 container = containerFor(server.database.client, 'Users')
const id = normalize(request.body.id)
const container = await containerFor(server.database.client, 'Users')
const { resource: user } = await container.item(id, id).read<IUser>()
const { password } = request.body
const user = await getItem<IUser>(container, id)
if (!user) return badRequestError(reply, 'User not found')
@ -168,7 +156,6 @@ function authenticateRoute(server: FastifyInstance<Server, IncomingMessage, Serv
if (!result) return badRequestError(reply, 'Incorrect credentials')
const refreshToken = createRefreshToken(user.id, request.headers['user-agent'], request.ip)
await container.items.create(refreshToken)
return {
@ -217,7 +204,7 @@ function refreshRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
const tokenString = tokenFromHeader(request.headers.authorization)
if (!tokenString) return badRequestError(reply, 'Access token required')
let userId: string = ''
let userId: string | undefined
try {
const tokenData = await JWT.verify(tokenString, {
@ -233,7 +220,7 @@ function refreshRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
return badRequestError(reply, 'Invalid token')
}
const container = await containerFor(server.database.client, 'Users')
const container = containerFor(server.database.client, 'Users')
const tokenItem = container.item(request.body.refresh, userId)
if (!tokenItem) return badRequestError(reply, 'Invalid refresh token')
@ -258,13 +245,7 @@ function selfRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespon
const options: RouteShorthandOptions = {
schema: {
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
},
200: userSchema,
},
},
}
@ -273,15 +254,10 @@ function selfRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespon
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const container = await containerFor(server.database.client, 'Users')
const { resource: viewer } = await container.item(request.viewer.id, request.viewer.id).read<IUser>()
const viewer = await getItem<IUser>(containerFor(server.database.client, 'Users'), request.viewer.id)
if (!viewer) return unauthorizedError(reply)
return {
id: viewer.id,
name: viewer.name,
}
return viewer
})
}

146
src/plugins/api/groups.ts

@ -1,4 +1,5 @@
import fastify, {
import {
FastifyInstance,
Plugin,
DefaultQuery,
DefaultParams,
@ -8,15 +9,13 @@ import fastify, {
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, badRequestError, serverError } from '../../lib/errors'
import { containerFor, normalize } from '../../lib/database'
import { IUser, IGroup, IGroupMembership } from '../../types/collections'
import { unauthorizedError, badRequestError, notFoundError, serverError } from '../../lib/errors'
import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'
import { IUser, IGroup, IGroupMembership, IUserBlock, IGroupBlock } from '../../types/collections'
interface PluginOptions {
interface PluginOptions {}
}
function createRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
function createRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
name: string
about?: string
@ -51,19 +50,22 @@ function createRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const userContainer = await containerFor(server.database.client, 'Users')
const viewerItem = userContainer.item(request.viewer.id, request.viewer.id)
const viewerItem = containerFor(server.database.client, 'Users').item(request.viewer.id, request.viewer.id)
const { resource: viewer } = await viewerItem.read<IUser>()
const groupContainer = await containerFor(server.database.client, 'Groups')
const groupContainer = containerFor(server.database.client, 'Groups')
if (viewer.group) return badRequestError(reply)
if (viewer.group) return badRequestError(reply, 'Invalid operation')
const { name, about, open, requiresApproval } = request.body
const normalizedName = normalize(name)
const id = normalize(name)
const existingGroup = await getItem<IGroup>(groupContainer, id)
if (existingGroup) return badRequestError(reply, 'Name already used')
const group: IGroup = {
id: normalizedName,
partitionKey: normalizedName,
id: id,
partitionKey: id,
type: 'group',
userId: request.viewer.id,
name,
@ -76,7 +78,7 @@ function createRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
const membership: IGroupMembership = {
id: request.viewer.id,
partitionKey: normalizedName,
partitionKey: id,
type: 'membership',
userId: request.viewer.id,
pending: false,
@ -103,8 +105,120 @@ function createRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
})
}
function blockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
interface Body {
description?: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
body: {
type: 'object',
properties: {
description: { type: 'string' },
},
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, Body>('/api/group/:id/block', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const groupContainer = containerFor(server.database.client, 'Groups')
const group = await getItem<IGroup>(groupContainer, request.params.id)
if (!group) return notFoundError(reply)
await containerFor(server.database.client, 'Users').items.create<IUserBlock>({
blockedId: group.id,
partitionKey: request.viewer.id,
type: 'block',
blockType: 'group',
description: request.body.description,
created: Date.now(),
})
await groupContainer.items.create<IGroupBlock>({
partitionKey: group.id,
type: 'block',
blockedId: group.id,
userId: request.viewer.id,
created: Date.now(),
})
reply.code(204)
})
}
function unblockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, Body>('/api/group/:id/unblock', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const userContainer = containerFor(server.database.client, 'Users')
const groupContainer = containerFor(server.database.client, 'Groups')
const group = await getItem<IGroup>(groupContainer, request.params.id)
if (!group) return notFoundError(reply)
const userBlockQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.partitionKey = @partitionKey AND u.blockedId = @blocked AND u.type = 'block'`, {
partitionKey: request.viewer.id,
blocked: group.id,
})
const userBlocks = await queryItems<IUserBlock>(userContainer, userBlockQuery)
for (const userBlock of userBlocks) {
await userContainer.item(userBlock.id!, request.viewer.id).delete()
}
const groupBlockQuery = createQuerySpec(
`SELECT g.id FROM Groups g WHERE g.partitionKey = @partitionKey AND u.blockedId = @blocked AND u.userId = @viewer AND u.type = 'block'`,
{
partitionKey: group.id,
blocked: group.id,
viewer: request.viewer.id,
}
)
const groupBlocks = await queryItems<IUserBlock>(groupContainer, groupBlockQuery)
for (const groupBlock of groupBlocks) {
await groupContainer.item(groupBlock.id!, group.id).delete()
}
reply.code(204)
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
createRoute(server)
blockRoute(server)
unblockRoute(server)
}
export default plugin

6
src/plugins/api/index.ts

@ -8,7 +8,7 @@ import { tokenFromHeader } from '../../lib/http'
import authentication from './authentication'
import groups from './groups'
import posts from './posts'
import profile from './profile'
import users from './users'
interface IDatabase {
client: CosmosClient
@ -32,7 +32,7 @@ interface PluginOptions {
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async (server, options) => {
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
const database: IDatabase = {
client: new CosmosClient({
endpoint: process.env.DATABASE_ENDPOINT!,
@ -63,7 +63,7 @@ const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = a
server.register(authentication)
server.register(groups)
server.register(posts)
server.register(profile)
server.register(users)
}
export default plugin

137
src/plugins/api/posts.ts

@ -1,25 +1,20 @@
import fastify, {
import {
FastifyInstance,
Plugin,
DefaultQuery,
DefaultParams,
DefaultHeaders,
DefaultBody,
RouteShorthandOptions,
JSONSchema,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import difference from 'lodash/difference'
import { userSchema, postSchema } from '../../schemas'
import { unauthorizedError, serverError, badRequestError, notFoundError } from '../../lib/errors'
import { trimContent, createPostId } from '../../lib/util'
import { getUsers } from '../../lib/collections'
import {
containerFor,
createQuerySpec,
normalize,
} from '../../lib/database'
import { getUsers, getSubscriptions, getUserBlocks } from '../../lib/collections'
import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'
import {
IPost,
@ -28,39 +23,14 @@ import {
IUserPost,
IUserSubscription,
IUserTimelinePost,
IUserBlock,
IGroupBlock,
IPostRelationship,
IStatus,
} from '../../types/collections'
interface PluginOptions {
}
const postSchema: JSONSchema = {
type: 'object',
properties: {
id: { type: 'string' },
userId: { type: 'string' },
text: { type: 'string' },
cover: { type: 'string' },
visible: { type: 'boolean' },
created: { type: 'number' },
},
}
const userSchema: JSONSchema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
group: { type: 'string' },
created: { type: 'number' },
},
}
interface PluginOptions {}
function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
function doPostRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
text?: string
cover?: string
@ -119,11 +89,12 @@ function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
if (!request.viewer) return unauthorizedError(reply)
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')
const postContainer = containerFor(server.database.client, 'Posts')
const postRelationshipContainer = containerFor(server.database.client, 'PostRelationships')
const userContainer = containerFor(server.database.client, 'Users')
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>()
const viewer = await getItem<IUser>(userContainer, request.viewer.id)
if (!viewer) return serverError(reply)
if (viewer.pending) return badRequestError(reply, 'User requires approval')
if (!viewer.group) return badRequestError(reply, 'User must belong to a group')
@ -131,10 +102,10 @@ function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
const postId = createPostId()
if (request.body.parent) {
const { resource: parent } = await postContainer.item(request.body.parent, request.body.parent).read<IPost>()
const parent = await getItem<IPost>(postContainer, request.body.parent)
if (!parent) return badRequestError(reply, 'Invalid parent', 'parent')
const { resource: parentRelationship } = await postRelationshipContainer.item(request.body.parent, parent.root).read<IPostRelationship>()
const parentRelationship = await getItem<IPostRelationship>(postRelationshipContainer, request.body.parent, parent.root)
const parents = parentRelationship ? parentRelationship.parents : []
newPostRelationship = {
@ -173,7 +144,7 @@ function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
if (newPostRelationship) await postRelationshipContainer.items.create<IPostRelationship>(newPostRelationship)
const query = createQuerySpec(`SELECT u.id FROM Users u WHERE u.partitionKey = @partitionKey AND u.type = 'subscription'`, { partitionKey: request.viewer.id })
const { resources: subscribers } = await userContainer.items.query<IUserSubscription>(query, {}).fetchAll()
const subscribers = await queryItems<IUserSubscription>(userContainer, query)
if (subscribers.length < 100) {
for (const subscriber of subscribers) {
@ -192,7 +163,7 @@ function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
})
}
function postsByUserRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
function postsByUserRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
@ -224,61 +195,45 @@ function postsByUserRoute(server: fastify.FastifyInstance<Server, IncomingMessag
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')
const { resource: user } = await userContainer.item(id, id).read<IUser>()
const userContainer = containerFor(server.database.client, 'Users')
const user = await getItem<IUser>(userContainer, id)
if (!user) return notFoundError(reply)
if (!user.group) return notFoundError(reply)
switch (user.privacy) {
case 'approve':
case 'private':
if (!request.viewer) return unauthorizedError(reply)
const query = createQuerySpec(`
SELECT u.id FROM Users u WHERE
u.id = @viewer
u.partitionKey = @user AND
u.type = 'subscription' AND
u.pending = false
`, {
viewer: request.viewer.id,
user: user.id,
})
const { resources: subscriptions } = await userContainer.items.query<IUserSubscription>(query, {}).fetchAll()
const subscriptions = await getSubscriptions(server.database.client, user.id, request.viewer.id)
if (subscriptions.length === 0) return unauthorizedError(reply)
break
case 'group':
if (!request.viewer) return unauthorizedError(reply)
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>()
if (!viewer.group) return unauthorizedError(reply)
if (viewer.group.id !== user.group.id) return unauthorizedError(reply)
break
}
if (request.viewer) {
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>()
const viewer = await getItem<IUser>(userContainer, request.viewer.id)
if (!viewer) return serverError(reply)
if (!viewer.group) return unauthorizedError(reply)
const query = createQuerySpec(`
SELECT u.id FROM Users u WHERE
(u.blockedId = @viewer OR u.blockedId = @viewerGroup)
AND u.partitionKey = @user
AND u.type = 'block'
`, {
viewer: viewer.id,
viewerGroup: viewer.group.id,
user: user.id,
})
const { resources: blocks } = await userContainer.items.query<IUserBlock>(query, {}).fetchAll()
const blocks = await getUserBlocks(server.database.client, user.id, [viewer.id, viewer.group.id])
if (blocks.length > 0) return unauthorizedError(reply)
}
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 userPosts = await queryItems<IUserPost>(userContainer, userPostsQuery)
const { resources: posts } = await postContainer.items.query<IPost>(createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@posts, p.id)', {
const posts = await queryItems<IPost>(containerFor(server.database.client, 'Posts'), createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@posts, p.id)', {
posts: userPosts.map(p => p.id!),
}), {}).fetchAll()
}))
return {
user,
@ -287,7 +242,7 @@ function postsByUserRoute(server: fastify.FastifyInstance<Server, IncomingMessag
})
}
function postRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
function postRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
@ -326,24 +281,24 @@ 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>()
const postContainer = containerFor(server.database.client, 'Posts')
const post = await getItem<IPost>(postContainer, request.params.id)
if (!post) return notFoundError(reply)
const postRelationshipContainer = await containerFor(server.database.client, 'PostRelationships')
const query = createQuerySpec('SELECT * FROM PostRelationships r WHERE r.partitionKey = @partitionKey AND ARRAY_CONTAINS(r.parents, @id)', {
partitionKey: post.root,
id: post.id,
})
const { resources: descendantRelationships } = await postRelationshipContainer.items.query<IPostRelationship>(query, {}).fetchAll()
const descendantRelationships = await queryItems<IPostRelationship>(containerFor(server.database.client, 'PostRelationships'), query)
const { resources: descendants } = await postContainer.items.query<IPost>(createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@descendants, p.id)', {
const descendants = await queryItems<IPost>(postContainer, 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>(createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@parents, p.id)', {
}))
const ancestors = await queryItems<IPost>(postContainer, createQuerySpec('SELECT * FROM Posts p WHERE ARRAY_CONTAINS(@parents, p.id)', {
parents: post.parents,
}), {}).fetchAll()
}))
const getUserId = (post: IPost) => post.userId
@ -356,10 +311,8 @@ function postRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Serv
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>()
const viewer = await getItem<IUser>(containerFor(server.database.client, 'Users'), request.viewer.id)
if (!viewer) return serverError(reply)
if (!viewer.group) return unauthorizedError(reply)
const blockQuery = createQuerySpec(`
@ -374,7 +327,7 @@ function postRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Serv
ids: userIds,
})
const { resources: blocks } = await groupContainer.items.query<IGroupBlock>(blockQuery, {}).fetchAll()
const blocks = await queryItems<IGroupBlock>(containerFor(server.database.client, 'Groups'), blockQuery)
const blockedUserIds = blocks.map(b => b.userId)
if (blockedUserIds.includes(post.userId)) return unauthorizedError(reply)

213
src/plugins/api/profile.ts

@ -1,213 +0,0 @@
import fastify, {
Plugin,
DefaultQuery,
DefaultParams,
RouteShorthandOptions,
DefaultHeaders,
DefaultBody,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, serverError, notFoundError, badRequestError } from '../../lib/errors'
import { containerFor, createQuerySpec } from '../../lib/database'
import { IUser, IUserSubscription, IGroupBlock } from '../../types/collections'
interface PluginOptions {
}
function updateRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Headers {
authorization: string
}
interface Body {
name?: string
about?: string
}
const options: RouteShorthandOptions = {
schema: {
headers: {
type: 'object',
properties: {
authorization: { type: 'string' },
},
},
body: {
type: 'object',
properties: {
name: { type: 'string' },
about: { type: 'string' },
},
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
},
},
},
}
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')
const viewerItem = container.item(request.viewer.id, request.viewer.id)
const { resource: viewer } = await viewerItem.read<IUser>()
if (!viewer) return serverError(reply)
if (request.body.name) {
const name = request.body.name.trim()
if (name !== '') {
viewer.name = name
}
}
if (request.body.about) {
const about = request.body.about.trim()
if (about !== '') {
viewer.about = about
}
}
await viewerItem.replace<IUser>(viewer)
return {
id: viewer.id,
name: viewer.name,
}
})
}
function subscribeRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
}
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')
const groupContainer = await containerFor(server.database.client, 'Groups')
if (request.viewer.id === request.params.id) return badRequestError(reply, 'Invalid operation')
const { resource: user } = await userContainer.item(request.params.id, request.params.id).read<IUser>()
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,
viewer: viewer.id,
})
const { resources: subscriptions } = await userContainer.items.query<IUserSubscription>(subscriptionQuery, {}).fetchAll()
if (subscriptions.length > 0) return badRequestError(reply, 'Already subscribed')
let pending = false
switch (user.privacy) {
case 'private':
return unauthorizedError(reply)
case 'group':
if (user.group !== viewer.group) return unauthorizedError(reply)
case 'approve':
pending = true
break
}
const blockQuery = createQuerySpec(`
SELECT g.id FROM Groups g WHERE
g.partitionKey = @viewerGroup AND
g.type = 'block' AND
g.userId = @user AND
(g.blockedId = @viewer OR g.blockedId = @viewerGroup)
`, {
user: user.id,
viewerGroup: viewer.group.id,
})
const { resources: blocks } = await groupContainer.items.query<IGroupBlock>(blockQuery, {}).fetchAll()
if (blocks.length > 0) return badRequestError(reply, 'Invalid operation')
await userContainer.items.create<IUserSubscription>({
subscriberId: user.id,
partitionKey: request.viewer.id,
type: 'subscription',
pending,
created: Date.now(),
})
reply.code(204)
})
}
function unsubscribeRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
}
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')
const { resource: user } = await userContainer.item(request.params.id, request.params.id).read<IUser>()
const { resource: viewer } = await userContainer.item(request.viewer.id, request.viewer.id).read<IUser>()
if (!user) return notFoundError(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,
viewer: viewer.id,
})
const { resources: subscriptions } = await userContainer.items.query<IUserSubscription>(subscriptionQuery, {}).fetchAll()
for (const subscription of subscriptions) {
await userContainer.item(subscription.id!, viewer.id).delete()
}
reply.code(204)
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
updateRoute(server)
subscribeRoute(server)
unsubscribeRoute(server)
}
export default plugin

339
src/plugins/api/users.ts

@ -0,0 +1,339 @@
import {
FastifyInstance,
Plugin,
DefaultQuery,
DefaultParams,
RouteShorthandOptions,
DefaultHeaders,
DefaultBody,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { userSchema } from '../../schemas'
import { unauthorizedError, serverError, notFoundError, badRequestError } from '../../lib/errors'
import { containerFor, createQuerySpec, queryItems, getItem } from '../../lib/database'
import { IUser, IUserSubscription, IUserBlock, IGroupBlock } from '../../types/collections'
interface PluginOptions {}
function updateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Headers {
authorization: string
}
interface Body {
name?: string
about?: string
}
const options: RouteShorthandOptions = {
schema: {
headers: {
type: 'object',
properties: {
authorization: { type: 'string' },
},
},
body: {
type: 'object',
properties: {
name: { type: 'string' },
about: { type: 'string' },
},
},
response: {
200: userSchema,
},
},
}
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 viewerItem = containerFor(server.database.client, 'Users').item(request.viewer.id, request.viewer.id)
const { resource: viewer } = await viewerItem.read<IUser>()
if (!viewer) return serverError(reply)
if (request.body.name) {
const name = request.body.name.trim()
if (name !== '') {
viewer.name = name
}
}
if (request.body.about) {
const about = request.body.about.trim()
if (about !== '') {
viewer.about = about
}
}
await viewerItem.replace<IUser>(viewer)
return viewer
})
}
function getRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/api/user/:id', options, async (request, reply) => {
})
}
function subscribeRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
}
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)
if (request.viewer.id === request.params.id) return badRequestError(reply)
const userContainer = containerFor(server.database.client, 'Users')
const user = await getItem<IUser>(userContainer, request.params.id)
const viewer = await getItem<IUser>(userContainer, request.viewer.id)
if (!user) return notFoundError(reply)
if (!viewer) return serverError(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,
viewer: viewer.id,
})
const subscriptions = await queryItems<IUserSubscription>(userContainer, subscriptionQuery)
if (subscriptions.length > 0) return badRequestError(reply, 'Already subscribed')
let pending = false
switch (user.privacy) {
case 'private':
return unauthorizedError(reply)
case 'group':
if (user.group !== viewer.group) return unauthorizedError(reply)
case 'approve':
pending = true
break
}
const blockQuery = createQuerySpec(`
SELECT g.id FROM Groups g WHERE
g.partitionKey = @viewerGroup AND
g.type = 'block' AND
g.userId = @user AND
(g.blockedId = @viewer OR g.blockedId = @viewerGroup)
`, {
user: user.id,
viewerGroup: viewer.group.id,
})
const blocks = await queryItems<IGroupBlock>(containerFor(server.database.client, 'Groups'), blockQuery)
if (blocks.length > 0) return badRequestError(reply, 'Invalid operation')
await userContainer.items.create<IUserSubscription>({
subscriberId: user.id,
partitionKey: request.viewer.id,
type: 'subscription',
pending,
created: Date.now(),
})
reply.code(204)
})
}
function unsubscribeRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
}
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 = containerFor(server.database.client, 'Users')
const user = await getItem<IUser>(userContainer, request.params.id)
const viewer = await getItem<IUser>(userContainer, request.viewer.id)
if (!user) return notFoundError(reply)
if (!viewer) return serverError(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,
viewer: viewer.id,
})
const subscriptions = await queryItems<IUserSubscription>(userContainer, subscriptionQuery)
for (const subscription of subscriptions) {
await userContainer.item(subscription.id!, viewer.id).delete()
}
reply.code(204)
})
}
function blockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
interface Body {
description?: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
body: {
type: 'object',
properties: {
description: { type: 'string' },
},
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, Body>('/api/user/:id/block', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const userContainer = containerFor(server.database.client, 'Users')
const user = await getItem<IUser>(userContainer, request.params.id)
if (!user) return notFoundError(reply)
if (!user.group) return badRequestError(reply)
await userContainer.items.create<IUserBlock>({
blockedId: user.id,
partitionKey: request.viewer.id,
type: 'block',
blockType: 'user',
description: request.body.description,
created: Date.now(),
})
await containerFor(server.database.client, 'Groups').items.create<IGroupBlock>({
partitionKey: user.group.id,
type: 'block',
blockedId: user.id,
userId: request.viewer.id,
created: Date.now(),
})
reply.code(204)
})
}
function unblockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, Body>('/api/user/:id/unblock', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const userContainer = containerFor(server.database.client, 'Users')
const groupContainer = containerFor(server.database.client, 'Groups')
const user = await getItem<IUser>(userContainer, request.params.id)
if (!user) return notFoundError(reply)
if (!user.group) return badRequestError(reply, 'Invalid operation')
const userBlockQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.partitionKey = @partitionKey AND u.blockedId = @blocked AND u.type = 'block'`, {
partitionKey: request.viewer.id,
blocked: user.id,
})
const userBlocks = await queryItems<IUserBlock>(userContainer, userBlockQuery)
for (const userBlock of userBlocks) {
await userContainer.item(userBlock.id!, request.viewer.id).delete()
}
const groupBlockQuery = createQuerySpec(
`SELECT g.id FROM Groups g WHERE g.partitionKey = @partitionKey AND u.blockedId = @blocked AND u.userId = @viewer AND u.type = 'block'`,
{
partitionKey: user.group.id,
blocked: user.id,
viewer: request.viewer.id,
}
)
const groupBlocks = await queryItems<IGroupBlock>(groupContainer, groupBlockQuery)
for (const groupBlock of groupBlocks) {
await groupContainer.item(groupBlock.id!, user.group.id).delete()
}
reply.code(204)
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
updateRoute(server)
subscribeRoute(server)
unsubscribeRoute(server)
blockRoute(server)
unblockRoute(server)
}
export default plugin

43
src/schemas.ts

@ -0,0 +1,43 @@
import { JSONSchema } from 'fastify'
export const tokenResponseSchema: JSONSchema = {
type: 'object',
properties: {
id: { type: 'string' },
access: { type: 'string' },
refresh: { type: 'string' },
},
}
export const postSchema: JSONSchema = {
type: 'object',
properties: {
id: { type: 'string' },
userId: { type: 'string' },
text: { type: 'string' },
cover: { type: 'string' },
visible: { type: 'boolean' },
created: { type: 'number' },
},
}
export const userSchema: JSONSchema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
group: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
},
},
created: { type: 'number' },
subscription: { type: 'string' },
},
}

4
src/types/collections.ts

@ -60,7 +60,7 @@ export interface IGroupReport {
}
export interface IGroupBlock {
id: string
id?: string
partitionKey: string // Group ID
type: 'block'
blockedId: string // User or Group ID
@ -123,7 +123,7 @@ export interface IUserBlock {
partitionKey: string
type: 'block'
blockType: IBlockType
description: string
description?: string
created: number
}

Loading…
Cancel
Save