Robert Schäfer d6a8de478b
feat(backend): migrate to s3 (#8545)
## 🍰 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
2025-06-01 09:53:31 +00:00

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
})
}