mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
## 🍰 Pullrequest This will migrate our assets to an objectstorage via S3. Before this PR is rolled out, the S3 credentials need to be configured in the respective infrastructure repository. The migration is implemented in a backend migration, i.e. I expect the `initContainer` to take a little longer but I hope then it's going to be fine. If any errors occcur, the migration should be repeatable, since the disk volume is still there. ### Issues The backend having direct access on disk. ### Todo - [ ] Configure backend environment variables in every infrastructure repo - [ ] Remove kubernetes uploads volume in a future PR Commits: * refactor: follow @ulfgebhardt Here: https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8545#pullrequestreview-2846163417 I don't know why the PR didn't include these changes already, I believe I made a mistake during rebase and lost the relevant commits. * refactor: use typescript assertions I found it a better way to react to this comment: https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8545/files#r2092766596 * add S3 credentials * refactor: easier to remember credentials It's for local development only * give init container necessary file access * fix: wrong upload location on production * refactor: follow @ulfgebhardt's review See: https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/8545#pullrequestreview-2881626504
510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
/* eslint-disable @typescript-eslint/require-await */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
import { UserInputError } from 'apollo-server'
|
|
import { v4 as uuid } from 'uuid'
|
|
|
|
import CONFIG from '@config/index'
|
|
import { CATEGORIES_MIN, CATEGORIES_MAX } from '@constants/categories'
|
|
import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '@constants/groups'
|
|
import { removeHtmlTags } from '@middleware/helpers/cleanHtml'
|
|
import type { Context } from '@src/server'
|
|
|
|
import Resolver, {
|
|
removeUndefinedNullValuesFromObject,
|
|
convertObjectToCypherMapLiteral,
|
|
} from './helpers/Resolver'
|
|
import { images } from './images/images'
|
|
import { createOrUpdateLocations } from './users/location'
|
|
|
|
export default {
|
|
Query: {
|
|
Group: async (_object, params, context: Context, _resolveInfo) => {
|
|
const { isMember, id, slug, first, offset } = params
|
|
let pagination = ''
|
|
const orderBy = 'ORDER BY group.createdAt DESC'
|
|
if (first !== undefined && offset !== undefined) pagination = `SKIP ${offset} LIMIT ${first}`
|
|
const matchParams = { id, slug }
|
|
removeUndefinedNullValuesFromObject(matchParams)
|
|
const session = context.driver.session()
|
|
const readTxResultPromise = session.readTransaction(async (txc) => {
|
|
const groupMatchParamsCypher = convertObjectToCypherMapLiteral(matchParams, true)
|
|
let groupCypher
|
|
if (isMember === true) {
|
|
groupCypher = `
|
|
MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group${groupMatchParamsCypher})
|
|
WITH group, membership
|
|
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
|
|
RETURN group {.*, myRole: membership.role}
|
|
${orderBy}
|
|
${pagination}
|
|
`
|
|
} else {
|
|
if (isMember === false) {
|
|
groupCypher = `
|
|
MATCH (group:Group${groupMatchParamsCypher})
|
|
WHERE (NOT (:User {id: $userId})-[:MEMBER_OF]->(group))
|
|
WITH group
|
|
WHERE group.groupType IN ['public', 'closed']
|
|
RETURN group {.*, myRole: NULL}
|
|
${orderBy}
|
|
${pagination}
|
|
`
|
|
} else {
|
|
groupCypher = `
|
|
MATCH (group:Group${groupMatchParamsCypher})
|
|
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
|
|
WITH group, membership
|
|
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
|
|
RETURN group {.*, myRole: membership.role}
|
|
${orderBy}
|
|
${pagination}
|
|
`
|
|
}
|
|
}
|
|
const transactionResponse = await txc.run(groupCypher, {
|
|
userId: context.user.id,
|
|
})
|
|
return transactionResponse.records.map((record) => record.get('group'))
|
|
})
|
|
try {
|
|
return await readTxResultPromise
|
|
} catch (error) {
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
GroupMembers: async (_object, params, context: Context, _resolveInfo) => {
|
|
const { id: groupId } = params
|
|
const session = context.driver.session()
|
|
const readTxResultPromise = session.readTransaction(async (txc) => {
|
|
const groupMemberCypher = `
|
|
MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId})
|
|
RETURN user {.*, myRoleInGroup: membership.role}
|
|
`
|
|
const transactionResponse = await txc.run(groupMemberCypher, {
|
|
groupId,
|
|
})
|
|
return transactionResponse.records.map((record) => record.get('user'))
|
|
})
|
|
try {
|
|
return await readTxResultPromise
|
|
} catch (error) {
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
GroupCount: async (_object, params, context, _resolveInfo) => {
|
|
const { isMember } = params
|
|
const {
|
|
user: { id: userId },
|
|
} = context
|
|
const session = context.driver.session()
|
|
const readTxResultPromise = session.readTransaction(async (txc) => {
|
|
let cypher
|
|
if (isMember) {
|
|
cypher = `MATCH (user:User)-[membership:MEMBER_OF]->(group:Group)
|
|
WHERE user.id = $userId
|
|
AND membership.role IN ['usual', 'admin', 'owner', 'pending']
|
|
RETURN toString(count(group)) AS count`
|
|
} else {
|
|
cypher = `MATCH (group:Group)
|
|
OPTIONAL MATCH (user:User)-[membership:MEMBER_OF]->(group)
|
|
WHERE user.id = $userId
|
|
WITH group, membership
|
|
WHERE group.groupType IN ['public', 'closed']
|
|
OR membership.role IN ['usual', 'admin', 'owner']
|
|
RETURN toString(count(group)) AS count`
|
|
}
|
|
const transactionResponse = await txc.run(cypher, { userId })
|
|
return transactionResponse.records.map((record) => record.get('count'))
|
|
})
|
|
try {
|
|
return parseInt(await readTxResultPromise)
|
|
} catch (error) {
|
|
throw new Error(error)
|
|
} finally {
|
|
session.close()
|
|
}
|
|
},
|
|
},
|
|
Mutation: {
|
|
CreateGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { categoryIds } = params
|
|
delete params.categoryIds
|
|
params.locationName = params.locationName === '' ? null : params.locationName
|
|
if (CONFIG.CATEGORIES_ACTIVE && (!categoryIds || categoryIds.length < CATEGORIES_MIN)) {
|
|
throw new UserInputError('Too few categories!')
|
|
}
|
|
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length > CATEGORIES_MAX) {
|
|
throw new UserInputError('Too many categories!')
|
|
}
|
|
if (
|
|
params.description === undefined ||
|
|
params.description === null ||
|
|
removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN
|
|
) {
|
|
throw new UserInputError('Description too short!')
|
|
}
|
|
params.id = params.id || uuid()
|
|
const session = context.driver.session()
|
|
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
|
|
const categoriesCypher =
|
|
CONFIG.CATEGORIES_ACTIVE && categoryIds
|
|
? `
|
|
WITH group, membership
|
|
UNWIND $categoryIds AS categoryId
|
|
MATCH (category:Category {id: categoryId})
|
|
MERGE (group)-[:CATEGORIZED]->(category)
|
|
`
|
|
: ''
|
|
const ownerCreateGroupTransactionResponse = await transaction.run(
|
|
`
|
|
CREATE (group:Group)
|
|
SET group += $params
|
|
SET group.createdAt = toString(datetime())
|
|
SET group.updatedAt = toString(datetime())
|
|
WITH group
|
|
MATCH (owner:User {id: $userId})
|
|
MERGE (owner)-[:CREATED]->(group)
|
|
MERGE (owner)-[membership:MEMBER_OF]->(group)
|
|
SET
|
|
membership.createdAt = toString(datetime()),
|
|
membership.updatedAt = null,
|
|
membership.role = 'owner'
|
|
${categoriesCypher}
|
|
RETURN group {.*, myRole: membership.role}
|
|
`,
|
|
{ userId: context.user.id, categoryIds, params },
|
|
)
|
|
const [group] = ownerCreateGroupTransactionResponse.records.map((record) =>
|
|
record.get('group'),
|
|
)
|
|
return group
|
|
})
|
|
try {
|
|
const group = await writeTxResultPromise
|
|
// TODO: put in a middleware, see "UpdateGroup", "UpdateUser"
|
|
await createOrUpdateLocations('Group', params.id, params.locationName, session)
|
|
return group
|
|
} catch (error) {
|
|
if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
|
|
throw new UserInputError('Group with this slug already exists!')
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
UpdateGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { categoryIds } = params
|
|
delete params.categoryIds
|
|
const { id: groupId, avatar: avatarInput } = params
|
|
delete params.avatar
|
|
params.locationName = params.locationName === '' ? null : params.locationName
|
|
|
|
if (CONFIG.CATEGORIES_ACTIVE && categoryIds) {
|
|
if (categoryIds.length < CATEGORIES_MIN) {
|
|
throw new UserInputError('Too few categories!')
|
|
}
|
|
if (categoryIds.length > CATEGORIES_MAX) {
|
|
throw new UserInputError('Too many categories!')
|
|
}
|
|
}
|
|
if (
|
|
params.description &&
|
|
removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN
|
|
) {
|
|
throw new UserInputError('Description too short!')
|
|
}
|
|
const session = context.driver.session()
|
|
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
|
|
const cypherDeletePreviousRelations = `
|
|
MATCH (group:Group {id: $groupId})-[previousRelations:CATEGORIZED]->(category:Category)
|
|
DELETE previousRelations
|
|
RETURN group, category
|
|
`
|
|
await session.writeTransaction((transaction) => {
|
|
return transaction.run(cypherDeletePreviousRelations, { groupId })
|
|
})
|
|
}
|
|
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
|
|
let updateGroupCypher = `
|
|
MATCH (group:Group {id: $groupId})
|
|
SET group += $params
|
|
SET group.updatedAt = toString(datetime())
|
|
WITH group
|
|
`
|
|
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
|
|
updateGroupCypher += `
|
|
UNWIND $categoryIds AS categoryId
|
|
MATCH (category:Category {id: categoryId})
|
|
MERGE (group)-[:CATEGORIZED]->(category)
|
|
WITH group
|
|
`
|
|
}
|
|
updateGroupCypher += `
|
|
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
|
|
RETURN group {.*, myRole: membership.role}
|
|
`
|
|
const transactionResponse = await transaction.run(updateGroupCypher, {
|
|
groupId,
|
|
userId: context.user.id,
|
|
categoryIds,
|
|
params,
|
|
})
|
|
const [group] = transactionResponse.records.map((record) => record.get('group'))
|
|
if (avatarInput) {
|
|
await images.mergeImage(group, 'AVATAR_IMAGE', avatarInput, { transaction })
|
|
}
|
|
return group
|
|
})
|
|
try {
|
|
const group = await writeTxResultPromise
|
|
// TODO: put in a middleware, see "CreateGroup", "UpdateUser"
|
|
await createOrUpdateLocations('Group', params.id, params.locationName, session)
|
|
return group
|
|
} catch (error) {
|
|
if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
|
|
throw new UserInputError('Group with this slug already exists!')
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
JoinGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId, userId } = params
|
|
const session = context.driver.session()
|
|
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
|
|
const joinGroupCypher = `
|
|
MATCH (member:User {id: $userId}), (group:Group {id: $groupId})
|
|
MERGE (member)-[membership:MEMBER_OF]->(group)
|
|
ON CREATE SET
|
|
membership.createdAt = toString(datetime()),
|
|
membership.updatedAt = null,
|
|
membership.role =
|
|
CASE WHEN group.groupType = 'public'
|
|
THEN 'usual'
|
|
ELSE 'pending'
|
|
END
|
|
RETURN member {.*, myRoleInGroup: membership.role}
|
|
`
|
|
const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId })
|
|
const [member] = transactionResponse.records.map((record) => record.get('member'))
|
|
return member
|
|
})
|
|
try {
|
|
return await writeTxResultPromise
|
|
} catch (error) {
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
LeaveGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId, userId } = params
|
|
const session = context.driver.session()
|
|
try {
|
|
return await removeUserFromGroupWriteTxResultPromise(session, groupId, userId)
|
|
} catch (error) {
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
ChangeGroupMemberRole: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId, userId, roleInGroup } = params
|
|
const session = context.driver.session()
|
|
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
|
|
let postRestrictionCypher = ''
|
|
if (['usual', 'admin', 'owner'].includes(roleInGroup)) {
|
|
postRestrictionCypher = `
|
|
WITH group, member, membership
|
|
FOREACH (restriction IN [(member)-[r:CANNOT_SEE]->(:Post)-[:IN]->(group) | r] |
|
|
DELETE restriction)`
|
|
} else {
|
|
postRestrictionCypher = `
|
|
WITH group, member, membership
|
|
FOREACH (post IN [(p:Post)-[:IN]->(group) | p] |
|
|
MERGE (member)-[:CANNOT_SEE]->(post))`
|
|
}
|
|
|
|
const joinGroupCypher = `
|
|
MATCH (member:User {id: $userId})
|
|
MATCH (group:Group {id: $groupId})
|
|
MERGE (member)-[membership:MEMBER_OF]->(group)
|
|
ON CREATE SET
|
|
membership.createdAt = toString(datetime()),
|
|
membership.updatedAt = null,
|
|
membership.role = $roleInGroup
|
|
ON MATCH SET
|
|
membership.updatedAt = toString(datetime()),
|
|
membership.role = $roleInGroup
|
|
${postRestrictionCypher}
|
|
RETURN member {.*, myRoleInGroup: membership.role}
|
|
`
|
|
|
|
const transactionResponse = await transaction.run(joinGroupCypher, {
|
|
groupId,
|
|
userId,
|
|
roleInGroup,
|
|
})
|
|
const [member] = transactionResponse.records.map((record) => record.get('member'))
|
|
return member
|
|
})
|
|
try {
|
|
return await writeTxResultPromise
|
|
} catch (error) {
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
RemoveUserFromGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId, userId } = params
|
|
const session = context.driver.session()
|
|
try {
|
|
return await removeUserFromGroupWriteTxResultPromise(session, groupId, userId)
|
|
} catch (error) {
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
muteGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId } = params
|
|
const userId = context.user.id
|
|
const session = context.driver.session()
|
|
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
|
|
const transactionResponse = await transaction.run(
|
|
`
|
|
MATCH (group:Group { id: $groupId })
|
|
MATCH (user:User { id: $userId })
|
|
MERGE (user)-[m:MUTED]->(group)
|
|
SET m.createdAt = toString(datetime())
|
|
RETURN group { .* }
|
|
`,
|
|
{
|
|
groupId,
|
|
userId,
|
|
},
|
|
)
|
|
const [group] = transactionResponse.records.map((record) => record.get('group'))
|
|
return group
|
|
})
|
|
try {
|
|
return await writeTxResultPromise
|
|
} catch (error) {
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
unmuteGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId } = params
|
|
const userId = context.user.id
|
|
const session = context.driver.session()
|
|
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
|
|
const transactionResponse = await transaction.run(
|
|
`
|
|
MATCH (group:Group { id: $groupId })
|
|
MATCH (user:User { id: $userId })
|
|
OPTIONAL MATCH (user)-[m:MUTED]->(group)
|
|
DELETE m
|
|
RETURN group { .* }
|
|
`,
|
|
{
|
|
groupId,
|
|
userId,
|
|
},
|
|
)
|
|
const [group] = transactionResponse.records.map((record) => record.get('group'))
|
|
return group
|
|
})
|
|
try {
|
|
return await writeTxResultPromise
|
|
} catch (error) {
|
|
throw new Error(error)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
},
|
|
Group: {
|
|
inviteCodes: async (parent, _args, context: Context, _resolveInfo) => {
|
|
if (!parent.id) {
|
|
throw new Error('Can not identify selected Group!')
|
|
}
|
|
return (
|
|
await context.database.query({
|
|
query: `
|
|
MATCH (user:User {id: $user.id})-[:GENERATED]->(inviteCodes:InviteCode)-[:INVITES_TO]->(g:Group {id: $parent.id})
|
|
RETURN inviteCodes {.*}
|
|
ORDER BY inviteCodes.createdAt ASC
|
|
`,
|
|
variables: {
|
|
user: context.user,
|
|
parent,
|
|
},
|
|
})
|
|
).records.map((r) => r.get('inviteCodes'))
|
|
},
|
|
...Resolver('Group', {
|
|
undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'],
|
|
hasMany: {
|
|
categories: '-[:CATEGORIZED]->(related:Category)',
|
|
posts: '<-[:IN]-(related:Post)',
|
|
},
|
|
hasOne: {
|
|
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
|
|
location: '-[:IS_IN]->(related:Location)',
|
|
},
|
|
boolean: {
|
|
isMutedByMe:
|
|
'MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )',
|
|
},
|
|
}),
|
|
name: async (parent, _args, context: Context, _resolveInfo) => {
|
|
if (!context.user) {
|
|
return parent.groupType === 'hidden' ? '' : parent.name
|
|
}
|
|
return parent.name
|
|
},
|
|
about: async (parent, _args, context: Context, _resolveInfo) => {
|
|
if (!context.user) {
|
|
return parent.groupType === 'hidden' ? '' : parent.about
|
|
}
|
|
return parent.about
|
|
},
|
|
},
|
|
}
|
|
|
|
const removeUserFromGroupWriteTxResultPromise = async (session, groupId, userId) => {
|
|
return session.writeTransaction(async (transaction) => {
|
|
const removeUserFromGroupCypher = `
|
|
MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
|
|
DELETE membership
|
|
WITH user, group
|
|
OPTIONAL MATCH (author:User)-[:WROTE]->(p:Post)-[:IN]->(group)
|
|
WHERE NOT group.groupType = 'public'
|
|
AND NOT author.id = $userId
|
|
WITH user, collect(p) AS posts
|
|
FOREACH (post IN posts |
|
|
MERGE (user)-[:CANNOT_SEE]->(post))
|
|
RETURN user {.*, myRoleInGroup: NULL}
|
|
`
|
|
|
|
const transactionResponse = await transaction.run(removeUserFromGroupCypher, {
|
|
groupId,
|
|
userId,
|
|
})
|
|
const [user] = await transactionResponse.records.map((record) => record.get('user'))
|
|
return user
|
|
})
|
|
}
|