[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.

618 lines
20 KiB

5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
  1. import {
  2. FastifyInstance,
  3. Plugin,
  4. DefaultQuery,
  5. DefaultParams,
  6. RouteShorthandOptions,
  7. DefaultHeaders,
  8. DefaultBody,
  9. } from 'fastify'
  10. import { Server, IncomingMessage, ServerResponse } from 'http'
  11. import { unauthorizedError, serverError, notFoundError, badRequestError, badRequestFormError } from '../../lib/errors'
  12. import { getUserBlocks, getUser, getUserIdFromPhone, getUserIdFromEmail } from '../../lib/collections'
  13. import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'
  14. import { deleteMedia, attachMedia } from '../../lib/media'
  15. import { MAX_NAME_LENGTH, USER_LISTING_PARTITION_KEY } from '../../constants'
  16. import { userSchema, selfSchema, errorSchema, userSettingsSchema } from '../../schemas'
  17. import {
  18. User,
  19. UserSubscription,
  20. UserBlock,
  21. GroupBlock,
  22. UserPrivacyType,
  23. UserItemType,
  24. GroupItemType,
  25. BlockType,
  26. UserInverseSubscription,
  27. UserListing,
  28. UserSettings,
  29. } from '../../types/collections'
  30. import { PluginOptions } from '../../types'
  31. function availabilityRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
  32. interface Body {
  33. name: string
  34. }
  35. const options: RouteShorthandOptions = {
  36. schema: {
  37. description: 'Check User ID availability.',
  38. tags: ['user'],
  39. body: {
  40. type: 'object',
  41. required: ['name'],
  42. properties: {
  43. name: {
  44. type: 'string',
  45. maxLength: MAX_NAME_LENGTH,
  46. },
  47. },
  48. },
  49. response: {
  50. 200: {
  51. description: 'Successful response.',
  52. type: 'object',
  53. properties: {
  54. id: { type: 'string' },
  55. available: { type: 'boolean' },
  56. },
  57. },
  58. 400: errorSchema,
  59. },
  60. },
  61. }
  62. server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/user/available', options, async (request, reply) => {
  63. if (!server.database) return serverError(reply)
  64. const id = normalize(request.body.name)
  65. const user = await getItem<User>({
  66. container: containerFor(server.database.client, 'Users'),
  67. id,
  68. })
  69. return {
  70. id,
  71. available: !user,
  72. }
  73. })
  74. }
  75. function updateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
  76. interface Body {
  77. name?: string
  78. email?: string
  79. phone?: string
  80. about?: string
  81. requiresApproval?: boolean
  82. privacy?: UserPrivacyType
  83. imageUrl?: string
  84. coverImageUrl?: string
  85. theme?: string
  86. settings?: UserSettings
  87. }
  88. const options: RouteShorthandOptions = {
  89. schema: {
  90. description: 'Update the authenticated User.',
  91. tags: ['user'],
  92. body: {
  93. type: 'object',
  94. properties: {
  95. name: {
  96. type: 'string',
  97. maxLength: MAX_NAME_LENGTH,
  98. },
  99. email: {
  100. type: 'string',
  101. format: 'email',
  102. },
  103. about: { type: 'string' },
  104. requiresApproval: { type: 'boolean' },
  105. privacy: {
  106. type: 'string',
  107. enum: ['public', 'group', 'subscribers', 'private'],
  108. },
  109. imageUrl: { type: 'string' },
  110. coverImageUrl: { type: 'string' },
  111. theme: { type: 'string' },
  112. settings: userSettingsSchema,
  113. },
  114. },
  115. response: {
  116. 200: selfSchema,
  117. 400: errorSchema,
  118. },
  119. },
  120. }
  121. server.put<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/self', options, async (request, reply) => {
  122. if (!server.database) return serverError(reply)
  123. if (!request.viewer) return unauthorizedError(reply)
  124. const viewerItem = containerFor(server.database.client, 'Users').item(request.viewer.id, request.viewer.id)
  125. const { resource: viewer } = await viewerItem.read<User>()
  126. if (!viewer) return serverError(reply)
  127. const {
  128. name,
  129. email,
  130. phone,
  131. about,
  132. requiresApproval,
  133. privacy,
  134. imageUrl,
  135. coverImageUrl,
  136. theme,
  137. settings,
  138. } = request.body
  139. if (name) viewer.name = name.trim()
  140. if (about) viewer.about = about.trim()
  141. if (email) {
  142. const emailT = email.trim()
  143. if (viewer.email !== emailT) {
  144. const id = await getUserIdFromEmail(server.database.client, emailT)
  145. if (id) return badRequestFormError(reply, 'email', 'Email address already used')
  146. viewer.email = emailT
  147. viewer.emailVerified = false
  148. }
  149. }
  150. if (phone) {
  151. const phoneT = phone.trim()
  152. if (viewer.phone !== phoneT) {
  153. const id = await getUserIdFromPhone(server.database.client, phoneT)
  154. if (id) return badRequestFormError(reply, 'phone', 'Phone number already used')
  155. viewer.phone = phoneT
  156. viewer.phoneVerified = false
  157. }
  158. }
  159. if (requiresApproval !== undefined) viewer.requiresApproval = requiresApproval
  160. if (privacy) viewer.privacy = privacy
  161. const mediaContainer = containerFor(server.database.client, 'Media')
  162. if (viewer.imageUrl && !imageUrl) await deleteMedia(viewer.imageUrl)
  163. if (viewer.coverImageUrl && !coverImageUrl) await deleteMedia(viewer.coverImageUrl)
  164. if (!viewer.imageUrl && imageUrl) await attachMedia(mediaContainer, imageUrl)
  165. if (!viewer.coverImageUrl && coverImageUrl) await attachMedia(mediaContainer, coverImageUrl)
  166. if (imageUrl) viewer.imageUrl = imageUrl
  167. if (coverImageUrl) viewer.coverImageUrl = coverImageUrl
  168. if (theme) viewer.theme = theme
  169. if (settings) viewer.settings = settings
  170. await viewerItem.replace<User>(viewer)
  171. const listingItem = containerFor(server.database.client, 'Directory').item(request.viewer.id, USER_LISTING_PARTITION_KEY)
  172. const { resource: listing } = await listingItem.read<UserListing>()
  173. if (listing) {
  174. if (email) listing.email = email.trim()
  175. if (phone) listing.phone = phone.trim()
  176. await listingItem.replace<UserListing>(listing)
  177. }
  178. return viewer
  179. })
  180. }
  181. function getRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
  182. interface Params {
  183. id: string
  184. }
  185. interface Subscription {
  186. from: string
  187. to: string
  188. pending: boolean
  189. }
  190. const options: RouteShorthandOptions = {
  191. schema: {
  192. description: 'Get a User.',
  193. tags: ['user'],
  194. params: {
  195. type: 'object',
  196. properties: {
  197. id: { type: 'string' },
  198. },
  199. },
  200. response: {
  201. 200: userSchema,
  202. 400: errorSchema,
  203. },
  204. },
  205. }
  206. server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/user/:id', options, async (request, reply) => {
  207. if (!server.database) return serverError(reply)
  208. const userContainer = containerFor(server.database.client, 'Users')
  209. const user = await getUser(server.database.client, request.params.id)
  210. if (!user) return notFoundError(reply)
  211. const subscriptions: Subscription[] = []
  212. if (request.viewer && request.viewer.id !== user.id) {
  213. const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
  214. if (!viewer) return serverError(reply)
  215. if (!viewer.groupId) return unauthorizedError(reply)
  216. const blocks = await getUserBlocks(server.database.client, user.id, [viewer.id, viewer.groupId], request.log)
  217. if (blocks.length > 0) return unauthorizedError(reply)
  218. const subscription = (await queryItems<UserSubscription>({
  219. container: userContainer,
  220. query: createQuerySpec('SELECT * FROM Users u WHERE u.pk = @pk AND u.userId = @userId AND u.t = @type', {
  221. userId: viewer.id,
  222. pk: user.id,
  223. type: UserItemType.Subscription,
  224. }),
  225. logger: request.log,
  226. }))[0]
  227. if (subscription) {
  228. subscriptions.push({
  229. from: subscription.userId,
  230. to: subscription.pk,
  231. pending: subscription.pending,
  232. })
  233. }
  234. const inverseSubscription = (await queryItems<UserInverseSubscription>({
  235. container: userContainer,
  236. query: createQuerySpec('SELECT * FROM Users u WHERE u.pk = @pk AND u.userId = @userId AND u.t = @type', {
  237. userId: user.id,
  238. pk: viewer.id,
  239. type: UserItemType.InverseSubscription,
  240. }),
  241. logger: request.log,
  242. }))[0]
  243. if (inverseSubscription) {
  244. subscriptions.push({
  245. from: inverseSubscription.pk,
  246. to: inverseSubscription.userId,
  247. pending: inverseSubscription.pending,
  248. })
  249. }
  250. }
  251. return {
  252. ...user,
  253. subscriptions,
  254. }
  255. })
  256. }
  257. function subscribeRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
  258. interface Params {
  259. id: string
  260. }
  261. const options: RouteShorthandOptions = {
  262. schema: {
  263. description: 'Subscribe to a User.',
  264. tags: ['user'],
  265. params: {
  266. type: 'object',
  267. properties: {
  268. id: { type: 'string' },
  269. },
  270. },
  271. response: {
  272. 204: {
  273. description: 'Subscribed.',
  274. type: 'object',
  275. },
  276. 400: errorSchema,
  277. },
  278. },
  279. }
  280. server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/user/:id/subscribe', options, async (request, reply) => {
  281. if (!server.database) return serverError(reply)
  282. if (!request.viewer) return unauthorizedError(reply)
  283. if (request.viewer.id === request.params.id) return badRequestError(reply)
  284. const userContainer = containerFor(server.database.client, 'Users')
  285. const user = await getItem<User>({ container: userContainer, id: request.params.id })
  286. const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
  287. if (!user) return notFoundError(reply)
  288. if (!viewer) return serverError(reply)
  289. if (!viewer.groupId) return unauthorizedError(reply)
  290. const subscriptionQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.id = @user AND u.pk = @viewer AND u.t = @type`, {
  291. user: user.id,
  292. viewer: viewer.id,
  293. type: UserItemType.Subscription,
  294. })
  295. const subscriptions = await queryItems<UserSubscription>({ container: userContainer, query: subscriptionQuery, logger: request.log })
  296. if (subscriptions.length > 0) return badRequestError(reply, 'Already subscribed')
  297. let pending = false
  298. switch (user.privacy) {
  299. case UserPrivacyType.Private:
  300. return unauthorizedError(reply)
  301. case UserPrivacyType.Group:
  302. if (user.groupId !== viewer.groupId) return unauthorizedError(reply)
  303. case UserPrivacyType.Subscribers:
  304. pending = true
  305. break
  306. }
  307. const blockQuery = createQuerySpec(`
  308. SELECT g.id FROM Groups g WHERE
  309. g.pk = @viewerGroup AND
  310. g.t = @type AND
  311. g.userId = @user AND
  312. (g.blockedId = @viewer OR g.blockedId = @viewerGroup)
  313. `, {
  314. user: user.id,
  315. viewerGroup: viewer.groupId,
  316. type: GroupItemType.Block,
  317. })
  318. const blocks = await queryItems<GroupBlock>({
  319. container: containerFor(server.database.client, 'Groups'),
  320. query: blockQuery,
  321. logger: request.log
  322. })
  323. if (blocks.length > 0) return badRequestError(reply, 'Invalid operation')
  324. await userContainer.items.create<UserSubscription>({
  325. userId: request.viewer.id,
  326. pk: user.id,
  327. t: UserItemType.Subscription,
  328. pending,
  329. created: Date.now(),
  330. })
  331. await userContainer.items.create<UserInverseSubscription>({
  332. userId: user.id,
  333. pk: request.viewer.id,
  334. t: UserItemType.InverseSubscription,
  335. pending,
  336. created: Date.now(),
  337. })
  338. reply.code(204)
  339. })
  340. }
  341. function unsubscribeRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
  342. interface Params {
  343. id: string
  344. }
  345. const options: RouteShorthandOptions = {
  346. schema: {
  347. description: 'Unsubscribe from a User.',
  348. tags: ['user'],
  349. params: {
  350. type: 'object',
  351. properties: {
  352. id: { type: 'string' },
  353. },
  354. },
  355. response: {
  356. 204: {
  357. description: 'Unsubscribed.',
  358. type: 'object',
  359. },
  360. 400: errorSchema,
  361. },
  362. },
  363. }
  364. server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/user/:id/unsubscribe', options, async (request, reply) => {
  365. if (!server.database) return serverError(reply)
  366. if (!request.viewer) return unauthorizedError(reply)
  367. const userContainer = containerFor(server.database.client, 'Users')
  368. const user = await getItem<User>({ container: userContainer, id: request.params.id })
  369. const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
  370. if (!user) return notFoundError(reply)
  371. if (!viewer) return serverError(reply)
  372. const subscriptionQuery = createQuerySpec(`SELECT u.id, u.pk FROM Users u WHERE u.userId = @user AND u.pk = @viewer AND u.t = @type`, {
  373. user: user.id,
  374. viewer: viewer.id,
  375. type: UserItemType.Subscription,
  376. })
  377. const subscriptions = await queryItems<UserSubscription>({ container: userContainer, query: subscriptionQuery, logger: request.log })
  378. for (const subscription of subscriptions) {
  379. await userContainer.item(subscription.id!, subscription.pk).delete()
  380. }
  381. const inverseSubscriptionQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.userId = @viewer AND u.pk = @user AND u.t = @type`, {
  382. user: user.id,
  383. viewer: viewer.id,
  384. type: UserItemType.InverseSubscription,
  385. })
  386. const inverseSubscriptions = await queryItems<UserInverseSubscription>({ container: userContainer, query: inverseSubscriptionQuery, logger: request.log })
  387. for (const inverseSubscription of inverseSubscriptions) {
  388. await userContainer.item(inverseSubscription.id!, inverseSubscription.pk).delete()
  389. }
  390. reply.code(204)
  391. })
  392. }
  393. function blockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
  394. interface Params {
  395. id: string
  396. }
  397. interface Body {
  398. description?: string
  399. }
  400. const options: RouteShorthandOptions = {
  401. schema: {
  402. description: 'Block a User.',
  403. tags: ['user'],
  404. params: {
  405. type: 'object',
  406. properties: {
  407. id: { type: 'string' },
  408. },
  409. },
  410. body: {
  411. type: 'object',
  412. properties: {
  413. description: { type: 'string' },
  414. },
  415. },
  416. response: {
  417. 204: {
  418. description: 'User blocked.',
  419. type: 'object',
  420. },
  421. 400: errorSchema,
  422. },
  423. },
  424. }
  425. server.post<DefaultQuery, Params, DefaultHeaders, Body>('/v1/user/:id/block', options, async (request, reply) => {
  426. if (!server.database) return serverError(reply)
  427. if (!request.viewer) return unauthorizedError(reply)
  428. const userContainer = containerFor(server.database.client, 'Users')
  429. const user = await getItem<User>({ container: userContainer, id: request.params.id })
  430. if (!user) return notFoundError(reply)
  431. if (!user.groupId) return badRequestError(reply)
  432. await userContainer.items.create<UserBlock>({
  433. blockedId: user.id,
  434. pk: request.viewer.id,
  435. t: UserItemType.Block,
  436. blockType: BlockType.User,
  437. description: request.body.description,
  438. created: Date.now(),
  439. })
  440. await containerFor(server.database.client, 'Groups').items.create<GroupBlock>({
  441. pk: user.groupId,
  442. t: GroupItemType.Block,
  443. blockedId: user.id,
  444. userId: request.viewer.id,
  445. created: Date.now(),
  446. })
  447. reply.code(204)
  448. })
  449. }
  450. function unblockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
  451. interface Params {
  452. id: string
  453. }
  454. const options: RouteShorthandOptions = {
  455. schema: {
  456. description: 'Unblock a User.',
  457. tags: ['user'],
  458. params: {
  459. type: 'object',
  460. properties: {
  461. id: { type: 'string' },
  462. },
  463. },
  464. response: {
  465. 204: {
  466. description: 'User unblocked.',
  467. type: 'object',
  468. },
  469. 400: errorSchema,
  470. },
  471. },
  472. }
  473. server.post<DefaultQuery, Params, DefaultHeaders, Body>('/v1/user/:id/unblock', options, async (request, reply) => {
  474. if (!server.database) return serverError(reply)
  475. if (!request.viewer) return unauthorizedError(reply)
  476. const userContainer = containerFor(server.database.client, 'Users')
  477. const groupContainer = containerFor(server.database.client, 'Groups')
  478. const user = await getItem<User>({ container: userContainer, id: request.params.id })
  479. if (!user) return notFoundError(reply)
  480. if (!user.groupId) return badRequestError(reply, 'Invalid operation')
  481. const userBlockQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.pk = @pk AND u.blockedId = @blocked AND u.t = @type`, {
  482. pk: request.viewer.id,
  483. blocked: user.id,
  484. type: UserItemType.Block,
  485. })
  486. const userBlocks = await queryItems<UserBlock>({
  487. container: userContainer,
  488. query: userBlockQuery,
  489. logger: request.log
  490. })
  491. for (const userBlock of userBlocks) {
  492. await userContainer.item(userBlock.id!, request.viewer.id).delete()
  493. }
  494. const groupBlockQuery = createQuerySpec(
  495. `SELECT g.id FROM Groups g WHERE g.pk = @pk AND u.blockedId = @blocked AND u.userId = @viewer AND u.t = @type`,
  496. {
  497. pk: user.groupId,
  498. blocked: user.id,
  499. viewer: request.viewer.id,
  500. type: GroupItemType.Block,
  501. }
  502. )
  503. const groupBlocks = await queryItems<GroupBlock>({
  504. container: groupContainer,
  505. query: groupBlockQuery,
  506. logger: request.log
  507. })
  508. for (const groupBlock of groupBlocks) {
  509. await groupContainer.item(groupBlock.id!, user.groupId).delete()
  510. }
  511. reply.code(204)
  512. })
  513. }
  514. const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
  515. availabilityRoute(server)
  516. updateRoute(server)
  517. getRoute(server)
  518. subscribeRoute(server)
  519. unsubscribeRoute(server)
  520. blockRoute(server)
  521. unblockRoute(server)
  522. }
  523. export default plugin