diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index a7eff0ef2..f528a8262 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -34,7 +34,7 @@ jobs: run: | docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/ docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar - + - name: Cache docker images id: cache-neo4j uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.0.2 @@ -55,7 +55,7 @@ jobs: run: | docker build --target test -t "ocelotsocialnetwork/backend:test" backend/ docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar - + - name: Cache docker images id: cache-backend uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.0.2 @@ -80,7 +80,7 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.docker == 'true' needs: [files-changed, build_test_neo4j, build_test_backend] runs-on: ubuntu-latest - permissions: + permissions: checks: write steps: - name: Checkout code diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index d517b3e23..14a0b821b 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -132,8 +132,11 @@ export interface S3Configured { AWS_REGION: string AWS_BUCKET: string S3_PUBLIC_GATEWAY: string | undefined + IMAGOR_SECRET: string | undefined } +export const IMAGOR_SECRET: string | undefined = env.IMAGOR_SECRET + export const isS3configured = (config: typeof s3): config is S3Configured => { return !!( config.AWS_ACCESS_KEY_ID && @@ -176,6 +179,7 @@ const CONFIG = { ...s3, ...options, ...language, + IMAGOR_SECRET, } export type Config = typeof CONFIG diff --git a/backend/src/config/test-mock.ts b/backend/src/config/test-mock.ts new file mode 100644 index 000000000..e9bc4ece7 --- /dev/null +++ b/backend/src/config/test-mock.ts @@ -0,0 +1,52 @@ +import type CONFIG from '.' + +export const TEST_CONFIG: typeof CONFIG = { + NODE_ENV: 'test', + DEBUG: undefined, + TEST: true, + PRODUCTION: false, + PRODUCTION_DB_CLEAN_ALLOW: false, + DISABLED_MIDDLEWARES: [], + SEND_MAIL: false, + + CLIENT_URI: 'http://localhost:3000', + GRAPHQL_URI: 'http://localhost:4000', + JWT_EXPIRES: '2y', + + MAPBOX_TOKEN: undefined, + JWT_SECRET: undefined, + PRIVATE_KEY_PASSPHRASE: undefined, + + NEO4J_URI: 'bolt://localhost:7687', + NEO4J_USERNAME: 'neo4j', + NEO4J_PASSWORD: 'neo4j', + + SENTRY_DSN_BACKEND: undefined, + COMMIT: undefined, + + REDIS_DOMAIN: undefined, + REDIS_PORT: undefined, + REDIS_PASSWORD: undefined, + + AWS_ACCESS_KEY_ID: '', + AWS_SECRET_ACCESS_KEY: '', + AWS_ENDPOINT: '', + AWS_REGION: '', + AWS_BUCKET: '', + S3_PUBLIC_GATEWAY: undefined, + IMAGOR_SECRET: undefined, + + EMAIL_DEFAULT_SENDER: '', + SUPPORT_EMAIL: '', + SUPPORT_URL: '', + APPLICATION_NAME: '', + ORGANIZATION_URL: '', + PUBLIC_REGISTRATION: false, + INVITE_REGISTRATION: true, + INVITE_CODES_PERSONAL_PER_USER: 7, + INVITE_CODES_GROUP_PER_USER: 7, + CATEGORIES_ACTIVE: false, + MAX_PINNED_POSTS: 1, + + LANGUAGE_DEFAULT: 'en', +} diff --git a/backend/src/graphql/resolvers/images.ts b/backend/src/graphql/resolvers/images.ts index ea596a183..46cfbc55a 100644 --- a/backend/src/graphql/resolvers/images.ts +++ b/backend/src/graphql/resolvers/images.ts @@ -1,9 +1,80 @@ +import crypto from 'node:crypto' + +import type { Context } from '@src/server' + import Resolver from './helpers/Resolver' +type UrlResolver = ( + parent: { url: string }, + args: { width?: number; height?: number }, + { + config: { S3_PUBLIC_GATEWAY }, + }: Context, +) => string + +const changeDomain: (opts: { transformations: UrlResolver[] }) => UrlResolver = + ({ transformations }) => + (parent, _args, context) => { + const { config } = context + const { S3_PUBLIC_GATEWAY, AWS_ENDPOINT } = config + if (!(S3_PUBLIC_GATEWAY && AWS_ENDPOINT)) { + return parent.url + } + if (new URL(parent.url).host !== new URL(AWS_ENDPOINT).host) { + // In this case it's an external upload - maybe seeded? + // Let's not change the URL in this case + return parent.url + } + + const publicUrl = new URL(S3_PUBLIC_GATEWAY) + publicUrl.pathname = new URL(parent.url).pathname + const url = publicUrl.href + return chain(...transformations)({ url }, _args, context) + } + +const sign: UrlResolver = ({ url }, _args, { config: { IMAGOR_SECRET } }) => { + if (!IMAGOR_SECRET) { + throw new Error('IMAGOR_SECRET is not set') + } + const newUrl = new URL(url) + const path = newUrl.pathname.replace('/', '') + const hash = crypto + .createHmac('sha1', IMAGOR_SECRET) + .update(path) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + newUrl.pathname = hash + newUrl.pathname + return newUrl.href +} + +const FALLBACK_MAXIMUM_LENGTH = 5000 +const resize: UrlResolver = ({ url }, { height, width }) => { + if (!(height || width)) { + return url + } + const window = `/fit-in/${width ?? FALLBACK_MAXIMUM_LENGTH}x${height ?? FALLBACK_MAXIMUM_LENGTH}` + const newUrl = new URL(url) + newUrl.pathname = window + newUrl.pathname + return newUrl.href +} + +const chain: (...methods: UrlResolver[]) => UrlResolver = (...methods) => { + return (parent, args, context) => { + let { url } = parent + for (const method of methods) { + url = method({ url }, args, context) + } + return url + } +} + export default { Image: { ...Resolver('Image', { undefinedToNull: ['sensitive', 'alt', 'aspectRatio', 'type'], }), + url: changeDomain({ transformations: [sign] }), + transform: changeDomain({ transformations: [resize, sign] }), }, } diff --git a/backend/src/graphql/resolvers/images/imagesS3.spec.ts b/backend/src/graphql/resolvers/images/imagesS3.spec.ts index 2bedec3cd..37c393bb1 100644 --- a/backend/src/graphql/resolvers/images/imagesS3.spec.ts +++ b/backend/src/graphql/resolvers/images/imagesS3.spec.ts @@ -30,6 +30,7 @@ const config: S3Configured = { AWS_ENDPOINT: 'AWS_ENDPOINT', AWS_REGION: 'AWS_REGION', S3_PUBLIC_GATEWAY: undefined, + IMAGOR_SECRET: undefined, } beforeAll(async () => { diff --git a/backend/src/graphql/resolvers/images/imagesS3.ts b/backend/src/graphql/resolvers/images/imagesS3.ts index 66c4a0a69..d51feea27 100644 --- a/backend/src/graphql/resolvers/images/imagesS3.ts +++ b/backend/src/graphql/resolvers/images/imagesS3.ts @@ -16,7 +16,7 @@ import type { FileUpload } from 'graphql-upload' export const images = (config: S3Configured) => { // const widths = [34, 160, 320, 640, 1024] - const { AWS_BUCKET: Bucket, S3_PUBLIC_GATEWAY } = config + const { AWS_BUCKET: Bucket } = config const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY } = config const s3 = new S3Client({ @@ -105,12 +105,7 @@ export const images = (config: S3Configured) => { const { name, ext } = path.parse(upload.filename) const uniqueFilename = `${uuid()}-${slug(name)}${ext}` const Location = await uploadCallback({ ...upload, uniqueFilename }) - if (!S3_PUBLIC_GATEWAY) { - return Location - } - const publicLocation = new URL(S3_PUBLIC_GATEWAY) - publicLocation.pathname = new URL(Location).pathname - return publicLocation.href + return Location } const s3Upload: FileUploadCallback = async ({ createReadStream, uniqueFilename, mimetype }) => { diff --git a/backend/src/graphql/types/type/Image.gql b/backend/src/graphql/types/type/Image.gql index f171a4b77..1cbe9c78c 100644 --- a/backend/src/graphql/types/type/Image.gql +++ b/backend/src/graphql/types/type/Image.gql @@ -1,5 +1,6 @@ type Image { url: ID!, + transform(width: Int, height: Int): String # urlW34: String, # urlW160: String, # urlW320: String, diff --git a/deployment/helm/charts/ocelot-social/templates/backend/stateful-set.yaml b/deployment/helm/charts/ocelot-social/templates/backend/stateful-set.yaml index 604b79826..b732c695f 100644 --- a/deployment/helm/charts/ocelot-social/templates/backend/stateful-set.yaml +++ b/deployment/helm/charts/ocelot-social/templates/backend/stateful-set.yaml @@ -38,6 +38,8 @@ spec: value: "http://{{ .Release.Name }}-backend:4000" - name: CLIENT_URI value: "https://{{ .Values.domain }}" + - name: S3_PUBLIC_GATEWAY + value: "https://{{ .Values.domain }}/imagor" envFrom: - configMapRef: name: {{ .Release.Name }}-backend-env diff --git a/deployment/helm/charts/ocelot-social/templates/imagor/deployment.yaml b/deployment/helm/charts/ocelot-social/templates/imagor/deployment.yaml new file mode 100644 index 000000000..80bac5e8b --- /dev/null +++ b/deployment/helm/charts/ocelot-social/templates/imagor/deployment.yaml @@ -0,0 +1,28 @@ +kind: Deployment +apiVersion: apps/v1 +metadata: + name: {{ .Release.Name }}-imagor +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Release.Name }}-imagor + template: + metadata: + labels: + app: {{ .Release.Name }}-imagor + spec: + restartPolicy: Always + containers: + - name: {{ .Release.Name }}-imagor + image: "{{ .Values.imagor.image.repository }}:{{ .Values.imagor.image.tag | default (include "defaultTag" .) }}" + imagePullPolicy: {{ quote .Values.global.image.pullPolicy }} + {{- include "resources" .Values.imagor.resources | indent 8 }} + ports: + - containerPort: 8000 + env: + - name: S3_FORCE_PATH_STYLE + value: "1" + envFrom: + - secretRef: + name: {{ .Release.Name }}-imagor-secret-env diff --git a/deployment/helm/charts/ocelot-social/templates/imagor/secret.yaml b/deployment/helm/charts/ocelot-social/templates/imagor/secret.yaml new file mode 100644 index 000000000..7e11e933f --- /dev/null +++ b/deployment/helm/charts/ocelot-social/templates/imagor/secret.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-imagor-secret-env +type: Opaque +stringData: +{{ .Values.secrets.imagor.env | toYaml | indent 2 }} diff --git a/deployment/helm/charts/ocelot-social/templates/imagor/service.yaml b/deployment/helm/charts/ocelot-social/templates/imagor/service.yaml new file mode 100644 index 000000000..229a039b4 --- /dev/null +++ b/deployment/helm/charts/ocelot-social/templates/imagor/service.yaml @@ -0,0 +1,11 @@ +kind: Service +apiVersion: v1 +metadata: + name: {{ .Release.Name }}-imagor +spec: + ports: + - name: {{ .Release.Name }}-http + port: 8000 + targetPort: 8000 + selector: + app: {{ .Release.Name }}-imagor diff --git a/deployment/helm/charts/ocelot-social/templates/ingress.yaml b/deployment/helm/charts/ocelot-social/templates/ingress.yaml index 56142f650..bf5e202df 100644 --- a/deployment/helm/charts/ocelot-social/templates/ingress.yaml +++ b/deployment/helm/charts/ocelot-social/templates/ingress.yaml @@ -62,4 +62,34 @@ spec: regex: ^https://{{ . }}(.*) replacement: https://{{ $.Values.domain }}${1} permanent: true -{{- end }} \ No newline at end of file +{{- end }} + +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ .Release.Name }}-stripprefix +spec: + stripPrefix: + prefixes: + - /imagor + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Release.Name }}-path-prefixes + annotations: + traefik.ingress.kubernetes.io/router.middlewares: "{{ .Release.Namespace }}-{{ .Release.Name }}-stripprefix@kubernetescrd" +spec: + rules: + - host: {{ quote .Values.domain }} + http: + paths: + - path: /imagor + pathType: Prefix + backend: + service: + name: {{ .Release.Name }}-imagor + port: + number: 8000 diff --git a/deployment/helm/charts/ocelot-social/values.yaml b/deployment/helm/charts/ocelot-social/values.yaml index 2213c5007..64867edb6 100644 --- a/deployment/helm/charts/ocelot-social/values.yaml +++ b/deployment/helm/charts/ocelot-social/values.yaml @@ -25,3 +25,9 @@ webapp: maintenance: image: repository: ghcr.io/ocelot-social-community/ocelot-social/maintenance + +imagor: + image: + repository: shumc/imagor + tag: 1.5.4 + diff --git a/deployment/helm/helmfile/secrets/ocelot.yaml b/deployment/helm/helmfile/secrets/ocelot.yaml index 41eff134c..5b08931c7 100644 --- a/deployment/helm/helmfile/secrets/ocelot.yaml +++ b/deployment/helm/helmfile/secrets/ocelot.yaml @@ -23,15 +23,25 @@ secrets: NEO4J_USERNAME: null NEO4J_PASSWORD: null REDIS_PASSWORD: null - AWS_ACCESS_KEY_ID: ENC[AES256_GCM,data:iiN5ueqyo60VHb9e2bnhc19iGTg=,iv:zawYpKrFafgsu1+YRet1hzZf1G3a6BIlZgsh7xNADaE=,tag:rTsmm8cqei34b6cT6vn08w==,type:str] - AWS_SECRET_ACCESS_KEY: ENC[AES256_GCM,data:Zl4LRXdDh/6Q8F9RVp+3L7NXGZ0F2cgFMKPhl/TVeuD5Bhy68W5ekg==,iv:AmPoinGISrSOZdoBKdeFFXfr2hwOK4nWMnniz8K5qgU=,tag:K8Q7M7e+6G9T0Oh3Sp4OzA==,type:str] - AWS_ENDPOINT: ENC[AES256_GCM,data:/waEqUgcOmldZ+peFTNVsDQf2KrpWY8ZZMt1nT5117SkbY4=,iv:n+Kvidjb/TM4bQYKqTaFxt8GkHo02PuxEGpzgOcywr4=,tag:lrGPgCWWy3GMIcTv75IYTg==,type:str] - AWS_REGION: ENC[AES256_GCM,data:kBPpHZ8zw4PMpg==,iv:R+QZe303do37Hd/97NpS1pt9VaBE/gqZDY2/qlIvvps=,tag:0WduW8wfJXtBqlh4qfRGNA==,type:str] - AWS_BUCKET: ENC[AES256_GCM,data:0fAspN/PoRVPlSbz+qDBRUOieeC4,iv:JGJ/LyLpMymN0tpZmW6DjPT3xqXzK/KhYQsy9sgPd60=,tag:Y6PBs0916JkHRHSe7hqSMA==,type:str] + IMAGOR_SECRET: null + AWS_ACCESS_KEY_ID: null + AWS_SECRET_ACCESS_KEY: null + AWS_ENDPOINT: null + AWS_REGION: null + AWS_BUCKET: null neo4j: env: NEO4J_USERNAME: "" NEO4J_PASSWORD: "" + imagor: + env: + HTTP_LOADER_BASE_URL: null + IMAGOR_SECRET: null + AWS_ACCESS_KEY_ID: null + AWS_SECRET_ACCESS_KEY: null + AWS_ENDPOINT: null + AWS_REGION: null + AWS_BUCKET: null sops: age: - recipient: age1llp6k66265q3rzqemxpnq0x3562u20989vcjf65fl9s3hjhgcscq6mhnjw @@ -70,7 +80,7 @@ sops: aGNFeXZZRmlJM041OHdTM0pmM3BBdGMKGvFgYY1jhKwciAOZKyw0hlFVNbOk7CM7 041g17JXNV1Wk6WgMZ4w8p54RKQVaWCT4wxChy6wNNdQ3IeKgqEU2w== -----END AGE ENCRYPTED FILE----- - lastmodified: "2025-05-29T06:57:01Z" - mac: ENC[AES256_GCM,data:0eWUqVsJrolrVYFsG4aigAYSjBW35Z+64y/ZE8Az15GmI0E4p/1kZPIY6hv9SUiCD1R2Ro0nm1QsV9A/hvNeWla0EdARNnARxIphxMJWKRGwcVkFVk28Jh4g4jE/rTggWx/wLbR6bza1RLHA1wRMb1PYuLfZybsb/whN4+gTMfg=,iv:irQkLpj1+3egSsiV9HqZP4tgG1fotCOizubL42gRjSQ=,tag:DC0xzG/VqcL4ib6ijxQZnA==,type:str] + lastmodified: "2025-05-30T12:50:05Z" + mac: ENC[AES256_GCM,data:b9GHzTW9yQ2Fd+EI+bhe6D+f72ToWDwvaJfJEoIIWUC1oExU7W1uRE9tftM8iPjD9CjM/bOSH8otQYGSXcN/SM3N9DW0UnGo5yIqcz/abpLSAgXK4a5MHMFtbJ7uPlsmgEixkPo9Kc82if4qJ1lPK8LL9+W2rZC5FLTHD/a9GKU=,iv:kBUvBsxxjWlXVIzVTLvl+zGKuCeefeNWAxo7OtAoyTg=,tag:6THq7miNLRbwhqg/xt6hXw==,type:str] unencrypted_suffix: _unencrypted version: 3.10.2 diff --git a/docker-compose.override.yml b/docker-compose.override.yml index a9e957dca..ea2bcefca 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -40,7 +40,8 @@ services: - AWS_ENDPOINT=http:/minio:9000 - AWS_REGION=local - AWS_BUCKET=ocelot - - S3_PUBLIC_GATEWAY=http:/localhost:9000 + - S3_PUBLIC_GATEWAY=http:/localhost:8000 + - IMAGOR_SECRET=mysecret volumes: - ./backend:/app @@ -83,5 +84,22 @@ services: /usr/bin/mc anonymous set-json /tmp/readonly-policy.json dockerminio/ocelot; " + imagor: + image: shumc/imagor:latest + ports: + - 8000:8000 + environment: + PORT: 8000 + IMAGOR_SECRET: mysecret # secret key for URL signature + # IMAGOR_UNSAFE: 1 # unsafe URL for testing + AWS_ACCESS_KEY_ID: h6uWJf7Earij3143YBV7 + AWS_SECRET_ACCESS_KEY: or9ZWh34BmAIqzIbJL5QpeTrey5ChGirH0mmyMdn + AWS_ENDPOINT: http:/minio:9000 + S3_FORCE_PATH_STYLE: 1 + S3_LOADER_BUCKET: ocelot # enable S3 loader by specifying bucket + S3_STORAGE_BUCKET: ocelot # enable S3 storage by specifying bucket + S3_RESULT_STORAGE_BUCKET: ocelot # enable S3 result storage by specifying bucket + HTTP_LOADER_BASE_URL: http://minio:9000 + volumes: minio_data: diff --git a/webapp/components/BadgeSelection.vue b/webapp/components/BadgeSelection.vue index a6554d779..66acfce75 100644 --- a/webapp/components/BadgeSelection.vue +++ b/webapp/components/BadgeSelection.vue @@ -7,7 +7,7 @@ @click="handleBadgeClick(badge, index)" >