feat(backend): resize images with imagor (#8558)

* 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.

Commits:
* do not replace upload domain url in the database

* fix all webapp specs

* refactor: remove behaviour we won't need

We don't want to apply image transformations on files, right?

* refactor: replace the domain on read not on write

* wip: webapp fixes

* refactor(backend): add another url to config

I've given up. There seems to be no nice way to tell the minio to return
a location which differs from it's host name.

* refactor: add test for s3Service

* refactor(backend): proxy minio via backend in local development

Commits:
* provide tests for message attachments
* remove S3_PUBLIC_URL config value

* refactor: follow @ulfgebhardt's review

* add missing environment variable

---------

Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de>
This commit is contained in:
Robert Schäfer 2025-08-19 15:11:12 +07:00 committed by GitHub
parent abb9d944f2
commit 00da9e8ecb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 614 additions and 159 deletions

View File

@ -45,7 +45,8 @@ AWS_SECRET_ACCESS_KEY=12341234
AWS_ENDPOINT=http://localhost:9000
AWS_REGION=local
AWS_BUCKET=ocelot
S3_PUBLIC_GATEWAY=http://localhost:8000
IMAGOR_PUBLIC_URL=http://localhost:8000
IMAGOR_SECRET=mysecret
CATEGORIES_ACTIVE=false
MAX_PINNED_POSTS=1

View File

@ -37,7 +37,8 @@ AWS_SECRET_ACCESS_KEY=12341234
AWS_ENDPOINT=http://localhost:9000
AWS_REGION=local
AWS_BUCKET=ocelot
S3_PUBLIC_GATEWAY=http://localhost:8000
IMAGOR_PUBLIC_URL=http://localhost:8000
IMAGOR_SECRET=mysecret
CATEGORIES_ACTIVE=false
MAX_PINNED_POSTS=1

View File

@ -30,6 +30,7 @@ const environment = {
: [],
SEND_MAIL: env.NODE_ENV !== 'test',
LOG_LEVEL: 'DEBUG',
PROXY_S3: env.PROXY_S3,
}
const server = {
@ -98,13 +99,14 @@ const required = {
AWS_REGION: env.AWS_REGION,
AWS_BUCKET: env.AWS_BUCKET,
IMAGOR_PUBLIC_URL: env.IMAGOR_PUBLIC_URL,
IMAGOR_SECRET: env.IMAGOR_SECRET,
MAPBOX_TOKEN: env.MAPBOX_TOKEN,
JWT_SECRET: env.JWT_SECRET,
PRIVATE_KEY_PASSPHRASE: env.PRIVATE_KEY_PASSPHRASE,
}
const S3_PUBLIC_GATEWAY = env.S3_PUBLIC_GATEWAY
// https://stackoverflow.com/a/53050575
type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> }
@ -151,7 +153,6 @@ const CONFIG = {
...redis,
...options,
...language,
S3_PUBLIC_GATEWAY,
}
export type Config = typeof CONFIG
@ -162,7 +163,8 @@ export type S3Config = Pick<
| 'AWS_ENDPOINT'
| 'AWS_REGION'
| 'AWS_BUCKET'
| 'S3_PUBLIC_GATEWAY'
| 'IMAGOR_SECRET'
| 'IMAGOR_PUBLIC_URL'
>
export default CONFIG

View File

@ -43,7 +43,8 @@ const config: S3Config = {
AWS_BUCKET: 'AWS_BUCKET',
AWS_ENDPOINT: 'AWS_ENDPOINT',
AWS_REGION: 'AWS_REGION',
S3_PUBLIC_GATEWAY: undefined,
IMAGOR_SECRET: 'IMAGOR_SECRET',
IMAGOR_PUBLIC_URL: 'IMAGOR_PUBLIC_URL',
}
let authenticatedUser
@ -233,25 +234,6 @@ describe('add Attachment', () => {
await expect(database.neode.all('File')).resolves.toHaveLength(1)
})
describe('given a `S3_PUBLIC_GATEWAY` configuration', () => {
const { add: addAttachment } = attachments({
...config,
S3_PUBLIC_GATEWAY: 'http://s3-public-gateway.com',
})
it('changes the domain of the URL to a server that could e.g. apply image transformations', async () => {
if (!fileInput.upload) {
throw new Error('Test imageInput was not setup correctly.')
}
const upload = await fileInput.upload
upload.filename = '/path/to/file-location/foo-bar-avatar.jpg'
fileInput.upload = Promise.resolve(upload)
await expect(addAttachment(post, 'ATTACHMENT', fileInput)).resolves.toMatchObject({
url: 'http://s3-public-gateway.com/bucket/',
})
})
})
it('connects resource with image via given image type', async () => {
await addAttachment(post, 'ATTACHMENT', fileInput)
const result = await database.neode.cypher(

View File

@ -5,7 +5,7 @@ import slug from 'slugify'
import { v4 as uuid } from 'uuid'
import type { S3Config } from '@config/index'
import { wrapTransaction } from '@graphql/resolvers/images/wrapTransaction'
import { getDriver } from '@db/neo4j'
import { s3Service } from '@src/uploads/s3Service'
import type { FileUpload } from 'graphql-upload'
@ -41,8 +41,7 @@ export interface Attachments {
resource: { id: string },
relationshipType: 'ATTACHMENT',
opts?: DeleteAttachmentsOpts,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<any>
) => Promise<File>
add: (
resource: { id: string },
@ -50,8 +49,44 @@ export interface Attachments {
file: FileInput,
fileAttributes?: object,
opts?: AddAttachmentOpts,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<any>
) => Promise<File>
}
const wrapTransactionDeleteAttachment = async (
wrappedCallback: Attachments['del'],
args: [resource: { id: string }, relationshipType: 'ATTACHMENT'],
opts: DeleteAttachmentsOpts,
): ReturnType<Attachments['del']> => {
const session = getDriver().session()
try {
const result = await session.writeTransaction((transaction) => {
return wrappedCallback(...args, { ...opts, transaction })
})
return result
} finally {
await session.close()
}
}
const wrapTransactionMergeAttachment = async (
wrappedCallback: Attachments['add'],
args: [
resource: { id: string },
relationshipType: 'ATTACHMENT',
file: FileInput,
fileAttributes?: object,
],
opts: AddAttachmentOpts,
): ReturnType<Attachments['add']> => {
const session = getDriver().session()
try {
const result = await session.writeTransaction((transaction) => {
return wrappedCallback(...args, { ...opts, transaction })
})
return result
} finally {
await session.close()
}
}
export const attachments = (config: S3Config) => {
@ -59,7 +94,8 @@ export const attachments = (config: S3Config) => {
const del: Attachments['del'] = async (resource, relationshipType, opts = {}) => {
const { transaction } = opts
if (!transaction) return wrapTransaction(del, [resource, relationshipType], opts)
if (!transaction)
return wrapTransactionDeleteAttachment(del, [resource, relationshipType], opts)
const txResult = await transaction.run(
`
MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(file:File)
@ -85,7 +121,11 @@ export const attachments = (config: S3Config) => {
) => {
const { transaction } = opts
if (!transaction)
return wrapTransaction(add, [resource, relationshipType, fileInput, fileAttributes], opts)
return wrapTransactionMergeAttachment(
add,
[resource, relationshipType, fileInput, fileAttributes],
opts,
)
const { upload } = fileInput
if (!upload) throw new UserInputError('Cannot find attachment for given resource')
@ -121,9 +161,9 @@ export const attachments = (config: S3Config) => {
return uploadedFile
}
const attachments: Attachments = {
const attachments = {
del,
add,
}
} satisfies Attachments
return attachments
}

View File

@ -0,0 +1,52 @@
import { TEST_CONFIG } from '@root/test/helpers'
import ImageResolver from './images'
describe('Image', () => {
const { Image } = ImageResolver
const Location =
'https://fsn1.your-objectstorage.com/ocelot-social-staging/original/f965ea15-1f6b-43aa-a535-927410e2585e-dsc02586.jpg'
const defaultConfig = {
...TEST_CONFIG,
AWS_ENDPOINT: 'https://fsn1.your-objectstorage.com',
IMAGOR_PUBLIC_URL: 'https://imagor-public-url.com',
IMAGOR_SECRET: 'IMAGOR_SECRET',
}
describe('.transform', () => {
describe('no transformations', () => {
const config = { ...defaultConfig }
const args = {}
it('just points the original url to imagor and adds a signature', () => {
const expectedUrl =
'https://imagor-public-url.com/f_qz7PlAWIQx-IrMOZfikzDFM6I=/ocelot-social-staging/original/f965ea15-1f6b-43aa-a535-927410e2585e-dsc02586.jpg'
expect(Image.transform({ url: Location }, args, { config })).toEqual(expectedUrl)
})
describe('if `IMAGOR_PUBLIC_URL` has a path segment', () => {
const config = {
...defaultConfig,
IMAGOR_PUBLIC_URL: 'https://imagor-public-url.com/path-segment',
}
it('keeps the path segment', () => {
const expectedUrl =
'https://imagor-public-url.com/path-segment/f_qz7PlAWIQx-IrMOZfikzDFM6I=/ocelot-social-staging/original/f965ea15-1f6b-43aa-a535-927410e2585e-dsc02586.jpg'
expect(Image.transform({ url: Location }, args, { config })).toEqual(expectedUrl)
})
})
})
describe('resize transformations', () => {
const config = { ...defaultConfig }
const args = { width: 320 }
it('encodes `fit-in` imagor transformations in the URL', () => {
const expectedUrl =
'https://imagor-public-url.com/1OEqC7g0YFxuvnRCX2hOukYMJEY=/fit-in/320x5000/ocelot-social-staging/original/f965ea15-1f6b-43aa-a535-927410e2585e-dsc02586.jpg'
expect(Image.transform({ url: Location }, args, { config })).toEqual(expectedUrl)
})
})
})
})

View File

@ -1,9 +1,83 @@
import crypto from 'node:crypto'
import { join as joinPath } from 'node:path/posix'
import type { Context } from '@src/context'
import Resolver from './helpers/Resolver'
type UrlResolver = (
parent: { url: string },
args: { width?: number; height?: number },
{
config: { IMAGOR_PUBLIC_URL },
}: Pick<Context, 'config'>,
) => string
const pointUrlToImagor: (opts: { transformations: UrlResolver[] }) => UrlResolver =
({ transformations }) =>
({ url }, _args, context) => {
const { config } = context
const { IMAGOR_PUBLIC_URL, AWS_ENDPOINT } = config
if (!IMAGOR_PUBLIC_URL) {
return url
}
const originalUrl = new URL(url, AWS_ENDPOINT)
if (originalUrl.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 url
}
const transformedUrl = new URL(
chain(...transformations)({ url: originalUrl.href }, _args, context),
)
const imagorUrl = new URL(IMAGOR_PUBLIC_URL)
imagorUrl.pathname = joinPath(imagorUrl.pathname, transformedUrl.pathname)
return imagorUrl.href
}
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'],
}),
transform: pointUrlToImagor({ transformations: [resize, sign] }),
},
}

View File

@ -38,16 +38,14 @@ export interface Images {
resource: { id: string },
relationshipType: 'HERO_IMAGE' | 'AVATAR_IMAGE',
opts?: DeleteImageOpts,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<any>
) => Promise<Image>
mergeImage: (
resource: { id: string },
relationshipType: 'HERO_IMAGE' | 'AVATAR_IMAGE',
imageInput: ImageInput | null | undefined,
opts?: MergeImageOpts,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<any>
) => Promise<Image | undefined>
}
export const images = (config: Context['config']) => imagesS3(config)

View File

@ -2,7 +2,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable promise/prefer-await-to-callbacks */
import { DeleteObjectCommand } from '@aws-sdk/client-s3'
import { Upload } from '@aws-sdk/lib-storage'
import { UserInputError } from 'apollo-server'
@ -47,7 +46,8 @@ const config: S3Config = {
AWS_BUCKET: 'AWS_BUCKET',
AWS_ENDPOINT: 'AWS_ENDPOINT',
AWS_REGION: 'AWS_REGION',
S3_PUBLIC_GATEWAY: undefined,
IMAGOR_SECRET: 'IMAGOR_SECRET',
IMAGOR_PUBLIC_URL: 'IMAGOR_PUBLIC_URL',
}
beforeAll(async () => {
@ -151,7 +151,7 @@ describe('mergeImage', () => {
beforeEach(() => {
const createReadStream: FileUpload['createReadStream'] = (() => ({
pipe: () => ({
on: (_, callback) => callback(),
on: (_: unknown, callback: () => void) => callback(), // eslint-disable-line promise/prefer-await-to-callbacks
}),
})) as unknown as FileUpload['createReadStream']
imageInput = {
@ -210,29 +210,6 @@ describe('mergeImage', () => {
})
})
describe('given a `S3_PUBLIC_GATEWAY` configuration', () => {
const { mergeImage } = images({
...config,
S3_PUBLIC_GATEWAY: 'http://s3-public-gateway.com',
})
it('changes the domain of the URL to a server that could e.g. apply image transformations', async () => {
if (!imageInput.upload) {
throw new Error('Test imageInput was not setup correctly.')
}
const upload = await imageInput.upload
upload.filename = '/path/to/file-location/foo-bar-avatar.jpg'
imageInput.upload = Promise.resolve(upload)
await expect(mergeImage(post, 'HERO_IMAGE', imageInput)).resolves.toMatchObject({
url: expect.stringMatching(
new RegExp(
`^http://s3-public-gateway.com/bucket/original/${uuid}-foo-bar-avatar.jpg`,
),
),
})
})
})
it('connects resource with image via given image type', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput)
const result = await neode.cypher(

View File

@ -8,7 +8,7 @@ import { v4 as uuid } from 'uuid'
import type { S3Config } from '@config/index'
import { s3Service } from '@src/uploads/s3Service'
import { wrapTransaction } from './wrapTransaction'
import { wrapTransactionDeleteImage, wrapTransactionMergeImage } from './wrapTransaction'
import type { Image, Images } from './images'
@ -17,7 +17,8 @@ export const images = (config: S3Config) => {
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
const { transaction } = opts
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
if (!transaction)
return wrapTransactionDeleteImage(deleteImage, [resource, relationshipType], opts)
const txResult = await transaction.run(
`
MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(image:Image)
@ -48,7 +49,7 @@ export const images = (config: S3Config) => {
if (imageInput === null) return deleteImage(resource, relationshipType, opts)
const { transaction } = opts
if (!transaction)
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)
return wrapTransactionMergeImage(mergeImage, [resource, relationshipType, imageInput], opts)
let txResult = await transaction.run(
`

View File

@ -1,21 +1,37 @@
import { getDriver } from '@db/neo4j'
import type { DeleteImageOpts, MergeImageOpts } from './images'
import type { DeleteImageOpts, MergeImageOpts, Images, ImageInput } from './images'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AsyncFunc = (...args: any[]) => Promise<any>
export const wrapTransaction = async <F extends AsyncFunc>(
wrappedCallback: F,
args: unknown[],
opts: DeleteImageOpts | MergeImageOpts,
) => {
export const wrapTransactionDeleteImage = async (
wrappedCallback: Images['deleteImage'],
args: [resource: { id: string }, relationshipType: 'HERO_IMAGE' | 'AVATAR_IMAGE'],
opts: DeleteImageOpts,
): ReturnType<Images['deleteImage']> => {
const session = getDriver().session()
try {
const result = await session.writeTransaction((transaction) => {
return wrappedCallback(...args, { ...opts, transaction })
})
return result
} finally {
await session.close()
}
}
export const wrapTransactionMergeImage = async (
wrappedCallback: Images['mergeImage'],
args: [
resource: { id: string },
relationshipType: 'HERO_IMAGE' | 'AVATAR_IMAGE',
imageInput: ImageInput | null | undefined,
],
opts: MergeImageOpts,
): ReturnType<Images['mergeImage']> => {
const session = getDriver().session()
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const result = await session.writeTransaction((transaction) => {
return wrappedCallback(...args, { ...opts, transaction })
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return result
} finally {
await session.close()

View File

@ -13,6 +13,8 @@ import { CHAT_MESSAGE_ADDED } from '@constants/subscriptions'
import { attachments } from './attachments/attachments'
import Resolver from './helpers/Resolver'
import type { File } from './attachments/attachments'
const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
return session.writeTransaction(async (transaction) => {
const setDistributedCypher = `

View File

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

View File

@ -2,6 +2,7 @@
import CONFIG from './config'
import { loggerPlugin } from './plugins/apolloLogger'
import createProxy from './proxy'
import createServer from './server'
const { server, httpServer } = createServer({
@ -14,3 +15,20 @@ httpServer.listen({ port: url.port }, () => {
/* eslint-disable-next-line no-console */
console.log(`🚀 Subscriptions ready at ws://localhost:${url.port}${server.subscriptionsPath}`)
})
if (CONFIG.PROXY_S3) {
/*
In a Docker environment, the `AWS_ENDPOINT` of the backend container would be `http://minio:9000` but this domain is not reachable from the Docker host.
Therefore, we forward the local port 9000 to "http://minio:9000." The backend can upload files to its own proxy `http://localhost:9000` and the returned file location is going to be accessible from the web frontend.
This behavior is only required in local development, not in production. Therefore, we put it behind a `CONFIG.PROXY_S3` feature flag.
*/
const target = new URL(CONFIG.PROXY_S3)
const proxy = createProxy(target)
const forwardedPort = target.port // target port and forwarded port must be the same
proxy.listen(forwardedPort, () => {
/* eslint-disable-next-line no-console */
console.log(`Simple HTTP proxy listening on port ${forwardedPort}`)
/* eslint-disable-next-line no-console */
console.log(`Proxying requests to ${target}`)
})
}

30
backend/src/proxy.ts Normal file
View File

@ -0,0 +1,30 @@
import http from 'node:http'
const createProxy = (target: URL) => {
const proxy = http.createServer((req, res) => {
const options = {
hostname: target.hostname,
port: target.port,
path: req.url,
method: req.method,
headers: req.headers,
}
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode ?? 500, proxyRes.headers)
proxyRes.pipe(res) // Pipe the response from the target server back to the client
})
req.pipe(proxyReq) // Pipe the client's request body to the target server
proxyReq.on('error', (err) => {
/* eslint-disable-next-line no-console */
console.error('Proxy request error:', err)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('Proxy error')
})
})
return proxy
}
export default createProxy

View File

@ -0,0 +1,80 @@
import { Upload } from '@aws-sdk/lib-storage'
import type { S3Config } from '@config/index'
import { s3Service } from './s3Service'
import type { FileUpload } from 'graphql-upload'
jest.mock('@aws-sdk/client-s3', () => {
return {
S3Client: jest.fn().mockImplementation(() => ({
send: jest.fn(),
})),
ObjectCannedACL: { public_read: 'public_read' },
DeleteObjectCommand: jest.fn().mockImplementation(() => ({})),
}
})
jest.mock('@aws-sdk/lib-storage', () => {
return {
Upload: jest.fn(),
}
})
const uploadMock = Upload as unknown as jest.Mock
const createReadStream: FileUpload['createReadStream'] = (() => ({
pipe: () => ({
on: (_: unknown, callback: () => void) => callback(), // eslint-disable-line promise/prefer-await-to-callbacks
}),
})) as unknown as FileUpload['createReadStream']
const input = {
uniqueFilename: 'unique-filename.jpg',
mimetype: 'image/jpeg',
createReadStream,
}
const config: S3Config = {
AWS_ACCESS_KEY_ID: 'AWS_ACCESS_KEY_ID',
AWS_SECRET_ACCESS_KEY: 'AWS_SECRET_ACCESS_KEY',
AWS_BUCKET: 'AWS_BUCKET',
AWS_ENDPOINT: 'AWS_ENDPOINT',
AWS_REGION: 'AWS_REGION',
IMAGOR_SECRET: 'IMAGOR_SECRET',
IMAGOR_PUBLIC_URL: 'IMAGOR_PUBLIC_URL',
}
describe('s3Service', () => {
describe('upload', () => {
describe('if the S3 service returns a valid URL as a `Location`', () => {
beforeEach(() => {
uploadMock.mockImplementation(({ params: { Key } }: { params: { Key: string } }) => ({
done: () => Promise.resolve({ Location: `http://your-objectstorage.com/bucket/${Key}` }),
}))
})
it('returns the `Location` that was returned by the s3 client library', async () => {
const service = s3Service(config, 'ocelot-social')
await expect(service.uploadFile(input)).resolves.toEqual(
'http://your-objectstorage.com/bucket/ocelot-social/unique-filename.jpg',
)
})
})
describe('but if for some reason, the S3 service returns a `Location` wich is not a valid URL and misses the protocol part', () => {
beforeEach(() => {
uploadMock.mockImplementation(({ params: { Key } }: { params: { Key: string } }) => ({
done: () => Promise.resolve({ Location: `your-objectstorage.com/bucket/${Key}` }),
}))
})
it('adds `https:` as protocol', async () => {
const service = s3Service(config, 'ocelot-social')
await expect(service.uploadFile(input)).resolves.toEqual(
'https://your-objectstorage.com/bucket/ocelot-social/unique-filename.jpg',
)
})
})
})
})

View File

@ -8,7 +8,7 @@ import { FileUploadCallback, FileDeleteCallback } from './types'
export const s3Service = (config: S3Config, prefix: string) => {
const { AWS_BUCKET: Bucket } = config
const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_PUBLIC_GATEWAY } = config
const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY } = config
const s3 = new S3Client({
credentials: {
accessKeyId: AWS_ACCESS_KEY_ID,
@ -40,13 +40,7 @@ export const s3Service = (config: S3Config, prefix: string) => {
location = `https://${location}`
}
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 deleteFile: FileDeleteCallback = async (url) => {

View File

@ -16,6 +16,7 @@ export const TEST_CONFIG = {
PRODUCTION_DB_CLEAN_ALLOW: false,
DISABLED_MIDDLEWARES: [],
SEND_MAIL: false,
PROXY_S3: 'http://minio:9000',
CLIENT_URI: 'http://webapp:3000',
GRAPHQL_URI: 'http://localhost:4000',
@ -43,7 +44,8 @@ export const TEST_CONFIG = {
AWS_REGION: 'local',
AWS_BUCKET: 'ocelot',
S3_PUBLIC_GATEWAY: undefined,
IMAGOR_SECRET: 'IMAGOR_SECRET',
IMAGOR_PUBLIC_URL: 'IMAGOR_PUBLIC_URL',
EMAIL_DEFAULT_SENDER: '',
SUPPORT_EMAIL: '',

View File

@ -20,6 +20,9 @@ spec:
imagePullPolicy: {{ quote .Values.global.image.pullPolicy }}
command: ["/bin/sh", "-c", "yarn prod:migrate init && yarn prod:migrate up"]
{{- include "resources" .Values.backend.resources | indent 10 }}
env:
- name: IMAGOR_PUBLIC_URL
value: "https://{{ .Values.domain }}/imagor"
envFrom:
- configMapRef:
name: {{ .Release.Name }}-backend-env
@ -38,6 +41,8 @@ spec:
value: "http://{{ .Release.Name }}-backend:4000"
- name: CLIENT_URI
value: "https://{{ .Values.domain }}"
- name: IMAGOR_PUBLIC_URL
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

@ -25,9 +25,12 @@ services:
backend:
image: ghcr.io/ocelot-social-community/ocelot-social/backend:local-development
ports:
- 9000:9000
depends_on:
- minio
- minio-mc
- imagor
build:
target: development
environment:
@ -36,10 +39,12 @@ services:
- SMTP_HOST=mailserver
- AWS_ACCESS_KEY_ID=minio
- AWS_SECRET_ACCESS_KEY=12341234
- AWS_ENDPOINT=http:/minio:9000
- AWS_ENDPOINT=http:/localhost:9000
- AWS_REGION=local
- AWS_BUCKET=ocelot
- S3_PUBLIC_GATEWAY=http:/localhost:9000
- IMAGOR_PUBLIC_URL=http://localhost:8000
- IMAGOR_SECRET=mysecret
- PROXY_S3=http://minio:9000
volumes:
- ./backend:/app
@ -58,7 +63,6 @@ services:
minio:
image: quay.io/minio/minio
ports:
- 9000:9000
- 9001:9001
volumes:
- minio_data:/data
@ -82,5 +86,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: minio
AWS_SECRET_ACCESS_KEY: 12341234
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

@ -27,6 +27,9 @@ services:
- AWS_REGION=local
- AWS_BUCKET=ocelot
- DEBUG=
- IMAGOR_PUBLIC_URL=http://localhost:8000
- IMAGOR_SECRET=mysecret
- PROXY_S3=http://minio:9000
volumes:
- ./coverage:/app/coverage

View File

@ -7,7 +7,7 @@
@click="handleBadgeClick(badge, index)"
>
<div class="badge-icon">
<img :src="badge.icon | proxyApiUrl" :alt="badge.id" />
<img :src="backendPath(badge.icon)" :alt="badge.id" />
</div>
<div class="badge-info">
<div class="badge-description">{{ badge.description }}</div>
@ -17,6 +17,7 @@
</template>
<script>
import { backendPath } from '~/helpers/backendPath'
export default {
name: 'BadgeSelection',
props: {
@ -31,6 +32,7 @@ export default {
}
},
methods: {
backendPath,
handleBadgeClick(badge, index) {
if (this.selectedIndex === index) {
this.selectedIndex = null

View File

@ -8,12 +8,13 @@
: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="backendPath(badge.icon)" class="hc-badge" />
</component>
</div>
</template>
<script>
import { backendPath } from '~/helpers/backendPath'
export default {
name: 'Badges',
props: {
@ -32,6 +33,7 @@ export default {
}
},
methods: {
backendPath,
handleBadgeClick(index) {
if (!this.selectionMode || index === 0) {
return

View File

@ -344,7 +344,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)
@ -466,7 +466,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,
@ -474,7 +474,7 @@ export default {
}
: {},
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) {
@ -521,8 +521,7 @@ export default {
from the same origin or from local blob storage. So we fetch it first
and then create a download link from blob storage. */
const url = this.$filters.proxyApiUrl(file.url)
const download = await fetch(url, {
const download = await fetch(file.url, {
method: 'GET',
headers: {
'Content-Type': file.type,

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">
@ -139,6 +139,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'
@ -156,6 +157,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

@ -21,7 +21,12 @@ const userTilda = {
name: 'Tilda Swinton',
slug: 'tilda-swinton',
id: 'user1',
avatar: '/avatars/tilda-swinton',
avatar: {
url: '/avatars/tilda-swinton',
w320: '/avatars/tilda-swinton-w320',
w640: '/avatars/tilda-swinton-w640',
w1024: '/avatars/tilda-swinton-w1024',
},
badgeVerification: {
id: 'bv1',
icon: '/icons/verified',

View File

@ -173,7 +173,9 @@ exports[`UserTeaser given an user user is disabled current user is a moderator r
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -293,7 +295,9 @@ exports[`UserTeaser given an user with linkToProfile, on desktop renders 1`] = `
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -381,7 +385,9 @@ exports[`UserTeaser given an user with linkToProfile, on desktop when hovering t
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -469,7 +475,9 @@ exports[`UserTeaser given an user with linkToProfile, on touch screen renders 1`
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -558,7 +566,9 @@ exports[`UserTeaser given an user with linkToProfile, on touch screen when click
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -651,7 +661,9 @@ exports[`UserTeaser given an user without linkToProfile, on desktop renders 1`]
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -739,7 +751,9 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -827,7 +841,9 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -915,7 +931,9 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen renders
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -1004,7 +1022,9 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen when cl
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>
@ -1097,7 +1117,9 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen when cl
<img
alt="Tilda Swinton"
class="image"
src="/api/avatars/tilda-swinton"
sizes="320px"
src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
title="Tilda Swinton"
/>
</div>

View File

@ -8,7 +8,7 @@
@click="toggleBadge(badge)"
:class="{ badge, inactive: !badge.isActive }"
>
<img :src="badge.icon | proxyApiUrl" :alt="badge.description" />
<img :src="backendPath(badge.icon)" :alt="badge.description" />
</button>
</div>
<div v-else>
@ -18,6 +18,7 @@
</template>
<script>
import { backendPath } from '~/helpers/backendPath'
export default {
props: {
title: {
@ -28,6 +29,7 @@ export default {
},
},
methods: {
backendPath,
toggleBadge(badge) {
this.$emit('toggleBadge', badge)
},

View File

@ -18,7 +18,7 @@ exports[`Admin/BadgesSection with badges renders 1`] = `
>
<img
alt="description1"
src="icon1"
src="/api/icon1"
/>
</button>
<button
@ -26,7 +26,7 @@ exports[`Admin/BadgesSection with badges renders 1`] = `
>
<img
alt="description2"
src="icon2"
src="/api/icon2"
/>
</button>
</div>

View File

@ -61,43 +61,35 @@ describe('ProfileAvatar', () => {
})
})
describe('with a relative avatar url', () => {
describe('with an avatar', () => {
beforeEach(() => {
propsData = {
profile: {
name: 'Not Anonymous',
avatar: {
url: '/avatar.jpg',
url: 'http://localhost:8000//avatar.jpg',
w320: 'http://localhost:8000//avatars/avatar-w320.jpg',
w640: 'http://localhost:8000//avatars/avatar-w640.jpg',
w1024: 'http://localhost:8000//avatars/avatar-w1024.jpg',
},
},
}
wrapper = Wrapper()
})
it('adds a prefix to load the image from the uploads service', () => {
expect(wrapper.find('.image').attributes('src')).toBe('/api/avatar.jpg')
})
})
describe('with an absolute avatar url', () => {
beforeEach(() => {
propsData = {
profile: {
name: 'Not Anonymous',
avatar: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',
},
},
}
wrapper = Wrapper()
it('puts the original url in `src` attribute', () => {
expect(wrapper.find('.image').attributes('src')).toBe('http://localhost:8000//avatar.jpg')
})
it('keeps the avatar URL as is', () => {
// e.g. our seeds have absolute image URLs
expect(wrapper.find('.image').attributes('src')).toBe(
'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',
it('puts various sizes of the image in `srcset` attribute', () => {
expect(wrapper.find('.image').attributes('srcset')).toBe(
'http://localhost:8000//avatars/avatar-w320.jpg 320w, http://localhost:8000//avatars/avatar-w640.jpg 640w, http://localhost:8000//avatars/avatar-w1024.jpg 1024w',
)
})
it('but because the avatar is so small, it will always ask the browser to render w320 size', () => {
expect(wrapper.find('.image').attributes('sizes')).toBe('320px')
})
})
})
})

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

@ -0,0 +1 @@
export const backendPath = (url) => (url.startsWith('/') ? '/api' + url : '/api/' + url)

View File

@ -51,7 +51,7 @@ exports[`.vue renders 1`] = `
>
<img
alt="description-v-1"
src="icon1"
src="/api/icon1"
/>
</button>
<button
@ -59,7 +59,7 @@ exports[`.vue renders 1`] = `
>
<img
alt="description-v-2"
src="icon2"
src="/api/icon2"
/>
</button>
</div>
@ -80,7 +80,7 @@ exports[`.vue renders 1`] = `
>
<img
alt="description-t-1"
src="icon3"
src="/api/icon3"
/>
</button>
<button
@ -88,7 +88,7 @@ exports[`.vue renders 1`] = `
>
<img
alt="description-t-2"
src="icon4"
src="/api/icon4"
/>
</button>
</div>

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