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

572 lines
21 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* 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 { isEmpty } from 'lodash'
import { neo4jgraphql } from 'neo4j-graphql-js'
import { v4 as uuid } from 'uuid'
import CONFIG from '@config/index'
import { Context } from '@src/server'
import { validateEventParams } from './helpers/events'
import { filterForMutedUsers } from './helpers/filterForMutedUsers'
import { filterInvisiblePosts } from './helpers/filterInvisiblePosts'
import { filterPostsOfMyGroups } from './helpers/filterPostsOfMyGroups'
import Resolver from './helpers/Resolver'
import { images } from './images/images'
import { createOrUpdateLocations } from './users/location'
const maintainPinnedPosts = (params) => {
const pinnedPostFilter = { pinned: true }
if (isEmpty(params.filter)) {
params.filter = { OR: [pinnedPostFilter, {}] }
} else {
params.filter = { OR: [pinnedPostFilter, { ...params.filter }] }
}
return params
}
const filterEventDates = (params) => {
if (params.filter?.eventStart_gte) {
const date = params.filter.eventStart_gte
delete params.filter.eventStart_gte
params.filter = { ...params.filter, OR: [{ eventStart_gte: date }, { eventEnd_gte: date }] }
}
return params
}
export default {
Query: {
Post: async (object, params, context, resolveInfo) => {
params = await filterPostsOfMyGroups(params, context)
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
params = filterEventDates(params)
params = await maintainPinnedPosts(params)
return neo4jgraphql(object, params, context, resolveInfo)
},
profilePagePosts: async (object, params, context, resolveInfo) => {
params = await filterPostsOfMyGroups(params, context)
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
return neo4jgraphql(object, params, context, resolveInfo)
},
PostsEmotionsCountByEmotion: async (_object, params, context, _resolveInfo) => {
const { postId, data } = params
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (transaction) => {
const emotionsCountTransactionResponse = await transaction.run(
`
MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-()
RETURN COUNT(DISTINCT emoted) as emotionsCount
`,
{ postId, data },
)
return emotionsCountTransactionResponse.records.map(
(record) => record.get('emotionsCount').low,
)
})
try {
const [emotionsCount] = await readTxResultPromise
return emotionsCount
} finally {
session.close()
}
},
PostsEmotionsByCurrentUser: async (_object, params, context, _resolveInfo) => {
const { postId } = params
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (transaction) => {
const emotionsTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId})
RETURN collect(emoted.emotion) as emotion
`,
{ userId: context.user.id, postId },
)
return emotionsTransactionResponse.records.map((record) => record.get('emotion'))
})
try {
const [emotions] = await readTxResultPromise
return emotions
} finally {
session.close()
}
},
PostsPinnedCounts: async (_object, params, context: Context, _resolveInfo) => {
const [postsPinnedCount] = (
await context.database.query({
query: 'MATCH (p:Post { pinned: true }) RETURN COUNT (p) AS count',
})
).records.map((r) => Number(r.get('count').toString()))
return {
maxPinnedPosts: CONFIG.MAX_PINNED_POSTS,
currentlyPinnedPosts: postsPinnedCount,
}
},
},
Mutation: {
CreatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds, groupId } = params
const { image: imageInput } = params
const locationName = validateEventParams(params)
delete params.categoryIds
delete params.image
delete params.groupId
params.id = params.id || uuid()
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
let groupCypher = ''
if (groupId) {
groupCypher = `
WITH post MATCH (group:Group { id: $groupId })
MERGE (post)-[:IN]->(group)`
const groupTypeResponse = await transaction.run(
`
MATCH (group:Group { id: $groupId }) RETURN group.groupType AS groupType`,
{ groupId },
)
const [groupType] = groupTypeResponse.records.map((record) => record.get('groupType'))
if (groupType !== 'public')
groupCypher += `
WITH post, group
MATCH (user:User)-[membership:MEMBER_OF]->(group)
WHERE group.groupType IN ['closed', 'hidden']
AND membership.role IN ['usual', 'admin', 'owner']
WITH post, collect(user.id) AS userIds
OPTIONAL MATCH path =(restricted:User) WHERE NOT restricted.id IN userIds
FOREACH (user IN nodes(path) |
MERGE (user)-[:CANNOT_SEE]->(post)
)`
}
const categoriesCypher =
CONFIG.CATEGORIES_ACTIVE && categoryIds
? `WITH post
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)`
: ''
const createPostTransactionResponse = await transaction.run(
`
CREATE (post:Post)
SET post += $params
SET post.createdAt = toString(datetime())
SET post.updatedAt = toString(datetime())
SET post.clickedCount = 0
SET post.viewedTeaserCount = 0
SET post:${params.postType}
WITH post
MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author)
MERGE (post)<-[obs:OBSERVES]-(author)
SET obs.active = true
SET obs.createdAt = toString(datetime())
SET obs.updatedAt = toString(datetime())
${categoriesCypher}
${groupCypher}
RETURN post {.*, postType: [l IN labels(post) WHERE NOT l = 'Post'] }
`,
{ userId: context.user.id, categoryIds, groupId, params },
)
const [post] = createPostTransactionResponse.records.map((record) => record.get('post'))
if (imageInput) {
await images.mergeImage(post, 'HERO_IMAGE', imageInput, { transaction })
}
return post
})
try {
const post = await writeTxResultPromise
if (locationName) {
await createOrUpdateLocations('Post', post.id, locationName, session)
}
return post
} catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('Post with this slug already exists!')
throw new Error(e)
} finally {
session.close()
}
},
UpdatePost: async (_parent, params, context, _resolveInfo) => {
const { categoryIds } = params
const { image: imageInput } = params
const locationName = validateEventParams(params)
delete params.categoryIds
delete params.image
const session = context.driver.session()
let updatePostCypher = `
MATCH (post:Post {id: $params.id})
SET post += $params
SET post.updatedAt = toString(datetime())
WITH post
`
if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = `
MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category)
DELETE previousRelations
RETURN post, category
`
await session.writeTransaction((transaction) => {
return transaction.run(cypherDeletePreviousRelations, { params })
})
updatePostCypher += `
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)
WITH post
`
}
if (params.postType) {
updatePostCypher += `
REMOVE post:Article
REMOVE post:Event
SET post:${params.postType}
WITH post
`
}
updatePostCypher += `RETURN post {.*, postType: [l IN labels(post) WHERE NOT l = 'Post']}`
const updatePostVariables = { categoryIds, params }
try {
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const updatePostTransactionResponse = await transaction.run(
updatePostCypher,
updatePostVariables,
)
const [post] = updatePostTransactionResponse.records.map((record) => record.get('post'))
await images.mergeImage(post, 'HERO_IMAGE', imageInput, { transaction })
return post
})
const post = await writeTxResultPromise
if (locationName) {
await createOrUpdateLocations('Post', post.id, locationName, session)
}
return post
} finally {
session.close()
}
},
DeletePost: async (_object, args, context, _resolveInfo) => {
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const deletePostTransactionResponse = await transaction.run(
`
MATCH (post:Post {id: $postId})
OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment)
SET post.deleted = TRUE
SET post.content = 'UNAVAILABLE'
SET post.contentExcerpt = 'UNAVAILABLE'
SET post.title = 'UNAVAILABLE'
SET comment.deleted = TRUE
RETURN post {.*}
`,
{ postId: args.id },
)
const [post] = deletePostTransactionResponse.records.map((record) => record.get('post'))
await images.deleteImage(post, 'HERO_IMAGE', { transaction })
return post
})
try {
const post = await writeTxResultPromise
return post
} finally {
session.close()
}
},
AddPostEmotions: async (_object, params, context, _resolveInfo) => {
const { to, data } = params
const { user } = context
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const addPostEmotionsTransactionResponse = await transaction.run(
`
MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id})
MERGE (userFrom)-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo)
RETURN userFrom, postTo, emotedRelation`,
{ user, to, data },
)
return addPostEmotionsTransactionResponse.records.map((record) => {
return {
from: { ...record.get('userFrom').properties },
to: { ...record.get('postTo').properties },
...record.get('emotedRelation').properties,
}
})
})
try {
const [emoted] = await writeTxResultPromise
return emoted
} finally {
session.close()
}
},
RemovePostEmotions: async (_object, params, context, _resolveInfo) => {
const { to, data } = params
const { id: from } = context.user
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const removePostEmotionsTransactionResponse = await transaction.run(
`
MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id})
DELETE emotedRelation
RETURN userFrom, postTo
`,
{ from, to, data },
)
return removePostEmotionsTransactionResponse.records.map((record) => {
return {
from: { ...record.get('userFrom').properties },
to: { ...record.get('postTo').properties },
emotion: data.emotion,
}
})
})
try {
const [emoted] = await writeTxResultPromise
return emoted
} finally {
session.close()
}
},
pinPost: async (_parent, params, context: Context, _resolveInfo) => {
if (CONFIG.MAX_PINNED_POSTS === 0) throw new Error('Pinned posts are not allowed!')
let pinnedPostWithNestedAttributes
const { driver, user } = context
const session = driver.session()
const { id: userId } = user
const pinPostCypher = `
MATCH (user:User {id: $userId}) WHERE user.role = 'admin'
MATCH (post:Post {id: $params.id})
WHERE NOT EXISTS((post)-[:IN]->(:Group)) OR
(post)-[:IN]->(:Group { groupType: 'public'})
MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post)
SET post.pinned = true
RETURN post, pinned.createdAt as pinnedAt`
if (CONFIG.MAX_PINNED_POSTS === 1) {
let writeTxResultPromise = session.writeTransaction(async (transaction) => {
const deletePreviousRelationsResponse = await transaction.run(
`
MATCH (:User)-[previousRelations:PINNED]->(post:Post)
REMOVE post.pinned
DELETE previousRelations
RETURN post
`,
)
return deletePreviousRelationsResponse.records.map(
(record) => record.get('post').properties,
)
})
try {
await writeTxResultPromise
writeTxResultPromise = session.writeTransaction(async (transaction) => {
const pinPostTransactionResponse = await transaction.run(pinPostCypher, {
userId,
params,
})
return pinPostTransactionResponse.records.map((record) => ({
pinnedPost: record.get('post').properties,
pinnedAt: record.get('pinnedAt'),
}))
})
const [transactionResult] = await writeTxResultPromise
if (transactionResult) {
const { pinnedPost, pinnedAt } = transactionResult
pinnedPostWithNestedAttributes = {
...pinnedPost,
pinnedAt,
}
}
} finally {
await session.close()
}
return pinnedPostWithNestedAttributes
} else {
const [currentPinnedPostCount] = (
await context.database.query({
query: `MATCH (:User)-[:PINNED]->(post:Post { pinned: true }) RETURN COUNT(post) AS count`,
})
).records.map((r) => Number(r.get('count').toString()))
if (currentPinnedPostCount >= CONFIG.MAX_PINNED_POSTS) {
throw new Error('Max number of pinned posts is reached!')
}
const [pinPostResult] = (
await context.database.write({
query: pinPostCypher,
variables: { userId, params },
})
).records.map((r) => ({
...r.get('post').properties,
pinnedAt: r.get('pinnedAt'),
}))
return pinPostResult
}
},
unpinPost: async (_parent, params, context, _resolveInfo) => {
let unpinnedPost
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const unpinPostTransactionResponse = await transaction.run(
`
MATCH (:User)-[previousRelations:PINNED]->(post:Post {id: $params.id})
REMOVE post.pinned
DELETE previousRelations
RETURN post
`,
{ params },
)
return unpinPostTransactionResponse.records.map((record) => record.get('post').properties)
})
try {
;[unpinnedPost] = await writeTxResultPromise
} finally {
session.close()
}
return unpinnedPost
},
markTeaserAsViewed: async (_parent, params, context, _resolveInfo) => {
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (post:Post { id: $params.id })
MATCH (user:User { id: $userId })
MERGE (user)-[relation:VIEWED_TEASER { }]->(post)
ON CREATE
SET relation.createdAt = toString(datetime()),
post.viewedTeaserCount = post.viewedTeaserCount + 1
RETURN post
`,
{ userId: context.user.id, params },
)
return transactionResponse.records.map((record) => record.get('post').properties)
})
try {
const [post] = await writeTxResultPromise
post.viewedTeaserCount = post.viewedTeaserCount.low
return post
} finally {
session.close()
}
},
toggleObservePost: async (_parent, params, context, _resolveInfo) => {
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const transactionResponse = await transaction.run(
`
MATCH (post:Post { id: $params.id })
MATCH (user:User { id: $userId })
MERGE (user)-[obs:OBSERVES]->(post)
ON CREATE SET
obs.createdAt = toString(datetime()),
obs.updatedAt = toString(datetime()),
obs.active = $params.value
ON MATCH SET
obs.updatedAt = toString(datetime()),
obs.active = $params.value
RETURN post
`,
{ userId: context.user.id, params },
)
return transactionResponse.records.map((record) => record.get('post').properties)
})
try {
const [post] = await writeTxResultPromise
post.viewedTeaserCount = post.viewedTeaserCount.low
return post
} finally {
session.close()
}
},
},
Post: {
...Resolver('Post', {
undefinedToNull: [
'activityId',
'objectId',
'language',
'pinnedAt',
'pinned',
'eventVenue',
'eventLocation',
'eventLocationName',
'eventStart',
'eventEnd',
'eventIsOnline',
],
hasMany: {
tags: '-[:TAGGED]->(related:Tag)',
categories: '-[:CATEGORIZED]->(related:Category)',
comments: '<-[:COMMENTS]-(related:Comment)',
shoutedBy: '<-[:SHOUTED]-(related:User)',
emotions: '<-[related:EMOTED]',
},
hasOne: {
author: '<-[:WROTE]-(related:User)',
pinnedBy: '<-[:PINNED]-(related:User)',
image: '-[:HERO_IMAGE]->(related:Image)',
group: '-[:IN]->(related:Group)',
eventLocation: '-[:IS_IN]->(related:Location)',
},
count: {
commentsCount:
'<-[:COMMENTS]-(related:Comment) WHERE NOT related.deleted = true AND NOT related.disabled = true',
shoutedCount:
'<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true',
emotionsCount: '<-[related:EMOTED]-(:User)',
observingUsersCount:
'<-[obs:OBSERVES]-(related:User) WHERE obs.active = true AND NOT related.deleted = true AND NOT related.disabled = true',
},
boolean: {
shoutedByCurrentUser:
'MATCH(this)<-[:SHOUTED]-(related:User {id: $cypherParams.currentUserId}) RETURN COUNT(related) >= 1',
viewedTeaserByCurrentUser:
'MATCH (this)<-[:VIEWED_TEASER]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1',
isObservedByMe:
'MATCH (this)<-[obs:OBSERVES]-(related:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(related) >= 1',
},
}),
relatedContributions: async (parent, _params, context, _resolveInfo) => {
if (typeof parent.relatedContributions !== 'undefined') return parent.relatedContributions
const { id } = parent
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const relatedContributionsTransactionResponse = await transaction.run(
`
MATCH (p:Post {id: $id})-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post)
WHERE NOT post.deleted AND NOT post.disabled
RETURN DISTINCT post
LIMIT 10
`,
{ id },
)
return relatedContributionsTransactionResponse.records.map(
(record) => record.get('post').properties,
)
})
try {
const relatedContributions = await writeTxResultPromise
return relatedContributions
} finally {
session.close()
}
},
},
}