Dwayne Harris 5 years ago
parent
commit
4cbc137e35
  1. 37
      src/plugins/api/authentication.ts
  2. 104
      src/plugins/api/groups.ts
  3. 60
      src/plugins/api/posts.ts
  4. 124
      src/plugins/api/profile.ts
  5. 88
      src/types/collections.ts

37
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, serverError, unauthorizedError } from '../../lib/errors'
import { errorSchema, badRequestError, unauthorizedError } from '../../lib/errors'
import { tokenFromHeader } from '../../lib/http'
import { IUser, IUserToken } from '../../types/collections'
import { IUser, IUserToken, IGroup } from '../../types/collections'
interface PluginOptions {
@ -38,6 +38,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
name: string
email: string
password: string
groupId: string
}
const options: RouteShorthandOptions = {
@ -50,6 +51,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
name: { type: 'string' },
email: { type: 'string' },
password: { type: 'string' },
groupId: { type: 'string' },
},
},
response: {
@ -60,7 +62,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/register', options, async (request, reply) => {
const { name, email, password } = request.body
const { name, email, password, groupId } = request.body
const id = normalize(request.body.id)
if (!id || id === '') return badRequestError(reply, 'id is required', 'id')
@ -71,34 +73,45 @@ 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')
if (!server.database) return serverError(reply, 'Database error')
const container = await containerFor(server.database.client, 'Users')
const { resource: existingUser } = await container.item(id, id).read<IUser>()
const userContainer = await containerFor(server.database.client, 'Users')
const { resource: existingUser } = await userContainer.item(id, id).read<IUser>()
if (existingUser) return badRequestError(reply, 'User id already taken', 'id')
let userPending = false
if (groupId) {
const groupContainer = await containerFor(server.database.client, 'Groups')
const { resource: group } = await groupContainer.item(groupId, groupId).read<IGroup>()
if (!group) return badRequestError(reply, 'Group not found', 'groupId')
if (!group.open) return badRequestError(reply, 'Group registration closed', 'groupId')
if (group.requiresApproval) userPending = true
}
const user: IUser = {
id,
partitionKey: id,
type: 'user',
group: groupId,
name,
email,
emailVerified: false,
passwordHash: await hashPassword(password),
installations: [],
status: 'default',
points: 0,
followerCount: 0,
followingCount: 0,
subscriberCount: 0,
subscribedCount: 0,
pending: userPending,
privacy: 'public',
paid: false,
about: '',
created: Date.now(),
}
const refreshToken = createRefreshToken(id, request.headers['user-agent'], request.ip)
await container.items.create(user)
await container.items.create(refreshToken)
await userContainer.items.create(user)
await userContainer.items.create(refreshToken)
return {
id,

104
src/plugins/api/groups.ts

@ -0,0 +1,104 @@
import fastify, {
Plugin,
DefaultQuery,
DefaultParams,
RouteShorthandOptions,
DefaultHeaders,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, badRequestError } from '../../lib/errors'
import { containerFor, normalize } from '../../lib/database'
import { IUser, IGroup, IGroupMembership } from '../../types/collections'
interface PluginOptions {
}
function createRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
name: string
about?: string
open: boolean
requiresApproval: boolean
}
const options: RouteShorthandOptions = {
schema: {
body: {
type: 'object',
required: ['name', 'open', 'requiresApproval'],
properties: {
name: { type: 'string' },
about: { type: 'string' },
open: { type: 'boolean' },
requiresApproval: { type: 'boolean' },
},
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/group', options, async (request, reply) => {
if (!request.user) return unauthorizedError(reply)
const userContainer = await containerFor(server.database.client, 'Users')
const userItem = userContainer.item(request.user.id, request.user.id)
const { resource: user } = await userItem.read<IUser>()
const groupContainer = await containerFor(server.database.client, 'Groups')
if (user.group !== '') return badRequestError(reply, 'Invalid operation')
const { name, about, open, requiresApproval } = request.body
const normalizedName = normalize(name)
const group: IGroup = {
id: normalizedName,
partitionKey: normalizedName,
type: 'group',
userId: request.user.id,
name,
about,
open,
requiresApproval,
members: 1,
created: Date.now(),
}
const membership: IGroupMembership = {
id: request.user.id,
partitionKey: normalizedName,
type: 'membership',
userId: request.user.id,
pending: false,
membership: 'admin',
created: Date.now(),
}
await groupContainer.items.create(group)
await groupContainer.items.create(membership)
await userItem.replace<IUser>({
...user,
group: group.id,
})
return {
id: group.id,
}
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
createRoute(server)
}
export default plugin

60
src/plugins/api/posts.ts

@ -24,8 +24,9 @@ import {
IPostAttachment,
IUser,
IUserPost,
IUserFollow,
IUserSubscription,
IUserTimelinePost,
IUserBlock,
IPostRelationship,
IStatus,
} from '../../types/collections'
@ -113,11 +114,15 @@ function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
if (!request.user) return unauthorizedError(reply)
let newPostRelationship: IPostRelationship = null
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 { resource: user } = await userContainer.item(request.user.id, request.user.id).read<IUser>()
if (user.pending) return badRequestError(reply, 'User requires approval')
if (user.group === '') return badRequestError(reply, 'User must belong to a group')
const postId = createPostId()
if (request.body.parent) {
@ -151,7 +156,7 @@ function doPostRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
}
const userPost: IUserPost = {
id: postId,
postId,
partitionKey: request.user.id,
type: 'post',
created: Date.now(),
@ -162,14 +167,14 @@ 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 = 'follow'`, { partitionKey: request.user.id })
const { resources: followers } = await userContainer.items.query<IUserFollow>(query, {}).fetchAll()
const query = createQuerySpec(`SELECT u.id FROM Users u WHERE u.partitionKey = @partitionKey AND u.type = 'subscription'`, { partitionKey: request.user.id })
const { resources: subscribers } = await userContainer.items.query<IUserSubscription>(query, {}).fetchAll()
if (followers.length < 500) {
for (let follower in followers) {
if (subscribers.length < 500) {
for (let subscriber in subscribers) {
await userContainer.items.create<IUserTimelinePost>({
id: postId,
partitionKey: followers[follower].id,
postId,
partitionKey: subscribers[subscriber].id,
type: 'timeline',
created: Date.now(),
})
@ -219,7 +224,42 @@ function postsByUserRoute(server: fastify.FastifyInstance<Server, IncomingMessag
if (!user) return notFoundError(reply)
const userPostsQuery = createQuerySpec(`SELECT p.id FROM Users p WHERE p.partitionKey = @userId AND p.type = 'post'`, { userId: id })
if (user.private) {
if (!request.user) 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.user.id,
user: user.id,
})
const { resources: subscriptions } = await userContainer.items.query<IUserSubscription>(query, {}).fetchAll()
if (subscriptions.length === 0) return unauthorizedError(reply)
}
if (request.user) {
const { resource: viewer } = await userContainer.item(request.user.id, request.user.id).read<IUser>()
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,
user: user.id,
})
const { resources: blocks } = await userContainer.items.query<IUserBlock>(query, {}).fetchAll()
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 { resources: posts } = await postContainer.items.query<IPost>({

124
src/plugins/api/profile.ts

@ -3,12 +3,14 @@ import fastify, {
DefaultQuery,
DefaultParams,
RouteShorthandOptions,
DefaultHeaders,
DefaultBody,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, serverError } from '../../lib/errors'
import { containerFor } from '../../lib/database'
import { IUser } from '../../types/collections'
import { unauthorizedError, serverError, notFoundError, badRequestError } from '../../lib/errors'
import { containerFor, createQuerySpec } from '../../lib/database'
import { IUser, IUserSubscription, IGroupBlock } from '../../types/collections'
interface PluginOptions {
@ -83,8 +85,124 @@ function updateRoute(server: fastify.FastifyInstance<Server, IncomingMessage, Se
})
}
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 (!request.user) return unauthorizedError(reply)
const userContainer = await containerFor(server.database.client, 'Users')
const groupContainer = await containerFor(server.database.client, 'Groups')
if (request.user.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.user.id, request.user.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()
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
}
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
})
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.user.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 (!request.user) 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.user.id, request.user.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

88
src/types/collections.ts

@ -2,27 +2,79 @@
//
// Users
// - Partition Key: partitionKey (userId)
// Groups
// - Partition Key: partitionKey (groupId)
// Posts
// - Partition Key: id
// PostRelationships
// - Partition Key: partitionKey (postId)
export type IAccountStatus = 'default' | 'platinum'
export type IUserItemType = 'user' | 'token' | 'post' | 'follow' | 'timeline'
export type IUserPrivacyType = 'public' | 'group' | 'approve' | 'private'
export type IGroupItemType = 'group' | 'membership' | 'report' | 'block'
export type IGroupMembershipType = 'admin' | 'moderator' | 'member'
export type IReportStatus = 'pending' | 'complete'
export type IBlockType = 'user' | 'group'
export interface IGroup {
id: string
partitionKey: string // ID
type: 'group'
userId: string
name: string // Prenormalized ID
about: string
open: boolean
requiresApproval: boolean
members: number
created: number
}
export interface IGroupMembership {
id: string
partitionKey: string // Group ID
type: 'membership'
userId: string
pending: boolean
membership: IGroupMembershipType
created: number
}
export interface IGroupReport {
id: string
partitionKey: string // Group ID
type: 'report'
postId: string
reportedById: string
description: string
status: string
created: number
}
export interface IGroupBlock {
id: string
partitionKey: string // Group ID
type: 'block'
blockedId: string // User or Group ID
userId: string // blocker ID
created: number
}
export interface IUser {
id: string
partitionKey: string // id
partitionKey: string // ID
type: 'user'
group: string
name: string
email: string
emailVerified: boolean
passwordHash: string
installations: IInstallation[]
status: IAccountStatus
points: number
followerCount: number
followingCount: number
subscriberCount: number
subscribedCount: number
pending: boolean
privacy: IUserPrivacyType
paid: boolean
about: string
created: number // Timestamp
}
@ -38,21 +90,35 @@ export interface IUserToken {
}
export interface IUserPost {
id: string // postId
id?: string
postId: string
partitionKey: string // userId
type: 'post'
created: number
}
export interface IUserFollow {
id: string // userId (followerId)
partitionKey: string // userId (followedId)
type: 'follow'
export interface IUserSubscription {
id?: string
subscriberId: string
partitionKey: string
type: 'subscription'
pending: boolean
created: number
}
export interface IUserBlock {
id?: string
blockedId: string
partitionKey: string
type: 'block'
blockType: IBlockType
description: string
created: number
}
export interface IUserTimelinePost {
id: string // postId
id?: string
postId: string
partitionKey: string // userId
type: 'timeline'
created: number

Loading…
Cancel
Save