mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
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:
parent
abb9d944f2
commit
00da9e8ecb
@ -45,7 +45,8 @@ AWS_SECRET_ACCESS_KEY=12341234
|
|||||||
AWS_ENDPOINT=http://localhost:9000
|
AWS_ENDPOINT=http://localhost:9000
|
||||||
AWS_REGION=local
|
AWS_REGION=local
|
||||||
AWS_BUCKET=ocelot
|
AWS_BUCKET=ocelot
|
||||||
S3_PUBLIC_GATEWAY=http://localhost:8000
|
IMAGOR_PUBLIC_URL=http://localhost:8000
|
||||||
|
IMAGOR_SECRET=mysecret
|
||||||
|
|
||||||
CATEGORIES_ACTIVE=false
|
CATEGORIES_ACTIVE=false
|
||||||
MAX_PINNED_POSTS=1
|
MAX_PINNED_POSTS=1
|
||||||
|
|||||||
@ -37,7 +37,8 @@ AWS_SECRET_ACCESS_KEY=12341234
|
|||||||
AWS_ENDPOINT=http://localhost:9000
|
AWS_ENDPOINT=http://localhost:9000
|
||||||
AWS_REGION=local
|
AWS_REGION=local
|
||||||
AWS_BUCKET=ocelot
|
AWS_BUCKET=ocelot
|
||||||
S3_PUBLIC_GATEWAY=http://localhost:8000
|
IMAGOR_PUBLIC_URL=http://localhost:8000
|
||||||
|
IMAGOR_SECRET=mysecret
|
||||||
|
|
||||||
CATEGORIES_ACTIVE=false
|
CATEGORIES_ACTIVE=false
|
||||||
MAX_PINNED_POSTS=1
|
MAX_PINNED_POSTS=1
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const environment = {
|
|||||||
: [],
|
: [],
|
||||||
SEND_MAIL: env.NODE_ENV !== 'test',
|
SEND_MAIL: env.NODE_ENV !== 'test',
|
||||||
LOG_LEVEL: 'DEBUG',
|
LOG_LEVEL: 'DEBUG',
|
||||||
|
PROXY_S3: env.PROXY_S3,
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = {
|
const server = {
|
||||||
@ -98,13 +99,14 @@ const required = {
|
|||||||
AWS_REGION: env.AWS_REGION,
|
AWS_REGION: env.AWS_REGION,
|
||||||
AWS_BUCKET: env.AWS_BUCKET,
|
AWS_BUCKET: env.AWS_BUCKET,
|
||||||
|
|
||||||
|
IMAGOR_PUBLIC_URL: env.IMAGOR_PUBLIC_URL,
|
||||||
|
IMAGOR_SECRET: env.IMAGOR_SECRET,
|
||||||
|
|
||||||
MAPBOX_TOKEN: env.MAPBOX_TOKEN,
|
MAPBOX_TOKEN: env.MAPBOX_TOKEN,
|
||||||
JWT_SECRET: env.JWT_SECRET,
|
JWT_SECRET: env.JWT_SECRET,
|
||||||
PRIVATE_KEY_PASSPHRASE: env.PRIVATE_KEY_PASSPHRASE,
|
PRIVATE_KEY_PASSPHRASE: env.PRIVATE_KEY_PASSPHRASE,
|
||||||
}
|
}
|
||||||
|
|
||||||
const S3_PUBLIC_GATEWAY = env.S3_PUBLIC_GATEWAY
|
|
||||||
|
|
||||||
// https://stackoverflow.com/a/53050575
|
// https://stackoverflow.com/a/53050575
|
||||||
type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> }
|
type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> }
|
||||||
|
|
||||||
@ -151,7 +153,6 @@ const CONFIG = {
|
|||||||
...redis,
|
...redis,
|
||||||
...options,
|
...options,
|
||||||
...language,
|
...language,
|
||||||
S3_PUBLIC_GATEWAY,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Config = typeof CONFIG
|
export type Config = typeof CONFIG
|
||||||
@ -162,7 +163,8 @@ export type S3Config = Pick<
|
|||||||
| 'AWS_ENDPOINT'
|
| 'AWS_ENDPOINT'
|
||||||
| 'AWS_REGION'
|
| 'AWS_REGION'
|
||||||
| 'AWS_BUCKET'
|
| 'AWS_BUCKET'
|
||||||
| 'S3_PUBLIC_GATEWAY'
|
| 'IMAGOR_SECRET'
|
||||||
|
| 'IMAGOR_PUBLIC_URL'
|
||||||
>
|
>
|
||||||
export default CONFIG
|
export default CONFIG
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,8 @@ const config: S3Config = {
|
|||||||
AWS_BUCKET: 'AWS_BUCKET',
|
AWS_BUCKET: 'AWS_BUCKET',
|
||||||
AWS_ENDPOINT: 'AWS_ENDPOINT',
|
AWS_ENDPOINT: 'AWS_ENDPOINT',
|
||||||
AWS_REGION: 'AWS_REGION',
|
AWS_REGION: 'AWS_REGION',
|
||||||
S3_PUBLIC_GATEWAY: undefined,
|
IMAGOR_SECRET: 'IMAGOR_SECRET',
|
||||||
|
IMAGOR_PUBLIC_URL: 'IMAGOR_PUBLIC_URL',
|
||||||
}
|
}
|
||||||
|
|
||||||
let authenticatedUser
|
let authenticatedUser
|
||||||
@ -233,25 +234,6 @@ describe('add Attachment', () => {
|
|||||||
await expect(database.neode.all('File')).resolves.toHaveLength(1)
|
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 () => {
|
it('connects resource with image via given image type', async () => {
|
||||||
await addAttachment(post, 'ATTACHMENT', fileInput)
|
await addAttachment(post, 'ATTACHMENT', fileInput)
|
||||||
const result = await database.neode.cypher(
|
const result = await database.neode.cypher(
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import slug from 'slugify'
|
|||||||
import { v4 as uuid } from 'uuid'
|
import { v4 as uuid } from 'uuid'
|
||||||
|
|
||||||
import type { S3Config } from '@config/index'
|
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 { s3Service } from '@src/uploads/s3Service'
|
||||||
|
|
||||||
import type { FileUpload } from 'graphql-upload'
|
import type { FileUpload } from 'graphql-upload'
|
||||||
@ -41,8 +41,7 @@ export interface Attachments {
|
|||||||
resource: { id: string },
|
resource: { id: string },
|
||||||
relationshipType: 'ATTACHMENT',
|
relationshipType: 'ATTACHMENT',
|
||||||
opts?: DeleteAttachmentsOpts,
|
opts?: DeleteAttachmentsOpts,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
) => Promise<File>
|
||||||
) => Promise<any>
|
|
||||||
|
|
||||||
add: (
|
add: (
|
||||||
resource: { id: string },
|
resource: { id: string },
|
||||||
@ -50,8 +49,44 @@ export interface Attachments {
|
|||||||
file: FileInput,
|
file: FileInput,
|
||||||
fileAttributes?: object,
|
fileAttributes?: object,
|
||||||
opts?: AddAttachmentOpts,
|
opts?: AddAttachmentOpts,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
) => Promise<File>
|
||||||
) => Promise<any>
|
}
|
||||||
|
|
||||||
|
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) => {
|
export const attachments = (config: S3Config) => {
|
||||||
@ -59,7 +94,8 @@ export const attachments = (config: S3Config) => {
|
|||||||
|
|
||||||
const del: Attachments['del'] = async (resource, relationshipType, opts = {}) => {
|
const del: Attachments['del'] = async (resource, relationshipType, opts = {}) => {
|
||||||
const { transaction } = 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(
|
const txResult = await transaction.run(
|
||||||
`
|
`
|
||||||
MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(file:File)
|
MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(file:File)
|
||||||
@ -85,7 +121,11 @@ export const attachments = (config: S3Config) => {
|
|||||||
) => {
|
) => {
|
||||||
const { transaction } = opts
|
const { transaction } = opts
|
||||||
if (!transaction)
|
if (!transaction)
|
||||||
return wrapTransaction(add, [resource, relationshipType, fileInput, fileAttributes], opts)
|
return wrapTransactionMergeAttachment(
|
||||||
|
add,
|
||||||
|
[resource, relationshipType, fileInput, fileAttributes],
|
||||||
|
opts,
|
||||||
|
)
|
||||||
|
|
||||||
const { upload } = fileInput
|
const { upload } = fileInput
|
||||||
if (!upload) throw new UserInputError('Cannot find attachment for given resource')
|
if (!upload) throw new UserInputError('Cannot find attachment for given resource')
|
||||||
@ -121,9 +161,9 @@ export const attachments = (config: S3Config) => {
|
|||||||
return uploadedFile
|
return uploadedFile
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachments: Attachments = {
|
const attachments = {
|
||||||
del,
|
del,
|
||||||
add,
|
add,
|
||||||
}
|
} satisfies Attachments
|
||||||
return attachments
|
return attachments
|
||||||
}
|
}
|
||||||
|
|||||||
52
backend/src/graphql/resolvers/images.spec.ts
Normal file
52
backend/src/graphql/resolvers/images.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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'
|
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 {
|
export default {
|
||||||
Image: {
|
Image: {
|
||||||
...Resolver('Image', {
|
...Resolver('Image', {
|
||||||
undefinedToNull: ['sensitive', 'alt', 'aspectRatio', 'type'],
|
undefinedToNull: ['sensitive', 'alt', 'aspectRatio', 'type'],
|
||||||
}),
|
}),
|
||||||
|
transform: pointUrlToImagor({ transformations: [resize, sign] }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,16 +38,14 @@ export interface Images {
|
|||||||
resource: { id: string },
|
resource: { id: string },
|
||||||
relationshipType: 'HERO_IMAGE' | 'AVATAR_IMAGE',
|
relationshipType: 'HERO_IMAGE' | 'AVATAR_IMAGE',
|
||||||
opts?: DeleteImageOpts,
|
opts?: DeleteImageOpts,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
) => Promise<Image>
|
||||||
) => Promise<any>
|
|
||||||
|
|
||||||
mergeImage: (
|
mergeImage: (
|
||||||
resource: { id: string },
|
resource: { id: string },
|
||||||
relationshipType: 'HERO_IMAGE' | 'AVATAR_IMAGE',
|
relationshipType: 'HERO_IMAGE' | 'AVATAR_IMAGE',
|
||||||
imageInput: ImageInput | null | undefined,
|
imageInput: ImageInput | null | undefined,
|
||||||
opts?: MergeImageOpts,
|
opts?: MergeImageOpts,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
) => Promise<Image | undefined>
|
||||||
) => Promise<any>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const images = (config: Context['config']) => imagesS3(config)
|
export const images = (config: Context['config']) => imagesS3(config)
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
/* eslint-disable promise/prefer-await-to-callbacks */
|
|
||||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3'
|
import { DeleteObjectCommand } from '@aws-sdk/client-s3'
|
||||||
import { Upload } from '@aws-sdk/lib-storage'
|
import { Upload } from '@aws-sdk/lib-storage'
|
||||||
import { UserInputError } from 'apollo-server'
|
import { UserInputError } from 'apollo-server'
|
||||||
@ -47,7 +46,8 @@ const config: S3Config = {
|
|||||||
AWS_BUCKET: 'AWS_BUCKET',
|
AWS_BUCKET: 'AWS_BUCKET',
|
||||||
AWS_ENDPOINT: 'AWS_ENDPOINT',
|
AWS_ENDPOINT: 'AWS_ENDPOINT',
|
||||||
AWS_REGION: 'AWS_REGION',
|
AWS_REGION: 'AWS_REGION',
|
||||||
S3_PUBLIC_GATEWAY: undefined,
|
IMAGOR_SECRET: 'IMAGOR_SECRET',
|
||||||
|
IMAGOR_PUBLIC_URL: 'IMAGOR_PUBLIC_URL',
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@ -151,7 +151,7 @@ describe('mergeImage', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const createReadStream: FileUpload['createReadStream'] = (() => ({
|
const createReadStream: FileUpload['createReadStream'] = (() => ({
|
||||||
pipe: () => ({
|
pipe: () => ({
|
||||||
on: (_, callback) => callback(),
|
on: (_: unknown, callback: () => void) => callback(), // eslint-disable-line promise/prefer-await-to-callbacks
|
||||||
}),
|
}),
|
||||||
})) as unknown as FileUpload['createReadStream']
|
})) as unknown as FileUpload['createReadStream']
|
||||||
imageInput = {
|
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 () => {
|
it('connects resource with image via given image type', async () => {
|
||||||
await mergeImage(post, 'HERO_IMAGE', imageInput)
|
await mergeImage(post, 'HERO_IMAGE', imageInput)
|
||||||
const result = await neode.cypher(
|
const result = await neode.cypher(
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { v4 as uuid } from 'uuid'
|
|||||||
import type { S3Config } from '@config/index'
|
import type { S3Config } from '@config/index'
|
||||||
import { s3Service } from '@src/uploads/s3Service'
|
import { s3Service } from '@src/uploads/s3Service'
|
||||||
|
|
||||||
import { wrapTransaction } from './wrapTransaction'
|
import { wrapTransactionDeleteImage, wrapTransactionMergeImage } from './wrapTransaction'
|
||||||
|
|
||||||
import type { Image, Images } from './images'
|
import type { Image, Images } from './images'
|
||||||
|
|
||||||
@ -17,7 +17,8 @@ export const images = (config: S3Config) => {
|
|||||||
|
|
||||||
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
|
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
|
||||||
const { transaction } = 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(
|
const txResult = await transaction.run(
|
||||||
`
|
`
|
||||||
MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(image:Image)
|
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)
|
if (imageInput === null) return deleteImage(resource, relationshipType, opts)
|
||||||
const { transaction } = opts
|
const { transaction } = opts
|
||||||
if (!transaction)
|
if (!transaction)
|
||||||
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)
|
return wrapTransactionMergeImage(mergeImage, [resource, relationshipType, imageInput], opts)
|
||||||
|
|
||||||
let txResult = await transaction.run(
|
let txResult = await transaction.run(
|
||||||
`
|
`
|
||||||
|
|||||||
@ -1,21 +1,37 @@
|
|||||||
import { getDriver } from '@db/neo4j'
|
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
|
export const wrapTransactionDeleteImage = async (
|
||||||
type AsyncFunc = (...args: any[]) => Promise<any>
|
wrappedCallback: Images['deleteImage'],
|
||||||
export const wrapTransaction = async <F extends AsyncFunc>(
|
args: [resource: { id: string }, relationshipType: 'HERO_IMAGE' | 'AVATAR_IMAGE'],
|
||||||
wrappedCallback: F,
|
opts: DeleteImageOpts,
|
||||||
args: unknown[],
|
): ReturnType<Images['deleteImage']> => {
|
||||||
opts: DeleteImageOpts | MergeImageOpts,
|
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()
|
const session = getDriver().session()
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
const result = await session.writeTransaction((transaction) => {
|
const result = await session.writeTransaction((transaction) => {
|
||||||
return wrappedCallback(...args, { ...opts, transaction })
|
return wrappedCallback(...args, { ...opts, transaction })
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
||||||
return result
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import { CHAT_MESSAGE_ADDED } from '@constants/subscriptions'
|
|||||||
import { attachments } from './attachments/attachments'
|
import { attachments } from './attachments/attachments'
|
||||||
import Resolver from './helpers/Resolver'
|
import Resolver from './helpers/Resolver'
|
||||||
|
|
||||||
|
import type { File } from './attachments/attachments'
|
||||||
|
|
||||||
const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
|
const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
|
||||||
return session.writeTransaction(async (transaction) => {
|
return session.writeTransaction(async (transaction) => {
|
||||||
const setDistributedCypher = `
|
const setDistributedCypher = `
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
type Image {
|
type Image {
|
||||||
url: ID!,
|
url: ID!,
|
||||||
|
transform(width: Int, height: Int): String
|
||||||
# urlW34: String,
|
# urlW34: String,
|
||||||
# urlW160: String,
|
# urlW160: String,
|
||||||
# urlW320: String,
|
# urlW320: String,
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import CONFIG from './config'
|
import CONFIG from './config'
|
||||||
import { loggerPlugin } from './plugins/apolloLogger'
|
import { loggerPlugin } from './plugins/apolloLogger'
|
||||||
|
import createProxy from './proxy'
|
||||||
import createServer from './server'
|
import createServer from './server'
|
||||||
|
|
||||||
const { server, httpServer } = createServer({
|
const { server, httpServer } = createServer({
|
||||||
@ -14,3 +15,20 @@ httpServer.listen({ port: url.port }, () => {
|
|||||||
/* eslint-disable-next-line no-console */
|
/* eslint-disable-next-line no-console */
|
||||||
console.log(`🚀 Subscriptions ready at ws://localhost:${url.port}${server.subscriptionsPath}`)
|
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
30
backend/src/proxy.ts
Normal 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
|
||||||
80
backend/src/uploads/s3Service.spec.ts
Normal file
80
backend/src/uploads/s3Service.spec.ts
Normal 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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -8,7 +8,7 @@ import { FileUploadCallback, FileDeleteCallback } from './types'
|
|||||||
export const s3Service = (config: S3Config, prefix: string) => {
|
export const s3Service = (config: S3Config, prefix: string) => {
|
||||||
const { AWS_BUCKET: Bucket } = config
|
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({
|
const s3 = new S3Client({
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||||
@ -40,13 +40,7 @@ export const s3Service = (config: S3Config, prefix: string) => {
|
|||||||
location = `https://${location}`
|
location = `https://${location}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!S3_PUBLIC_GATEWAY) {
|
return location
|
||||||
return location
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicLocation = new URL(S3_PUBLIC_GATEWAY)
|
|
||||||
publicLocation.pathname = new URL(location).pathname
|
|
||||||
return publicLocation.href
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteFile: FileDeleteCallback = async (url) => {
|
const deleteFile: FileDeleteCallback = async (url) => {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export const TEST_CONFIG = {
|
|||||||
PRODUCTION_DB_CLEAN_ALLOW: false,
|
PRODUCTION_DB_CLEAN_ALLOW: false,
|
||||||
DISABLED_MIDDLEWARES: [],
|
DISABLED_MIDDLEWARES: [],
|
||||||
SEND_MAIL: false,
|
SEND_MAIL: false,
|
||||||
|
PROXY_S3: 'http://minio:9000',
|
||||||
|
|
||||||
CLIENT_URI: 'http://webapp:3000',
|
CLIENT_URI: 'http://webapp:3000',
|
||||||
GRAPHQL_URI: 'http://localhost:4000',
|
GRAPHQL_URI: 'http://localhost:4000',
|
||||||
@ -43,7 +44,8 @@ export const TEST_CONFIG = {
|
|||||||
AWS_REGION: 'local',
|
AWS_REGION: 'local',
|
||||||
AWS_BUCKET: 'ocelot',
|
AWS_BUCKET: 'ocelot',
|
||||||
|
|
||||||
S3_PUBLIC_GATEWAY: undefined,
|
IMAGOR_SECRET: 'IMAGOR_SECRET',
|
||||||
|
IMAGOR_PUBLIC_URL: 'IMAGOR_PUBLIC_URL',
|
||||||
|
|
||||||
EMAIL_DEFAULT_SENDER: '',
|
EMAIL_DEFAULT_SENDER: '',
|
||||||
SUPPORT_EMAIL: '',
|
SUPPORT_EMAIL: '',
|
||||||
|
|||||||
@ -20,6 +20,9 @@ spec:
|
|||||||
imagePullPolicy: {{ quote .Values.global.image.pullPolicy }}
|
imagePullPolicy: {{ quote .Values.global.image.pullPolicy }}
|
||||||
command: ["/bin/sh", "-c", "yarn prod:migrate init && yarn prod:migrate up"]
|
command: ["/bin/sh", "-c", "yarn prod:migrate init && yarn prod:migrate up"]
|
||||||
{{- include "resources" .Values.backend.resources | indent 10 }}
|
{{- include "resources" .Values.backend.resources | indent 10 }}
|
||||||
|
env:
|
||||||
|
- name: IMAGOR_PUBLIC_URL
|
||||||
|
value: "https://{{ .Values.domain }}/imagor"
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: {{ .Release.Name }}-backend-env
|
name: {{ .Release.Name }}-backend-env
|
||||||
@ -38,6 +41,8 @@ spec:
|
|||||||
value: "http://{{ .Release.Name }}-backend:4000"
|
value: "http://{{ .Release.Name }}-backend:4000"
|
||||||
- name: CLIENT_URI
|
- name: CLIENT_URI
|
||||||
value: "https://{{ .Values.domain }}"
|
value: "https://{{ .Values.domain }}"
|
||||||
|
- name: IMAGOR_PUBLIC_URL
|
||||||
|
value: "https://{{ .Values.domain }}/imagor"
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: {{ .Release.Name }}-backend-env
|
name: {{ .Release.Name }}-backend-env
|
||||||
|
|||||||
@ -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
|
||||||
@ -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 }}
|
||||||
@ -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
|
||||||
@ -62,4 +62,34 @@ spec:
|
|||||||
regex: ^https://{{ . }}(.*)
|
regex: ^https://{{ . }}(.*)
|
||||||
replacement: https://{{ $.Values.domain }}${1}
|
replacement: https://{{ $.Values.domain }}${1}
|
||||||
permanent: true
|
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
|
||||||
|
|||||||
@ -25,3 +25,9 @@ webapp:
|
|||||||
maintenance:
|
maintenance:
|
||||||
image:
|
image:
|
||||||
repository: ghcr.io/ocelot-social-community/ocelot-social/maintenance
|
repository: ghcr.io/ocelot-social-community/ocelot-social/maintenance
|
||||||
|
|
||||||
|
imagor:
|
||||||
|
image:
|
||||||
|
repository: shumc/imagor
|
||||||
|
tag: 1.5.4
|
||||||
|
|
||||||
|
|||||||
@ -23,15 +23,25 @@ secrets:
|
|||||||
NEO4J_USERNAME: null
|
NEO4J_USERNAME: null
|
||||||
NEO4J_PASSWORD: null
|
NEO4J_PASSWORD: null
|
||||||
REDIS_PASSWORD: null
|
REDIS_PASSWORD: null
|
||||||
AWS_ACCESS_KEY_ID: ENC[AES256_GCM,data:iiN5ueqyo60VHb9e2bnhc19iGTg=,iv:zawYpKrFafgsu1+YRet1hzZf1G3a6BIlZgsh7xNADaE=,tag:rTsmm8cqei34b6cT6vn08w==,type:str]
|
IMAGOR_SECRET: null
|
||||||
AWS_SECRET_ACCESS_KEY: ENC[AES256_GCM,data:Zl4LRXdDh/6Q8F9RVp+3L7NXGZ0F2cgFMKPhl/TVeuD5Bhy68W5ekg==,iv:AmPoinGISrSOZdoBKdeFFXfr2hwOK4nWMnniz8K5qgU=,tag:K8Q7M7e+6G9T0Oh3Sp4OzA==,type:str]
|
AWS_ACCESS_KEY_ID: null
|
||||||
AWS_ENDPOINT: ENC[AES256_GCM,data:/waEqUgcOmldZ+peFTNVsDQf2KrpWY8ZZMt1nT5117SkbY4=,iv:n+Kvidjb/TM4bQYKqTaFxt8GkHo02PuxEGpzgOcywr4=,tag:lrGPgCWWy3GMIcTv75IYTg==,type:str]
|
AWS_SECRET_ACCESS_KEY: null
|
||||||
AWS_REGION: ENC[AES256_GCM,data:kBPpHZ8zw4PMpg==,iv:R+QZe303do37Hd/97NpS1pt9VaBE/gqZDY2/qlIvvps=,tag:0WduW8wfJXtBqlh4qfRGNA==,type:str]
|
AWS_ENDPOINT: null
|
||||||
AWS_BUCKET: ENC[AES256_GCM,data:0fAspN/PoRVPlSbz+qDBRUOieeC4,iv:JGJ/LyLpMymN0tpZmW6DjPT3xqXzK/KhYQsy9sgPd60=,tag:Y6PBs0916JkHRHSe7hqSMA==,type:str]
|
AWS_REGION: null
|
||||||
|
AWS_BUCKET: null
|
||||||
neo4j:
|
neo4j:
|
||||||
env:
|
env:
|
||||||
NEO4J_USERNAME: ""
|
NEO4J_USERNAME: ""
|
||||||
NEO4J_PASSWORD: ""
|
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:
|
sops:
|
||||||
age:
|
age:
|
||||||
- recipient: age1llp6k66265q3rzqemxpnq0x3562u20989vcjf65fl9s3hjhgcscq6mhnjw
|
- recipient: age1llp6k66265q3rzqemxpnq0x3562u20989vcjf65fl9s3hjhgcscq6mhnjw
|
||||||
@ -70,7 +80,7 @@ sops:
|
|||||||
aGNFeXZZRmlJM041OHdTM0pmM3BBdGMKGvFgYY1jhKwciAOZKyw0hlFVNbOk7CM7
|
aGNFeXZZRmlJM041OHdTM0pmM3BBdGMKGvFgYY1jhKwciAOZKyw0hlFVNbOk7CM7
|
||||||
041g17JXNV1Wk6WgMZ4w8p54RKQVaWCT4wxChy6wNNdQ3IeKgqEU2w==
|
041g17JXNV1Wk6WgMZ4w8p54RKQVaWCT4wxChy6wNNdQ3IeKgqEU2w==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
lastmodified: "2025-05-29T06:57:01Z"
|
lastmodified: "2025-05-30T12:50:05Z"
|
||||||
mac: ENC[AES256_GCM,data:0eWUqVsJrolrVYFsG4aigAYSjBW35Z+64y/ZE8Az15GmI0E4p/1kZPIY6hv9SUiCD1R2Ro0nm1QsV9A/hvNeWla0EdARNnARxIphxMJWKRGwcVkFVk28Jh4g4jE/rTggWx/wLbR6bza1RLHA1wRMb1PYuLfZybsb/whN4+gTMfg=,iv:irQkLpj1+3egSsiV9HqZP4tgG1fotCOizubL42gRjSQ=,tag:DC0xzG/VqcL4ib6ijxQZnA==,type:str]
|
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
|
unencrypted_suffix: _unencrypted
|
||||||
version: 3.10.2
|
version: 3.10.2
|
||||||
|
|||||||
@ -25,9 +25,12 @@ services:
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: ghcr.io/ocelot-social-community/ocelot-social/backend:local-development
|
image: ghcr.io/ocelot-social-community/ocelot-social/backend:local-development
|
||||||
|
ports:
|
||||||
|
- 9000:9000
|
||||||
depends_on:
|
depends_on:
|
||||||
- minio
|
- minio
|
||||||
- minio-mc
|
- minio-mc
|
||||||
|
- imagor
|
||||||
build:
|
build:
|
||||||
target: development
|
target: development
|
||||||
environment:
|
environment:
|
||||||
@ -36,10 +39,12 @@ services:
|
|||||||
- SMTP_HOST=mailserver
|
- SMTP_HOST=mailserver
|
||||||
- AWS_ACCESS_KEY_ID=minio
|
- AWS_ACCESS_KEY_ID=minio
|
||||||
- AWS_SECRET_ACCESS_KEY=12341234
|
- AWS_SECRET_ACCESS_KEY=12341234
|
||||||
- AWS_ENDPOINT=http:/minio:9000
|
- AWS_ENDPOINT=http:/localhost:9000
|
||||||
- AWS_REGION=local
|
- AWS_REGION=local
|
||||||
- AWS_BUCKET=ocelot
|
- AWS_BUCKET=ocelot
|
||||||
- S3_PUBLIC_GATEWAY=http:/localhost:9000
|
- IMAGOR_PUBLIC_URL=http://localhost:8000
|
||||||
|
- IMAGOR_SECRET=mysecret
|
||||||
|
- PROXY_S3=http://minio:9000
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
|
||||||
@ -58,7 +63,6 @@ services:
|
|||||||
minio:
|
minio:
|
||||||
image: quay.io/minio/minio
|
image: quay.io/minio/minio
|
||||||
ports:
|
ports:
|
||||||
- 9000:9000
|
|
||||||
- 9001:9001
|
- 9001:9001
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
@ -82,5 +86,22 @@ services:
|
|||||||
/usr/bin/mc anonymous set-json /tmp/readonly-policy.json dockerminio/ocelot;
|
/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:
|
volumes:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|||||||
@ -27,6 +27,9 @@ services:
|
|||||||
- AWS_REGION=local
|
- AWS_REGION=local
|
||||||
- AWS_BUCKET=ocelot
|
- AWS_BUCKET=ocelot
|
||||||
- DEBUG=
|
- DEBUG=
|
||||||
|
- IMAGOR_PUBLIC_URL=http://localhost:8000
|
||||||
|
- IMAGOR_SECRET=mysecret
|
||||||
|
- PROXY_S3=http://minio:9000
|
||||||
volumes:
|
volumes:
|
||||||
- ./coverage:/app/coverage
|
- ./coverage:/app/coverage
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
@click="handleBadgeClick(badge, index)"
|
@click="handleBadgeClick(badge, index)"
|
||||||
>
|
>
|
||||||
<div class="badge-icon">
|
<div class="badge-icon">
|
||||||
<img :src="badge.icon | proxyApiUrl" :alt="badge.id" />
|
<img :src="backendPath(badge.icon)" :alt="badge.id" />
|
||||||
</div>
|
</div>
|
||||||
<div class="badge-info">
|
<div class="badge-info">
|
||||||
<div class="badge-description">{{ badge.description }}</div>
|
<div class="badge-description">{{ badge.description }}</div>
|
||||||
@ -17,6 +17,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { backendPath } from '~/helpers/backendPath'
|
||||||
export default {
|
export default {
|
||||||
name: 'BadgeSelection',
|
name: 'BadgeSelection',
|
||||||
props: {
|
props: {
|
||||||
@ -31,6 +32,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
backendPath,
|
||||||
handleBadgeClick(badge, index) {
|
handleBadgeClick(badge, index) {
|
||||||
if (this.selectedIndex === index) {
|
if (this.selectedIndex === index) {
|
||||||
this.selectedIndex = null
|
this.selectedIndex = null
|
||||||
|
|||||||
@ -8,12 +8,13 @@
|
|||||||
:class="{ selectable: selectionMode && index > 0, selected: selectedIndex === index }"
|
:class="{ selectable: selectionMode && index > 0, selected: selectedIndex === index }"
|
||||||
@click="handleBadgeClick(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>
|
</component>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { backendPath } from '~/helpers/backendPath'
|
||||||
export default {
|
export default {
|
||||||
name: 'Badges',
|
name: 'Badges',
|
||||||
props: {
|
props: {
|
||||||
@ -32,6 +33,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
backendPath,
|
||||||
handleBadgeClick(index) {
|
handleBadgeClick(index) {
|
||||||
if (!this.selectionMode || index === 0) {
|
if (!this.selectionMode || index === 0) {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -344,7 +344,7 @@ export default {
|
|||||||
;[...this.messages, ...Message].forEach((m) => {
|
;[...this.messages, ...Message].forEach((m) => {
|
||||||
if (m.senderId !== this.currentUser.id) m.seen = true
|
if (m.senderId !== this.currentUser.id) m.seen = true
|
||||||
m.date = new Date(m.date).toDateString()
|
m.date = new Date(m.date).toDateString()
|
||||||
m.avatar = this.$filters.proxyApiUrl(m.avatar)
|
m.avatar = m.avatar?.w320
|
||||||
msgs[m.indexId] = m
|
msgs[m.indexId] = m
|
||||||
})
|
})
|
||||||
this.messages = msgs.filter(Boolean)
|
this.messages = msgs.filter(Boolean)
|
||||||
@ -466,7 +466,7 @@ export default {
|
|||||||
const fixedRoom = {
|
const fixedRoom = {
|
||||||
...room,
|
...room,
|
||||||
index: room.lastMessage ? room.lastMessage.date : room.createdAt,
|
index: room.lastMessage ? room.lastMessage.date : room.createdAt,
|
||||||
avatar: this.$filters.proxyApiUrl(room.avatar),
|
avatar: room.avatar?.w320,
|
||||||
lastMessage: room.lastMessage
|
lastMessage: room.lastMessage
|
||||||
? {
|
? {
|
||||||
...room.lastMessage,
|
...room.lastMessage,
|
||||||
@ -474,7 +474,7 @@ export default {
|
|||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
users: room.users.map((u) => {
|
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) {
|
if (!fixedRoom.avatar) {
|
||||||
@ -521,8 +521,7 @@ export default {
|
|||||||
from the same origin or from local blob storage. So we fetch it first
|
from the same origin or from local blob storage. So we fetch it first
|
||||||
and then create a download link from blob storage. */
|
and then create a download link from blob storage. */
|
||||||
|
|
||||||
const url = this.$filters.proxyApiUrl(file.url)
|
const download = await fetch(file.url, {
|
||||||
const download = await fetch(url, {
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': file.type,
|
'Content-Type': file.type,
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
<template #heroImage>
|
<template #heroImage>
|
||||||
<img
|
<img
|
||||||
v-if="formData.image"
|
v-if="formData.image"
|
||||||
:src="formData.image | proxyApiUrl"
|
:src="formData.image.url"
|
||||||
:class="['image', formData.imageBlurred && '--blur-image']"
|
:class="['image', formData.imageBlurred && '--blur-image']"
|
||||||
/>
|
/>
|
||||||
<image-uploader
|
<image-uploader
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
:highlight="isPinned"
|
:highlight="isPinned"
|
||||||
>
|
>
|
||||||
<template v-if="post.image" #heroImage>
|
<template v-if="post.image" #heroImage>
|
||||||
<img :src="post.image | proxyApiUrl" class="image" />
|
<responsive-image :image="post.image" sizes="640px" class="image" />
|
||||||
</template>
|
</template>
|
||||||
<client-only>
|
<client-only>
|
||||||
<div class="post-user-row">
|
<div class="post-user-row">
|
||||||
@ -139,6 +139,7 @@ import HcRibbon from '~/components/Ribbon'
|
|||||||
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
|
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
|
||||||
import DateTime from '~/components/DateTime'
|
import DateTime from '~/components/DateTime'
|
||||||
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
||||||
|
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
import PostMutations from '~/graphql/PostMutations'
|
import PostMutations from '~/graphql/PostMutations'
|
||||||
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
|
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
|
||||||
@ -156,6 +157,7 @@ export default {
|
|||||||
LocationTeaser,
|
LocationTeaser,
|
||||||
DateTime,
|
DateTime,
|
||||||
UserTeaser,
|
UserTeaser,
|
||||||
|
ResponsiveImage,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
post: {
|
post: {
|
||||||
|
|||||||
24
webapp/components/ResponsiveImage/ResponsiveImage.vue
Normal file
24
webapp/components/ResponsiveImage/ResponsiveImage.vue
Normal 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>
|
||||||
@ -21,7 +21,12 @@ const userTilda = {
|
|||||||
name: 'Tilda Swinton',
|
name: 'Tilda Swinton',
|
||||||
slug: 'tilda-swinton',
|
slug: 'tilda-swinton',
|
||||||
id: 'user1',
|
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: {
|
badgeVerification: {
|
||||||
id: 'bv1',
|
id: 'bv1',
|
||||||
icon: '/icons/verified',
|
icon: '/icons/verified',
|
||||||
|
|||||||
@ -173,7 +173,9 @@ exports[`UserTeaser given an user user is disabled current user is a moderator r
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -293,7 +295,9 @@ exports[`UserTeaser given an user with linkToProfile, on desktop renders 1`] = `
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -381,7 +385,9 @@ exports[`UserTeaser given an user with linkToProfile, on desktop when hovering t
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -469,7 +475,9 @@ exports[`UserTeaser given an user with linkToProfile, on touch screen renders 1`
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -558,7 +566,9 @@ exports[`UserTeaser given an user with linkToProfile, on touch screen when click
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -651,7 +661,9 @@ exports[`UserTeaser given an user without linkToProfile, on desktop renders 1`]
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -739,7 +751,9 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -827,7 +841,9 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -915,7 +931,9 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen renders
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1004,7 +1022,9 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen when cl
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1097,7 +1117,9 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen when cl
|
|||||||
<img
|
<img
|
||||||
alt="Tilda Swinton"
|
alt="Tilda Swinton"
|
||||||
class="image"
|
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"
|
title="Tilda Swinton"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
@click="toggleBadge(badge)"
|
@click="toggleBadge(badge)"
|
||||||
:class="{ badge, inactive: !badge.isActive }"
|
:class="{ badge, inactive: !badge.isActive }"
|
||||||
>
|
>
|
||||||
<img :src="badge.icon | proxyApiUrl" :alt="badge.description" />
|
<img :src="backendPath(badge.icon)" :alt="badge.description" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@ -18,6 +18,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { backendPath } from '~/helpers/backendPath'
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
title: {
|
title: {
|
||||||
@ -28,6 +29,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
backendPath,
|
||||||
toggleBadge(badge) {
|
toggleBadge(badge) {
|
||||||
this.$emit('toggleBadge', badge)
|
this.$emit('toggleBadge', badge)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,7 +18,7 @@ exports[`Admin/BadgesSection with badges renders 1`] = `
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="description1"
|
alt="description1"
|
||||||
src="icon1"
|
src="/api/icon1"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -26,7 +26,7 @@ exports[`Admin/BadgesSection with badges renders 1`] = `
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="description2"
|
alt="description2"
|
||||||
src="icon2"
|
src="/api/icon2"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -61,43 +61,35 @@ describe('ProfileAvatar', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('with a relative avatar url', () => {
|
describe('with an avatar', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
propsData = {
|
propsData = {
|
||||||
profile: {
|
profile: {
|
||||||
name: 'Not Anonymous',
|
name: 'Not Anonymous',
|
||||||
avatar: {
|
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()
|
wrapper = Wrapper()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds a prefix to load the image from the uploads service', () => {
|
it('puts the original url in `src` attribute', () => {
|
||||||
expect(wrapper.find('.image').attributes('src')).toBe('/api/avatar.jpg')
|
expect(wrapper.find('.image').attributes('src')).toBe('http://localhost:8000//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('keeps the avatar URL as is', () => {
|
it('puts various sizes of the image in `srcset` attribute', () => {
|
||||||
// e.g. our seeds have absolute image URLs
|
expect(wrapper.find('.image').attributes('srcset')).toBe(
|
||||||
expect(wrapper.find('.image').attributes('src')).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',
|
||||||
'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 -->
|
<!-- '--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>
|
<span class="initials">{{ profileInitials }}</span>
|
||||||
<base-icon v-if="isAnonymous" name="eye-slash" />
|
<base-icon v-if="isAnonymous" name="eye-slash" />
|
||||||
<img
|
<responsive-image
|
||||||
v-if="isAvatar"
|
v-if="isAvatar"
|
||||||
:src="profile.avatar | proxyApiUrl"
|
:image="profile.avatar"
|
||||||
class="image"
|
class="image"
|
||||||
:alt="profile.name"
|
:alt="profile.name"
|
||||||
:title="showProfileNameTitle ? profile.name : ''"
|
:title="showProfileNameTitle ? profile.name : ''"
|
||||||
@error="$event.target.style.display = 'none'"
|
@error="$event.target.style.display = 'none'"
|
||||||
|
sizes="320px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ProfileAvatar',
|
name: 'ProfileAvatar',
|
||||||
|
components: {
|
||||||
|
ResponsiveImage,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
size: {
|
size: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@ -7,6 +7,9 @@ export const userFragment = gql`
|
|||||||
name
|
name
|
||||||
avatar {
|
avatar {
|
||||||
url
|
url
|
||||||
|
w320: transform(width: 320)
|
||||||
|
w640: transform(width: 640)
|
||||||
|
w1024: transform(width: 1024)
|
||||||
}
|
}
|
||||||
disabled
|
disabled
|
||||||
deleted
|
deleted
|
||||||
@ -80,6 +83,9 @@ export const postFragment = gql`
|
|||||||
language
|
language
|
||||||
image {
|
image {
|
||||||
url
|
url
|
||||||
|
w320: transform(width: 320)
|
||||||
|
w640: transform(width: 640)
|
||||||
|
w1024: transform(width: 1024)
|
||||||
sensitive
|
sensitive
|
||||||
aspectRatio
|
aspectRatio
|
||||||
type
|
type
|
||||||
|
|||||||
1
webapp/helpers/backendPath.js
Normal file
1
webapp/helpers/backendPath.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const backendPath = (url) => (url.startsWith('/') ? '/api' + url : '/api/' + url)
|
||||||
@ -51,7 +51,7 @@ exports[`.vue renders 1`] = `
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="description-v-1"
|
alt="description-v-1"
|
||||||
src="icon1"
|
src="/api/icon1"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -59,7 +59,7 @@ exports[`.vue renders 1`] = `
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="description-v-2"
|
alt="description-v-2"
|
||||||
src="icon2"
|
src="/api/icon2"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -80,7 +80,7 @@ exports[`.vue renders 1`] = `
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="description-t-1"
|
alt="description-t-1"
|
||||||
src="icon3"
|
src="/api/icon3"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -88,7 +88,7 @@ exports[`.vue renders 1`] = `
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="description-t-2"
|
alt="description-t-2"
|
||||||
src="icon4"
|
src="/api/icon4"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,9 +21,13 @@
|
|||||||
:style="heroImageStyle"
|
:style="heroImageStyle"
|
||||||
>
|
>
|
||||||
<template #heroImage v-if="post.image">
|
<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">
|
<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
|
<base-button
|
||||||
:icon="blurred ? 'eye' : 'eye-slash'"
|
:icon="blurred ? 'eye' : 'eye-slash'"
|
||||||
filled
|
filled
|
||||||
@ -167,6 +171,7 @@ import {
|
|||||||
deletePostMutation,
|
deletePostMutation,
|
||||||
sortTagsAlphabetically,
|
sortTagsAlphabetically,
|
||||||
} from '~/components/utils/PostHelpers'
|
} from '~/components/utils/PostHelpers'
|
||||||
|
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
|
||||||
import PostQuery from '~/graphql/PostQuery'
|
import PostQuery from '~/graphql/PostQuery'
|
||||||
import { groupQuery } from '~/graphql/groups'
|
import { groupQuery } from '~/graphql/groups'
|
||||||
import PostMutations from '~/graphql/PostMutations'
|
import PostMutations from '~/graphql/PostMutations'
|
||||||
@ -193,6 +198,7 @@ export default {
|
|||||||
ObserveButton,
|
ObserveButton,
|
||||||
LocationTeaser,
|
LocationTeaser,
|
||||||
PageParamsLink,
|
PageParamsLink,
|
||||||
|
ResponsiveImage,
|
||||||
UserTeaser,
|
UserTeaser,
|
||||||
},
|
},
|
||||||
mixins: [GetCategories, postListActions, SortCategories],
|
mixins: [GetCategories, postListActions, SortCategories],
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import ContributionForm from '~/components/ContributionForm/ContributionForm'
|
import ContributionForm from '~/components/ContributionForm/ContributionForm.vue'
|
||||||
import PostQuery from '~/graphql/PostQuery'
|
import PostQuery from '~/graphql/PostQuery'
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
|
|||||||
@ -95,12 +95,6 @@ export default ({ app = {} }) => {
|
|||||||
|
|
||||||
return contentExcerpt
|
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
|
// add all methods as filters on each vue component
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user