fix: fix tainted canvas error with OCR (#1902)

* fix: fix tainted canvas error with OCR

fixes #1901

* fix: minor tweaks
This commit is contained in:
Nolan Lawson 2020-11-24 15:37:10 -08:00 committed by GitHub
parent d3ce112f60
commit 69aad56421
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 176 additions and 26 deletions

View file

@ -6,6 +6,8 @@ import { database } from '../_database/database'
import { emit } from '../_utils/eventBus' import { emit } from '../_utils/eventBus'
import { putMediaMetadata } from '../_api/media' import { putMediaMetadata } from '../_api/media'
import uniqBy from 'lodash-es/uniqBy' import uniqBy from 'lodash-es/uniqBy'
import { deleteCachedMediaFile } from '../_utils/mediaUploadFileCache'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
export async function insertHandleForReply (statusId) { export async function insertHandleForReply (statusId) {
const { currentInstance } = store.get() const { currentInstance } = store.get()
@ -58,6 +60,7 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
addStatusOrNotification(currentInstance, 'home', status) addStatusOrNotification(currentInstance, 'home', status)
store.clearComposeData(realm) store.clearComposeData(realm)
emit('postedStatus', realm, inReplyToUuid) emit('postedStatus', realm, inReplyToUuid)
scheduleIdleTask(() => (mediaIds || []).forEach(mediaId => deleteCachedMediaFile(mediaId))) // clean up media cache
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Unable to post status: ' + (e.message || '')) toast.say('Unable to post status: ' + (e.message || ''))

View file

@ -2,7 +2,7 @@ import { store } from '../_store/store'
import { uploadMedia } from '../_api/media' import { uploadMedia } from '../_api/media'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import { mediaUploadFileCache } from '../_utils/mediaUploadFileCache' import { setCachedMediaFile } from '../_utils/mediaUploadFileCache'
export async function doMediaUpload (realm, file) { export async function doMediaUpload (realm, file) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -13,7 +13,7 @@ export async function doMediaUpload (realm, file) {
if (composeMedia.length === 4) { if (composeMedia.length === 4) {
throw new Error('Only 4 media max are allowed') throw new Error('Only 4 media max are allowed')
} }
mediaUploadFileCache.set(response.url, file) await setCachedMediaFile(response.id, file)
composeMedia.push({ composeMedia.push({
data: response, data: response,
file: { name: file.name }, file: { name: file.name },

View file

@ -106,7 +106,7 @@
import { runTesseract } from '../../../_utils/runTesseract' import { runTesseract } from '../../../_utils/runTesseract'
import SvgIcon from '../../SvgIcon.html' import SvgIcon from '../../SvgIcon.html'
import { toast } from '../../toast/toast' import { toast } from '../../toast/toast'
import { mediaUploadFileCache } from '../../../_utils/mediaUploadFileCache' import { getCachedMediaFile } from '../../../_utils/mediaUploadFileCache'
const updateRawTextInStore = throttleTimer(requestPostAnimationFrame) const updateRawTextInStore = throttleTimer(requestPostAnimationFrame)
@ -131,6 +131,7 @@
length: ({ rawText }) => length(rawText || ''), length: ({ rawText }) => length(rawText || ''),
overLimit: ({ mediaAltCharLimit, length }) => length > mediaAltCharLimit, overLimit: ({ mediaAltCharLimit, length }) => length > mediaAltCharLimit,
url: ({ media, index }) => get(media, [index, 'data', 'url']), url: ({ media, index }) => get(media, [index, 'data', 'url']),
mediaId: ({ media, index }) => get(media, [index, 'data', 'id']),
extractButtonText: ({ extracting }) => extracting ? 'Extracting text…' : 'Extract text from image', extractButtonText: ({ extracting }) => extracting ? 'Extracting text…' : 'Extract text from image',
extractButtonLabel: ({ extractButtonText, extractionProgress, extracting }) => { extractButtonLabel: ({ extractButtonText, extractionProgress, extracting }) => {
if (extracting) { if (extracting) {
@ -183,13 +184,13 @@
async onClick () { async onClick () {
this.set({ extracting: true }) this.set({ extracting: true })
try { try {
const { url } = this.get() const { url, mediaId } = this.get()
const onProgress = progress => { const onProgress = progress => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.set({ extractionProgress: progress * 100 }) this.set({ extractionProgress: progress * 100 })
}) })
} }
const file = mediaUploadFileCache.get(url) const file = await getCachedMediaFile(mediaId)
let text let text
if (file) { // Avoid downloading from the network a file that the user *just* uploaded if (file) { // Avoid downloading from the network a file that the user *just* uploaded
const fileUrl = URL.createObjectURL(file) const fileUrl = URL.createObjectURL(file)

View file

@ -1,5 +1,4 @@
import { set, keys, del, close } from '../_thirdparty/idb-keyval/idb-keyval' import { set, keys, del } from '../_thirdparty/idb-keyval/idb-keyval'
import { lifecycle } from '../_utils/lifecycle'
const PREFIX = 'known-instance-' const PREFIX = 'known-instance-'
@ -16,12 +15,3 @@ export async function addKnownInstance (instanceName) {
export async function deleteKnownInstance (instanceName) { export async function deleteKnownInstance (instanceName) {
return del(PREFIX + instanceName) return del(PREFIX + instanceName)
} }
if (process.browser) {
lifecycle.addEventListener('statechange', async event => {
if (event.newState === 'frozen') { // page is frozen, close IDB connections
await close()
console.log('closed knownInstances DB')
}
})
}

View file

@ -1,5 +1,8 @@
// Forked from https://github.com/jakearchibald/idb-keyval/commit/ea7d507 // Forked from https://github.com/jakearchibald/idb-keyval/commit/ea7d507
// Adds a function for closing the database, ala https://github.com/jakearchibald/idb-keyval/pull/65 // Adds a function for closing the database, ala https://github.com/jakearchibald/idb-keyval/pull/65
// Also hooks it into the lifecycle frozen event
import { lifecycle } from '../../_utils/lifecycle'
class Store { class Store {
constructor (dbName = 'keyval-store', storeName = 'keyval') { constructor (dbName = 'keyval-store', storeName = 'keyval') {
this.storeName = storeName this.storeName = storeName
@ -51,32 +54,37 @@ function getDefaultStore () {
return store return store
} }
function get (key, store = getDefaultStore()) { function get (key) {
const store = getDefaultStore()
let req let req
return store._withIDBStore('readonly', store => { return store._withIDBStore('readonly', store => {
req = store.get(key) req = store.get(key)
}).then(() => req.result) }).then(() => req.result)
} }
function set (key, value, store = getDefaultStore()) { function set (key, value) {
const store = getDefaultStore()
return store._withIDBStore('readwrite', store => { return store._withIDBStore('readwrite', store => {
store.put(value, key) store.put(value, key)
}) })
} }
function del (key, store = getDefaultStore()) { function del (key) {
const store = getDefaultStore()
return store._withIDBStore('readwrite', store => { return store._withIDBStore('readwrite', store => {
store.delete(key) store.delete(key)
}) })
} }
function clear (store = getDefaultStore()) { function clear () {
const store = getDefaultStore()
return store._withIDBStore('readwrite', store => { return store._withIDBStore('readwrite', store => {
store.clear() store.clear()
}) })
} }
function keys (store = getDefaultStore()) { function keys () {
const store = getDefaultStore()
const keys = [] const keys = []
return store._withIDBStore('readonly', store => { return store._withIDBStore('readonly', store => {
// This would be store.getAllKeys(), but it isn't supported by Edge or Safari. // This would be store.getAllKeys(), but it isn't supported by Edge or Safari.
@ -91,8 +99,18 @@ function keys (store = getDefaultStore()) {
}).then(() => keys) }).then(() => keys)
} }
function close (store = getDefaultStore()) { function close () {
const store = getDefaultStore()
return store._close() return store._close()
} }
if (process.browser) {
lifecycle.addEventListener('statechange', async event => {
if (event.newState === 'frozen') { // page is frozen, close IDB connections
await close()
console.log('closed keyval DB')
}
})
}
export { Store, get, set, del, clear, keys, close } export { Store, get, set, del, clear, keys, close }

View file

@ -1,6 +1,84 @@
// keep a cache of files for the most recent uploads to avoid // Keep an LRU cache of recently-uploaded files for OCR.
// re-downloading them for OCR // We keep them in IDB to avoid tainted canvas errors after a refresh.
// https://github.com/nolanlawson/pinafore/issues/1901
import { QuickLRU } from '../_thirdparty/quick-lru/quick-lru' import { get, set, keys, del } from '../_thirdparty/idb-keyval/idb-keyval'
export const mediaUploadFileCache = new QuickLRU({ maxSize: 4 }) const PREFIX = 'media-cache-'
const DELIMITER = '-cache-'
const LIMIT = 4 // you can upload 4 images per post, this seems reasonable despite cross-instance usage
export const DELETE_AFTER = 604800000 // 7 days
let deleteAfter = DELETE_AFTER
function keyToData (key) {
key = key.substring(PREFIX.length)
const index = key.indexOf(DELIMITER)
// avoiding str.split() to not have to worry about ids containing the delimiter string somehow
return [key.substring(0, index), key.substring(index + DELIMITER.length)]
}
function dataToKey (timestamp, id) {
return `${PREFIX}${timestamp}${DELIMITER}${id}`
}
async function getAllKeys () {
return (await keys()).filter(key => key.startsWith(PREFIX)).sort()
}
export async function getCachedMediaFile (id) {
const allKeys = await getAllKeys()
for (const key of allKeys) {
const otherId = keyToData(key)[1]
if (id === otherId) {
return get(key)
}
}
}
export async function setCachedMediaFile (id, file) {
const allKeys = await getAllKeys()
if (allKeys.map(keyToData).map(_ => _[1]).includes(id)) {
return // do nothing, it's already in there
}
while (allKeys.length >= LIMIT) {
// already sorted in chronological order, so delete the oldest
await del(allKeys.shift())
}
// delete anything that's too old, while we're at it
for (const key of allKeys) {
const timestamp = keyToData(key)[0]
if (Date.now() - Date.parse(timestamp) >= deleteAfter) {
await del(key)
}
}
const key = dataToKey(new Date().toISOString(), id)
await set(key, file)
}
export async function deleteCachedMediaFile (id) {
const allKeys = await getAllKeys()
for (const key of allKeys) {
const otherId = keyToData(key)[1]
if (otherId === id) {
await del(key)
}
}
}
// The following are only used in tests
export async function getAllCachedFileIds () {
return (await getAllKeys()).map(keyToData).map(_ => _[1])
}
export function setDeleteAfter (newDeleteAfter) {
deleteAfter = newDeleteAfter
}

View file

@ -0,0 +1,60 @@
/* global it describe beforeEach */
import '../indexedDBShims'
import assert from 'assert'
import {
getCachedMediaFile, setCachedMediaFile, deleteCachedMediaFile, getAllCachedFileIds, setDeleteAfter, DELETE_AFTER
} from '../../src/routes/_utils/mediaUploadFileCache'
describe('test-database.js', function () {
this.timeout(60000)
beforeEach(async () => {
for (const key of await getAllCachedFileIds()) {
await deleteCachedMediaFile(key)
}
setDeleteAfter(DELETE_AFTER)
})
it('can store media files', async () => {
await setCachedMediaFile('woot', 'woot')
const result = await getCachedMediaFile('woot')
assert.deepStrictEqual(result, 'woot')
await deleteCachedMediaFile('woot')
const result2 = await getCachedMediaFile('woot')
assert.deepStrictEqual(result2, undefined)
})
it('does nothing if you set() the same id twice', async () => {
await setCachedMediaFile('woot', 'woot')
await setCachedMediaFile('woot', 'woot2')
const result = await getCachedMediaFile('woot')
assert.deepStrictEqual(result, 'woot')
})
it('returns undefined if not found', async () => {
const result = await getCachedMediaFile('woot')
assert.deepStrictEqual(result, undefined)
})
it('does nothing when deleting an unfound key', async () => {
await deleteCachedMediaFile('doesnt-exist')
})
it('only stores up to 4 files', async () => {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 4)) // delay to avoid timing collisions
await setCachedMediaFile(i.toString(), i)
}
const ids = await getAllCachedFileIds()
assert.deepStrictEqual(ids, [6, 7, 8, 9].map(_ => _.toString()))
})
it('deletes old files during set()', async () => {
setDeleteAfter(0)
await setCachedMediaFile('woot', 'woot')
await setCachedMediaFile('woot2', 'woot2')
assert.deepStrictEqual(await getCachedMediaFile('woot'), undefined)
assert.deepStrictEqual(await getCachedMediaFile('woot2'), 'woot2')
})
})