Skip to content

Commit b10cacb

Browse files
committed
feat: clone external pictures when added to a page
Signed-off-by: Matt Krick <matt.krick@gmail.com>
1 parent 2d83bfb commit b10cacb

File tree

7 files changed

+127
-6
lines changed

7 files changed

+127
-6
lines changed

packages/client/styles/theme/global.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,15 @@
191191
}
192192
}
193193

194+
--animate-shimmer: shimmer 2.5s infinite;
195+
@keyframes shimmer {
196+
from {
197+
mask-position: right;
198+
}
199+
to {
200+
mask-position: left;
201+
}
202+
}
194203
}
195204

196205
:root {

packages/client/tiptap/extensions/imageBlock/ImageBlockView.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,38 @@
11
import {type NodeViewProps, NodeViewWrapper} from '@tiptap/react'
2-
import {useCallback, useRef, useState} from 'react'
2+
import {useCallback, useEffect, useRef, useState} from 'react'
3+
import type {AssetScopeEnum} from '~/__generated__/useEmbedUserAssetMutation.graphql'
4+
import useAtmosphere from '~/hooks/useAtmosphere'
5+
import {useEmbedUserAsset} from '~/mutations/useEmbedUserAsset'
6+
import {GQLID} from '~/utils/GQLID'
37
import {useBlockResizer} from '../../../hooks/useBlockResizer'
48
import {cn} from '../../../ui/cn'
59
import {BlockResizer} from './BlockResizer'
610
import {ImageBlockBubbleMenu} from './ImageBlockBubbleMenu'
11+
12+
const getRelativeSrc = (src: string) => {
13+
if (src.startsWith('/')) return src
14+
try {
15+
const url = new URL(src)
16+
return url.pathname
17+
} catch {
18+
return ''
19+
}
20+
}
21+
const getIsHosted = (src: string, scopeKey: string, assetScope: AssetScopeEnum) => {
22+
const relativeSrc = getRelativeSrc(src)
23+
const scopeCode = assetScope === 'Page' ? GQLID.fromKey(scopeKey)[0] : scopeKey
24+
const hostedPath = `/assets/${assetScope}/${scopeCode}`
25+
return relativeSrc.startsWith(hostedPath)
26+
}
727
export const ImageBlockView = (props: NodeViewProps) => {
828
const {editor, getPos, node, updateAttributes} = props
929
const imageWrapperRef = useRef<HTMLDivElement>(null)
1030
const {attrs} = node
1131
const {src, align, height, width} = attrs
1232
const alignClass =
1333
align === 'left' ? 'justify-start' : align === 'right' ? 'justify-end' : 'justify-center'
14-
34+
const {scopeKey, assetScope} = editor.extensionStorage.imageUpload
35+
const isHosted = getIsHosted(src, scopeKey, assetScope)
1536
const onClick = useCallback(() => {
1637
const pos = getPos()
1738
if (!pos) return
@@ -32,13 +53,41 @@ export const ImageBlockView = (props: NodeViewProps) => {
3253
)
3354
const onMouseDownLeft = onMouseDown('left')
3455
const onMouseDownRight = onMouseDown('right')
56+
const atmosphere = useAtmosphere()
57+
const [commit] = useEmbedUserAsset()
58+
useEffect(() => {
59+
if (isHosted) return
60+
commit({
61+
variables: {url: src, scope: assetScope, scopeKey},
62+
onCompleted: (res, error) => {
63+
const {embedUserAsset} = res
64+
if (!embedUserAsset) {
65+
// Since this is triggered without user input, we log it silently
66+
console.error(error?.[0]?.message)
67+
return
68+
}
69+
const {url} = embedUserAsset
70+
const message = embedUserAsset?.error?.message
71+
if (message) {
72+
atmosphere.eventEmitter.emit('addSnackbar', {
73+
key: 'errorEmbeddingAsset',
74+
message,
75+
autoDismiss: 5
76+
})
77+
return
78+
}
79+
updateAttributes({src: url})
80+
}
81+
})
82+
}, [isHosted])
3583
return (
3684
<NodeViewWrapper>
3785
<div className={cn('flex', alignClass)}>
3886
<div contentEditable={false} ref={imageWrapperRef} className='group relative w-fit'>
3987
<img
4088
draggable={false}
41-
className='block'
89+
data-uploading={isHosted ? undefined : ''}
90+
className='block data-uploading:animate-shimmer data-uploading:[mask:linear-gradient(-60deg,#000_30%,#0005,#000_70%)_right/350%_100%]'
4291
src={src}
4392
alt=''
4493
onClick={onClick}

packages/server/fileStorage/FileStoreManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default abstract class FileStoreManager {
2626
partialPath: string
2727
): Promise<string>
2828

29+
abstract copyFile(oldKey: PartialPath, newKey: PartialPath): Promise<string>
2930
abstract moveFile(oldKey: PartialPath, newKey: PartialPath): Promise<void>
3031
abstract presignUrl(partialPath: PartialPath): Promise<string>
3132
async putUserFile(file: ArrayBufferLike | Buffer<ArrayBufferLike>, partialPath: PartialPath) {

packages/server/fileStorage/GCSManager.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {fetch} from '@whatwg-node/fetch'
22
import {sign} from 'jsonwebtoken'
33
import mime from 'mime-types'
4+
import makeAppURL from 'parabol-client/utils/makeAppURL'
45
import path from 'path'
6+
import appOrigin from '../appOrigin'
57
import {Logger} from '../utils/Logger'
68
import FileStoreManager, {type FileAssetDir, type PartialPath} from './FileStoreManager'
79

@@ -150,6 +152,25 @@ export default class GCSManager extends FileStoreManager {
150152
return this.getPublicFileLocation(fullPath)
151153
}
152154

155+
async copyFile(oldPartialPath: PartialPath, newPartialPath: PartialPath) {
156+
const accessToken = await this.getAccessToken()
157+
const oldFullPath = encodeURIComponent(this.prependPath(oldPartialPath))
158+
const newFullPath = encodeURIComponent(this.prependPath(newPartialPath))
159+
const copyURL = `https://storage.googleapis.com/storage/v1/b/${this.bucket}/o/${oldFullPath}/rewriteTo/b/${this.bucket}/o/${newFullPath}`
160+
const moveRes = await fetch(copyURL, {
161+
method: 'POST',
162+
headers: {
163+
Authorization: `Bearer ${accessToken}`,
164+
Accept: 'application/json',
165+
'User-Agent': 'parabol'
166+
}
167+
})
168+
if (!moveRes.ok) {
169+
const text = await moveRes.text()
170+
throw new Error(`GCS Copy Error: ${moveRes.status} ${text}. ${copyURL}`)
171+
}
172+
return makeAppURL(appOrigin, `/assets/${newPartialPath}`)
173+
}
153174
async moveFile(oldPartialPath: PartialPath, newPartialPath: PartialPath): Promise<void> {
154175
const accessToken = await this.getAccessToken()
155176
const oldFullPath = encodeURIComponent(this.prependPath(oldPartialPath))
@@ -165,7 +186,7 @@ export default class GCSManager extends FileStoreManager {
165186
})
166187
if (!moveRes.ok) {
167188
const text = await moveRes.text()
168-
throw new Error(`GCS Copy Error: ${moveRes.status} ${text}. ${moveURL}`)
189+
throw new Error(`GCS Move Error: ${moveRes.status} ${text}. ${moveURL}`)
169190
}
170191
}
171192
prependPath(partialPath: string, assetDir: FileAssetDir = 'store') {

packages/server/fileStorage/LocalFileStoreManager.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export default class LocalFileStoreManager extends FileStoreManager {
2929
return makeAppURL(appOrigin, fullPath)
3030
}
3131

32+
async copyFile(oldKey: PartialPath, newKey: PartialPath) {
33+
const oldFullPath = this.prependPath(oldKey)
34+
const newFullPath = this.prependPath(newKey)
35+
await fs.promises.copyFile(oldFullPath, newFullPath)
36+
return makeAppURL(appOrigin, newFullPath)
37+
}
38+
3239
async moveFile(oldKey: PartialPath, newKey: PartialPath): Promise<void> {
3340
const oldFullPath = this.prependPath(oldKey)
3441
const newFullPath = this.prependPath(newKey)

packages/server/fileStorage/S3FileStoreManager.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {getSignedUrl} from '@aws-sdk/s3-request-presigner'
1010
import type {RetryErrorInfo, StandardRetryToken} from '@smithy/types'
1111
import {StandardRetryStrategy} from '@smithy/util-retry'
1212
import mime from 'mime-types'
13+
import makeAppURL from 'parabol-client/utils/makeAppURL'
1314
import path from 'path'
15+
import appOrigin from '../appOrigin'
1416
import {Logger} from '../utils/Logger'
1517
import FileStoreManager, {type FileAssetDir, type PartialPath} from './FileStoreManager'
1618

@@ -122,7 +124,7 @@ export default class S3Manager extends FileStoreManager {
122124
return true
123125
}
124126

125-
async moveFile(oldPartialPath: PartialPath, newPartialPath: PartialPath) {
127+
async copyFile(oldPartialPath: PartialPath, newPartialPath: PartialPath) {
126128
const oldFullPath = decodeURI(this.prependPath(oldPartialPath))
127129
const newFullPath = decodeURI(this.prependPath(newPartialPath))
128130

@@ -133,6 +135,12 @@ export default class S3Manager extends FileStoreManager {
133135
Key: newFullPath
134136
})
135137
)
138+
return makeAppURL(appOrigin, `/assets/${newPartialPath}`)
139+
}
140+
141+
async moveFile(oldPartialPath: PartialPath, newPartialPath: PartialPath) {
142+
const oldFullPath = decodeURI(this.prependPath(oldPartialPath))
143+
await this.copyFile(oldPartialPath, newPartialPath)
136144
await this.s3.send(
137145
new DeleteObjectCommand({
138146
Bucket: this.bucket,

packages/server/graphql/public/mutations/embedUserAsset.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import {fetch} from '@whatwg-node/fetch'
22
import base64url from 'base64url'
33
import {createHash} from 'crypto'
4+
import {GraphQLError} from 'graphql'
45
import mime from 'mime-types'
6+
import makeAppURL from 'parabol-client/utils/makeAppURL'
7+
import appOrigin from '../../../appOrigin'
8+
import type {AssetType, PartialPath} from '../../../fileStorage/FileStoreManager'
59
import getFileStoreManager from '../../../fileStorage/getFileStoreManager'
610
import {compressImage} from '../../../utils/compressImage'
7-
import type {MutationResolvers} from '../resolverTypes'
11+
import {Logger} from '../../../utils/Logger'
12+
import type {AssetScopeEnum, MutationResolvers} from '../resolverTypes'
813
import {validateScope} from './uploadUserAsset'
914

1015
const fetchImage = async (url: string) => {
@@ -37,6 +42,27 @@ const embedUserAsset: MutationResolvers['embedUserAsset'] = async (
3742
const scopeCode = await validateScope(authToken, scope, scopeKey, dataLoader)
3843
if (typeof scopeCode !== 'string') return scopeCode
3944

45+
const hostedPrefix = makeAppURL(appOrigin, '/assets')
46+
const isParabolHostedAsset = url.startsWith(hostedPrefix)
47+
if (isParabolHostedAsset) {
48+
// if we host it, just make a copy of it in the new directory
49+
const manager = getFileStoreManager()
50+
const sourcePartialPath = url.slice(hostedPrefix.length + 1) as PartialPath
51+
const [scope, _sourceScopeCode, assetType, filename] = sourcePartialPath.split('/') as [
52+
AssetScopeEnum,
53+
string,
54+
AssetType,
55+
string
56+
]
57+
const targetPartialPath = `${scope}/${scopeCode}/${assetType}/${filename}` as PartialPath
58+
try {
59+
const url = await manager.copyFile(sourcePartialPath, targetPartialPath)
60+
return {url}
61+
} catch (e) {
62+
Logger.warn(e)
63+
throw new GraphQLError('Could not copy parabol asset')
64+
}
65+
}
4066
const asset = await fetchImage(url)
4167
if (!asset) {
4268
return {error: {message: 'Unable to fetch asset'}}

0 commit comments

Comments
 (0)