Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/ckeditor/image/BlobImagePlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { FileLoader, UploadAdapter, UploadResponse, ViewDocumentFragment, ViewElement } from 'ckeditor5'

import { FileRepository, Plugin, UpcastWriter } from 'ckeditor5'
import logger from '../../logger.js'
import { createBlobUrl } from '../../util/blobImages.js'

const BASE64_DATA_URI = /^data:([^;,]*);base64,(.*)$/s

class BlobUploadAdapter implements UploadAdapter {
private loader: FileLoader

constructor(loader: FileLoader) {
this.loader = loader
}

async upload(): Promise<UploadResponse> {
const file = await this.loader.file
return { default: createBlobUrl(file!) }
}

abort(): void {}
}

/**
* Holds images as blob: object URLs while editing. Run the editor's data
* through embedBlobImages before persisting it.
*/
export default class BlobImagePlugin extends Plugin {
static get requires() {
return [FileRepository] as const
}

static get pluginName() {
return 'BlobImage' as const
}

init(): void {
this.editor.plugins.get('FileRepository').createUploadAdapter = (loader) => new BlobUploadAdapter(loader)

this.editor.data.on('toModel', (event, [view]) => {
const writer = new UpcastWriter((view as ViewElement | ViewDocumentFragment).document)

for (const { item } of writer.createRangeIn(view as ViewElement | ViewDocumentFragment)) {
if (!item.is('element', 'img')) {
continue
}

const blobUrl = dataUriToBlobUrl(item.getAttribute('src') ?? '')
if (blobUrl !== null) {
writer.setAttribute('src', blobUrl, item)
}
}
}, { priority: 'high' })
}
}

/**
* @param src image source to convert
* @return an object URL for a base64 data URI, null for anything else
*/
function dataUriToBlobUrl(src: string): string | null {
const match = BASE64_DATA_URI.exec(src)
if (match === null) {
return null
}

try {
const bytes = Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0))
return createBlobUrl(new Blob([bytes], { type: match[1] }))
} catch (error) {
logger.warn('Could not convert inline image to a blob, keeping base64', { error })
return null
}
}
13 changes: 2 additions & 11 deletions src/ckeditor/image/FilesImagePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { t } from '@nextcloud/l10n'
import { ButtonView, IconImageAssetManager, ImageInsertUI, MenuBarMenuListItemButtonView, Plugin } from 'ckeditor5'
import { getClient } from '../../dav/client.js'
import logger from '../../logger.js'
import { createBlobUrl } from '../../util/blobImages.js'

const MIME_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/bmp', 'image/webp']

Expand Down Expand Up @@ -84,22 +85,12 @@ export default class FilesImagePlugin extends Plugin {
try {
const response = await getClient('files').getFileContents(node.path, { details: true })
const blob = new Blob([response.data as BlobPart], { type: response.headers['content-type'] })
const dataUri = await this._readBlobAsDataUri(blob)

this.editor.execute('insertImage', { source: dataUri })
this.editor.execute('insertImage', { source: createBlobUrl(blob) })
this.editor.editing.view.focus()
} catch (error) {
logger.error('Could not insert image from Files', { error })
showError(t('mail', 'Could not insert the selected image'))
}
}

_readBlobAsDataUri(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(blob)
})
}
}
8 changes: 6 additions & 2 deletions src/components/AppSettingsMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ import TextEditor from './TextEditor.vue'
import TrustedSenders from './TrustedSenders.vue'
import Logger from '../logger.js'
import useMainStore from '../store/mainStore.js'
import { embedBlobImages } from '../util/blobImages.js'

export default {
name: 'AppSettingsMenu',
Expand Down Expand Up @@ -823,8 +824,11 @@ export default {
document.body.append(iframe)
},

newTextBlock() {
this.mainStore.createTextBlock({ ...this.localTextBlock })
async newTextBlock() {
this.mainStore.createTextBlock({
...this.localTextBlock,
content: await embedBlobImages(this.localTextBlock.content),
})
this.textBlockDialogOpen = false
this.localTextBlock = {
title: '',
Expand Down
8 changes: 5 additions & 3 deletions src/components/NewMessageModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ import { deleteDraft, saveDraft, updateDraft } from '../service/DraftService.js'
import { UNDO_DELAY } from '../store/constants.js'
import useMainStore from '../store/mainStore.js'
import useOutboxStore from '../store/outboxStore.js'
import { embedBlobImages } from '../util/blobImages.js'
import { messageBodyToTextInstance } from '../util/message.js'
import { toPlain } from '../util/text.js'

Expand Down Expand Up @@ -353,7 +354,7 @@ export default {
this.draftSaved = false
try {
let idToReturn
const dataForServer = this.getDataForServer(data, true)
const dataForServer = await this.getDataForServer(data, true)
if (!id) {
if (dataForServer.draftId) {
this.mainStore.removeEnvelopeMutation({ id: dataForServer.draftId })
Expand Down Expand Up @@ -406,7 +407,7 @@ export default {
return this.draftsPromise
},

getDataForServer(data) {
async getDataForServer(data) {
const dataForServer = {
...data,
id: data.id,
Expand All @@ -423,6 +424,7 @@ export default {

if (data.isHtml) {
delete dataForServer.bodyPlain
dataForServer.bodyHtml = await embedBlobImages(data.bodyHtml)
} else {
delete dataForServer.bodyHtml
}
Expand Down Expand Up @@ -456,7 +458,7 @@ export default {
attachment.type = 'local'
}
}
const dataForServer = this.getDataForServer({
const dataForServer = await this.getDataForServer({
...data,
id: await this.draftsPromise,
sendAt: data.sendAt ? data.sendAt : Math.floor((now + UNDO_DELAY) / 1000),
Expand Down
5 changes: 3 additions & 2 deletions src/components/SignatureSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import TextEditor from './TextEditor.vue'
import logger from '../logger.js'
import { EDITOR_MODE_HTML } from '../store/constants.js'
import useMainStore from '../store/mainStore.js'
import { embedBlobImages, embeddedSize } from '../util/blobImages.js'
import { containsImage, detect, toHtml } from '../util/text.js'

export default {
Expand Down Expand Up @@ -149,7 +150,7 @@ export default {
},

isLargeSignature() {
return (new Blob([this.signature])).size > 2 * 1024 * 1024
return embeddedSize(this.signature) > 2 * 1024 * 1024
},
},

Expand Down Expand Up @@ -194,7 +195,7 @@ export default {

const payload = {
account: this.account,
signature: this.signature,
signature: await embedBlobImages(this.signature),
}

if (this.identity.id > -1) {
Expand Down
4 changes: 2 additions & 2 deletions src/components/TextEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import { getLanguage } from '@nextcloud/l10n'
import { emojiAddRecent, emojiSearch } from '@nextcloud/vue/functions/emoji'
import {
Alignment,
Base64UploadAdapter,
BlockQuote,
Bold,
ClassicEditor,
Expand All @@ -54,6 +53,7 @@ import {
} from 'ckeditor5'
import { getLinkWithPicker, searchProvider } from '@nextcloud/vue/components/NcRichText'
import TextDirectionPlugin from '../ckeditor/direction/TextDirectionPlugin.js'
import BlobImagePlugin from '../ckeditor/image/BlobImagePlugin.ts'
import FilesImagePlugin from '../ckeditor/image/FilesImagePlugin.ts'
import ImageDowncastPlugin from '../ckeditor/image/ImageDowncastPlugin.ts'
import MailPlugin from '../ckeditor/mail/MailPlugin.js'
Expand Down Expand Up @@ -156,7 +156,7 @@ export default {
ImageDowncastPlugin,
Font,
RemoveFormat,
Base64UploadAdapter,
BlobImagePlugin,
MailPlugin,
SourceEditing,
TextDirectionPlugin,
Expand Down
6 changes: 5 additions & 1 deletion src/components/textBlocks/ListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ import TextEditor from '../TextEditor.vue'
import logger from '../../logger.js'
import { getShares, shareTextBlock, unshareTextBlock } from '../../service/TextBlockService.js'
import useMainStore from '../../store/mainStore.js'
import { embedBlobImages } from '../../util/blobImages.js'

export default {
name: 'ListItem',
Expand Down Expand Up @@ -377,7 +378,10 @@ export default {
async saveTextBlock() {
this.saveLoading = true
try {
await this.mainStore.patchTextBlock(this.localTextBlock)
await this.mainStore.patchTextBlock({
...this.localTextBlock,
content: await embedBlobImages(this.localTextBlock.content),
})
this.saveLoading = false
this.editModalOpen = false
} catch (error) {
Expand Down
85 changes: 85 additions & 0 deletions src/tests/unit/ckeditor/image/BlobImagePlugin.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { ClassicEditor, ImageBlock, ImageInline, Paragraph } from 'ckeditor5'
import BlobImagePlugin from '../../../../ckeditor/image/BlobImagePlugin.ts'

window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}

/**
* @param {string} initialData content to load into the editor
* @return {Promise<ClassicEditor>}
*/
async function createEditor(initialData = '') {
const element = document.createElement('div')
document.body.appendChild(element)

return ClassicEditor.create(element, {
licenseKey: 'GPL',
initialData,
plugins: [Paragraph, ImageBlock, ImageInline, BlobImagePlugin],
})
}

describe('BlobImagePlugin', () => {
let editor

beforeEach(() => {
URL.createObjectURL = vi.fn(() => 'blob:http://localhost/image')
})

afterEach(async () => {
await editor?.destroy()
delete URL.createObjectURL
})

it('turns base64 images into blob URLs', async () => {
editor = await createEditor('<p><img src="data:image/png;base64,aW1hZ2U="></p>')

expect(editor.data.get()).toContain('src="blob:http://localhost/image"')
const blob = URL.createObjectURL.mock.calls[0][0]
expect(blob.type).toBe('image/png')
expect(await blob.text()).toBe('image')
})

it('turns base64 images inserted as a view fragment into blob URLs', async () => {
editor = await createEditor()

const viewFragment = editor.data.processor.toView('<p><img src="data:image/png;base64,aW1hZ2U="></p>')
editor.model.change((writer) => {
writer.append(editor.data.toModel(viewFragment), editor.model.document.getRoot())
})

expect(editor.data.get()).toContain('src="blob:http://localhost/image"')
})

it('keeps other image sources', async () => {
editor = await createEditor('<p><img src="https://example.com/image.png"></p>')

expect(editor.data.get()).toContain('src="https://example.com/image.png"')
expect(URL.createObjectURL).not.toHaveBeenCalled()
})

it('keeps invalid base64 as it is', async () => {
editor = await createEditor('<p><img src="data:image/png;base64,***"></p>')

expect(editor.data.get()).toContain('src="data:image/png;base64,***"')
expect(URL.createObjectURL).not.toHaveBeenCalled()
})

it('uploads files as blob URLs', async () => {
editor = await createEditor()
const file = new File(['image'], 'image.png', { type: 'image/png' })

const adapter = editor.plugins.get('FileRepository').createUploadAdapter({ file: Promise.resolve(file) })

await expect(adapter.upload()).resolves.toEqual({ default: 'blob:http://localhost/image' })
expect(URL.createObjectURL).toHaveBeenCalledWith(file)
})
})
51 changes: 51 additions & 0 deletions src/tests/unit/util/blobImages.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createBlobUrl, embedBlobImages, embeddedSize } from '../../../util/blobImages.js'

describe('blobImages', () => {
let counter = 0

beforeEach(() => {
URL.createObjectURL = vi.fn(() => `blob:http://localhost/${++counter}`)
})

afterEach(() => {
delete URL.createObjectURL
})

it('leaves html without blob images untouched', async () => {
const html = '<p>hello</p>'

await expect(embedBlobImages(html)).resolves.toBe(html)
})

it('embeds blob images as base64', async () => {
const url = createBlobUrl(new Blob(['image'], { type: 'image/png' }))

const result = await embedBlobImages(`<p><img src="${url}" alt="logo"></p>`)

expect(result).toBe('<p><img src="data:image/png;base64,aW1hZ2U=" alt="logo"></p>')
})

it('keeps unknown blob URLs', async () => {
const html = '<p><img src="blob:http://localhost/unknown"></p>'

await expect(embedBlobImages(html)).resolves.toBe(html)
})

it('measures the size after embedding', async () => {
const url = createBlobUrl(new Blob(['image'], { type: 'image/png' }))
const html = `<p><img src="${url}"></p><p><img src="${url}"></p>`

expect(embeddedSize(html)).toBe(new Blob([await embedBlobImages(html)]).size)
})

it('measures html without blob images as it is', () => {
const html = '<p>hello</p>'

expect(embeddedSize(html)).toBe(html.length)
})
})
Loading
Loading