feat(backend): resize images with imagor

Open questions:
* Do we have external URLs for images? E.g. we have them for seeds. But
  in production?

* Do we want to apply image transformations on these as well? My current
implementation does not apply image transformations as of now. If we
want to do that, we will also expose internal URLs in the kubernetes
Cluster to the S3 endpoint to the client.

TODOs:
* The chat component is using a fixed size for all avatars at the moment.
Maybe we can pair-program on this how to implement responsive images in
this component library.
This commit is contained in:
Robert Schäfer 2025-05-26 23:49:00 +08:00
parent 3c1c2d4dcb
commit d5ded75078
27 changed files with 312 additions and 38 deletions

View File

@ -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

View File

@ -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

View File

@ -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',
}

View File

@ -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] }),
},
}

View File

@ -30,6 +30,7 @@ const config: S3Configured = {
AWS_ENDPOINT: 'AWS_ENDPOINT',
AWS_REGION: 'AWS_REGION',
S3_PUBLIC_GATEWAY: undefined,
IMAGOR_SECRET: undefined,
}
beforeAll(async () => {

View File

@ -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 }) => {

View File

@ -1,5 +1,6 @@
type Image {
url: ID!,
transform(width: Int, height: Int): String
# urlW34: String,
# urlW160: String,
# urlW320: String,

View File

@ -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

View File

@ -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

View File

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

View File

@ -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

View File

@ -62,4 +62,34 @@ spec:
regex: ^https://{{ . }}(.*)
replacement: https://{{ $.Values.domain }}${1}
permanent: true
{{- end }}
{{- 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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -7,7 +7,7 @@
@click="handleBadgeClick(badge, index)"
>
<div class="badge-icon">
<img :src="badge.icon | proxyApiUrl" :alt="badge.id" />
<img :src="'/api' + badge.icon" :alt="badge.id" />
</div>
<div class="badge-info">
<div class="badge-description">{{ badge.description }}</div>

View File

@ -8,7 +8,7 @@
:class="{ selectable: selectionMode && index > 0, selected: selectedIndex === index }"
@click="handleBadgeClick(index)"
>
<img :title="badge.description" :src="badge.icon | proxyApiUrl" class="hc-badge" />
<img :title="badge.description" :src="'/api' + badge.icon" class="hc-badge" />
</component>
</div>
</template>

View File

@ -335,7 +335,7 @@ export default {
;[...this.messages, ...Message].forEach((m) => {
if (m.senderId !== this.currentUser.id) m.seen = true
m.date = new Date(m.date).toDateString()
m.avatar = this.$filters.proxyApiUrl(m.avatar)
m.avatar = m.avatar?.w320
msgs[m.indexId] = m
})
this.messages = msgs.filter(Boolean)
@ -408,7 +408,7 @@ export default {
const fixedRoom = {
...room,
index: room.lastMessage ? room.lastMessage.date : room.createdAt,
avatar: this.$filters.proxyApiUrl(room.avatar),
avatar: room.avatar?.w320,
lastMessage: room.lastMessage
? {
...room.lastMessage,
@ -416,7 +416,7 @@ export default {
}
: null,
users: room.users.map((u) => {
return { ...u, username: u.name, avatar: this.$filters.proxyApiUrl(u.avatar?.url) }
return { ...u, username: u.name, avatar: u.avatar?.w320 }
}),
}
if (!fixedRoom.avatar) {

View File

@ -12,7 +12,7 @@
<template #heroImage>
<img
v-if="formData.image"
:src="formData.image | proxyApiUrl"
:src="formData.image.url"
:class="['image', formData.imageBlurred && '--blur-image']"
/>
<image-uploader

View File

@ -12,7 +12,7 @@
:highlight="isPinned"
>
<template v-if="post.image" #heroImage>
<img :src="post.image | proxyApiUrl" class="image" />
<responsive-image :image="post.image" sizes="640px" class="image" />
</template>
<client-only>
<div class="post-user-row">
@ -136,6 +136,7 @@ import HcRibbon from '~/components/Ribbon'
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
import DateTime from '~/components/DateTime'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
import { mapGetters } from 'vuex'
import PostMutations from '~/graphql/PostMutations'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
@ -153,6 +154,7 @@ export default {
LocationTeaser,
DateTime,
UserTeaser,
ResponsiveImage,
},
props: {
post: {

View File

@ -0,0 +1,24 @@
<template>
<img :src="image.url" :sizes="sizes" :srcset="srcset" />
</template>
<script>
export default {
props: {
image: {
type: Object,
required: true,
},
sizes: {
type: String,
required: true,
},
},
computed: {
srcset() {
const { w320, w640, w1024 } = this.image
return `${w320} 320w, ${w640} 640w, ${w1024} 1024w`
},
},
}
</script>

View File

@ -8,7 +8,7 @@
@click="toggleBadge(badge)"
:class="{ badge, inactive: !badge.isActive }"
>
<img :src="badge.icon | proxyApiUrl" :alt="badge.description" />
<img :src="'/api' + badge.icon" :alt="badge.description" />
</button>
</div>
<div v-else>

View File

@ -3,20 +3,26 @@
<!-- '--no-image' is neccessary, because otherwise we still have a little unwanted boarder araund the image for images with white backgrounds -->
<span class="initials">{{ profileInitials }}</span>
<base-icon v-if="isAnonymous" name="eye-slash" />
<img
<responsive-image
v-if="isAvatar"
:src="profile.avatar | proxyApiUrl"
:image="profile.avatar"
class="image"
:alt="profile.name"
:title="showProfileNameTitle ? profile.name : ''"
@error="$event.target.style.display = 'none'"
sizes="320px"
/>
</div>
</template>
<script>
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
export default {
name: 'ProfileAvatar',
components: {
ResponsiveImage,
},
props: {
size: {
type: String,

View File

@ -7,6 +7,9 @@ export const userFragment = gql`
name
avatar {
url
w320: transform(width: 320)
w640: transform(width: 640)
w1024: transform(width: 1024)
}
disabled
deleted
@ -80,6 +83,9 @@ export const postFragment = gql`
language
image {
url
w320: transform(width: 320)
w640: transform(width: 640)
w1024: transform(width: 1024)
sensitive
aspectRatio
type

View File

@ -21,9 +21,13 @@
:style="heroImageStyle"
>
<template #heroImage v-if="post.image">
<img :src="post.image | proxyApiUrl" class="image" />
<responsive-image
:image="post.image"
sizes="(max-width: 1024px) 640px, 1024px"
class="image"
/>
<aside v-show="post.image && post.image.sensitive" class="blur-toggle">
<img v-show="blurred" :src="post.image | proxyApiUrl" class="preview" />
<img v-show="blurred" :src="post.image.url.w320" class="preview" />
<base-button
:icon="blurred ? 'eye' : 'eye-slash'"
filled
@ -167,6 +171,7 @@ import {
deletePostMutation,
sortTagsAlphabetically,
} from '~/components/utils/PostHelpers'
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
import PostQuery from '~/graphql/PostQuery'
import { groupQuery } from '~/graphql/groups'
import PostMutations from '~/graphql/PostMutations'
@ -193,6 +198,7 @@ export default {
ObserveButton,
LocationTeaser,
PageParamsLink,
ResponsiveImage,
UserTeaser,
},
mixins: [GetCategories, postListActions, SortCategories],

View File

@ -27,7 +27,7 @@
</template>
<script>
import ContributionForm from '~/components/ContributionForm/ContributionForm'
import ContributionForm from '~/components/ContributionForm/ContributionForm.vue'
import PostQuery from '~/graphql/PostQuery'
import { mapGetters } from 'vuex'

View File

@ -95,12 +95,6 @@ export default ({ app = {} }) => {
return contentExcerpt
},
proxyApiUrl: (input) => {
const url = input && (input.url || input)
if (!url) return url
if (url.startsWith('/api/')) return url
return url.startsWith('/') ? url.replace('/', '/api/') : url
},
})
// add all methods as filters on each vue component