diff --git a/package.json b/package.json index 08f647ca7241..85738f01871d 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "jss": "^10", "lint-staged": "^9.4.3", "lodash-es": "^4.17.11", + "node-stego": "^0.8.0", "notistack": "^0.9.5", "npm-run-all": "^4.1.5", "pvtsutils": "^1.0.4", diff --git a/public/maskbook-steganography-mask.png b/public/maskbook-steganography-mask.png new file mode 100644 index 000000000000..b13203d053fd Binary files /dev/null and b/public/maskbook-steganography-mask.png differ diff --git a/public/maskbook-steganography.png b/public/maskbook-steganography.png new file mode 100644 index 000000000000..fda09b9ad739 Binary files /dev/null and b/public/maskbook-steganography.png differ diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index def090a6445c..f5def0dba059 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -17,6 +17,12 @@ "additional_post_box__encrypted_failed": { "message": "Please paste the following text into the post box." }, + "additional_post_box__steganography_post_pre": { + "message": "This post is shared with my friends. You may install Maskbook to decrypt the content from the image. ([I:b])" + }, + "additional_post_box__steganography_post_failed": { + "message": "Please upload downloaded image into the post box." + }, "comment_box__paste_failed": { "message": "Please paste it into the comment box." }, diff --git a/src/_locales/zh/messages.json b/src/_locales/zh/messages.json index 12a6bbb9d932..74cd8f30987b 100644 --- a/src/_locales/zh/messages.json +++ b/src/_locales/zh/messages.json @@ -18,6 +18,12 @@ "additional_post_box__encrypted_failed": { "message": "請將以下内容拷貝至發佈框。" }, + "additional_post_box__steganography_post_pre": { + "message": "此圖片僅分享給我的好友。請安裝 Maskbook 以解讀圖片。 ([I:b])" + }, + "additional_post_box__steganography_post_failed": { + "message": "請將已下載的圖片上载至發佈框。" + }, "comment_box__paste_failed": { "message": "請將以下内容拷貝至評論框。" }, diff --git a/src/background-service.ts b/src/background-service.ts index 1ec335b8493c..b232ffba7bce 100644 --- a/src/background-service.ts +++ b/src/background-service.ts @@ -12,9 +12,10 @@ import elliptic from 'elliptic' import * as CryptoService from './extension/background-script/CryptoService' import * as WelcomeService from './extension/background-script/WelcomeService' import * as PeopleService from './extension/background-script/PeopleService' +import * as SteganographyService from './extension/background-script/SteganographyService' import { decryptFromMessageWithProgress } from './extension/background-script/CryptoServices/decryptFrom' import { initAutoShareToFriends } from './extension/background-script/Jobs/AutoShareToFriends' -Object.assign(window, { CryptoService, WelcomeService, PeopleService }) +Object.assign(window, { CryptoService, WelcomeService, PeopleService, SteganographyService }) Object.assign(window, { ServicesWithProgress: { decryptFrom: decryptFromMessageWithProgress, diff --git a/src/components/InjectedComponents/AdditionalPostBox.tsx b/src/components/InjectedComponents/AdditionalPostBox.tsx index 3f8ea3bec3ab..643cfb6788a8 100644 --- a/src/components/InjectedComponents/AdditionalPostBox.tsx +++ b/src/components/InjectedComponents/AdditionalPostBox.tsx @@ -14,6 +14,14 @@ import { getActivatedUI } from '../../social-network/ui' import { ChooseIdentity, ChooseIdentityProps } from '../shared/ChooseIdentity' import { useAsync } from '../../utils/components/AsyncComponent' import { useStylesExtends, or } from '../custom-ui-helper' +import { steganographyModeSetting } from '../shared-settings/settings' +import { useValueRef } from '../../utils/hooks/useValueRef' + +interface Props { + availableTarget: Array + + onRequestPost: (target: Array, text: string) => void +} const useStyles = makeStyles({ root: { margin: '10px 0' }, @@ -113,6 +121,7 @@ export function AdditionalPostBox(props: AdditionalPostBoxProps) { ) const identities = or(props.identities, useMyIdentities()) const currentIdentity = or(props.currentIdentity, useCurrentIdentity()) + const isSteganography = useValueRef(steganographyModeSetting) const onRequestPost = or( props.onRequestPost, @@ -123,14 +132,24 @@ export function AdditionalPostBox(props: AdditionalPostBoxProps) { target.map(x => x.identifier), currentIdentity!.identifier, ) - const fullPost = geti18nString('additional_post_box__encrypted_post_pre', encrypted) - getActivatedUI().taskPasteIntoPostBox(fullPost, { - warningText: geti18nString('additional_post_box__encrypted_failed'), - shouldOpenPostDialog: false, - }) + const activeUI = getActivatedUI() + if (isSteganography) { + activeUI.taskPasteIntoPostBox(geti18nString('additional_post_box__steganography_post_pre'), { + warningText: geti18nString('additional_post_box__encrypted_failed'), + shouldOpenPostDialog: false, + }) + activeUI.taskUploadToPostBox(encrypted, { + warningText: geti18nString('additional_post_box__steganography_post_failed'), + }) + } else { + activeUI.taskPasteIntoPostBox(geti18nString('additional_post_box__encrypted_post_pre', encrypted), { + warningText: geti18nString('additional_post_box__encrypted_failed'), + shouldOpenPostDialog: false, + }) + } Services.Crypto.publishPostAESKey(token) }, - [currentIdentity], + [currentIdentity, isSteganography], ), ) diff --git a/src/components/shared-settings/settings.ts b/src/components/shared-settings/settings.ts index e3b518c86c98..ccb014dbf615 100644 --- a/src/components/shared-settings/settings.ts +++ b/src/components/shared-settings/settings.ts @@ -9,6 +9,14 @@ export const debugModeSetting = createNewSettings('debugMode', false, { primary: 'Enable debug mode', secondary: 'Enable this will display additional information on the Maskbook UI to help debugging', }) +/** + * Dose steganography post mode on + */ +export const steganographyModeSetting = createNewSettings('steganographyMode', false, { + primary: 'Enable steganography mode', + secondary: 'Publishing post with steganography payload instead of text playload', +}) + /** * Never open a new tab in the background */ diff --git a/src/database/helpers/avatar.ts b/src/database/helpers/avatar.ts index ca1b3dd30aed..e6421a074b1d 100644 --- a/src/database/helpers/avatar.ts +++ b/src/database/helpers/avatar.ts @@ -3,6 +3,7 @@ import { queryAvatarDB, isAvatarOutdatedDB, storeAvatarDB } from '../avatar' import { memoizePromise } from '../../utils/memoize' import { MessageCenter } from '../../utils/messages' import { queryPerson } from './person' +import { downloadUrl } from '../../utils/utils' /** * Get a (cached) blob url for an identifier. @@ -43,7 +44,7 @@ export async function storeAvatar( if (typeof avatar === 'string') { if (avatar.startsWith('http') === false) return if (force || (await isAvatarOutdatedDB(identifier, 'lastUpdateTime'))) { - await storeAvatarDB(identifier, await downloadAvatar(avatar)) + await storeAvatarDB(identifier, await downloadUrl(avatar)) } // else do nothing } else { @@ -58,11 +59,3 @@ export async function storeAvatar( } } } -/** - * Download avatar from url - */ -async function downloadAvatar(url: string): Promise { - const res = await fetch(url) - if (!res.ok) throw new Error('Fetch avatar failed.') - return res.arrayBuffer() -} diff --git a/src/extension/background-script/SteganographyService.ts b/src/extension/background-script/SteganographyService.ts new file mode 100644 index 000000000000..d2ee27163a9b --- /dev/null +++ b/src/extension/background-script/SteganographyService.ts @@ -0,0 +1,54 @@ +import { encode, decode } from 'node-stego/es/dom' +import { GrayscaleAlgorithm } from 'node-stego/es/grayscale' +import { TransformAlgorithm } from 'node-stego/es/transform' +import { OnlyRunInContext } from '@holoflows/kit/es' +import { EncodeOptions, DecodeOptions } from 'node-stego/es/stego' +import { getUrl, downloadUrl } from '../../utils/utils' +import { memoizePromise } from '../../utils/memoize' + +OnlyRunInContext('background', 'SteganographyService') + +type WithPartial = { [P in Exclude]?: T[P] | undefined } & { [P in K]: T[P] } + +const defaultOptions = { + size: 8, + narrow: 0, + copies: 3, + tolerance: 128, +} + +const getMaskBuf = memoizePromise(() => downloadUrl(getUrl('/maskbook-steganography-mask.png')), undefined) + +export async function encodeImage( + { buffer: imgBuf }: Uint8Array, + options: WithPartial, 'text' | 'pass'>, +) { + return new Uint8Array( + await encode(imgBuf, await getMaskBuf(), { + ...defaultOptions, + noCropEdgePixels: false, + grayscaleAlgorithm: GrayscaleAlgorithm.LUMINANCE, + transformAlgorithm: TransformAlgorithm.FFT1D, + ...options, + }), + ) +} + +export async function decodeImage( + { buffer: imgBuf }: Uint8Array, + options: WithPartial, 'pass'>, +) { + return decode(imgBuf, await getMaskBuf(), { + ...defaultOptions, + transformAlgorithm: TransformAlgorithm.FFT1D, + ...options, + }) +} + +export function downloadImage({ buffer }: Uint8Array) { + return browser.downloads.download({ + url: URL.createObjectURL(new Blob([buffer], { type: 'image/png' })), + filename: 'maskbook.png', + saveAs: true, + }) +} diff --git a/src/extension/injected-script/addEventListener.ts b/src/extension/injected-script/addEventListener.ts index 35f1d5fc6899..67eee1ae72bb 100644 --- a/src/extension/injected-script/addEventListener.ts +++ b/src/extension/injected-script/addEventListener.ts @@ -1,6 +1,6 @@ import { CustomEventId } from '../../utils/constants' export interface CustomEvents { - paste: [string] + paste: [string | { type: 'image'; value: Array }] input: [string] } { @@ -30,11 +30,38 @@ export interface CustomEvents { } const hacks: { [key in keyof CustomEvents & keyof DocumentEventMap]: (...params: CustomEvents[key]) => Event } = { - paste(text) { + paste(textOrImage) { const e = new ClipboardEvent('paste', { clipboardData: new DataTransfer() }) - e.clipboardData!.setData('text/plain', text) - // ! Why? - return getEvent(e, { defaultPrevented: false, preventDefault() {} }) + if (typeof textOrImage === 'string') { + e.clipboardData!.setData('text/plain', textOrImage) + return getEvent(e, { defaultPrevented: false, preventDefault() {} }) + } else if (textOrImage.type === 'image') { + const binary = Uint8Array.from(textOrImage.value) + const blob = new Blob([binary], { type: 'image/png' }) + const file = new File([blob], 'image.png', { lastModified: Date.now(), type: 'image/png' }) + const dt = new Proxy(new DataTransfer(), { + get(target, key: keyof typeof target) { + if (key === 'files') return [file] + if (key === 'types') return ['Files'] + if (key === 'items') + return [ + { + kind: 'file', + type: 'image/png', + getAsFile() { + return file + }, + }, + ] + if (key === 'getData') return () => '' + return target[key] + }, + }) + return getEvent(e, { defaultPrevented: false, preventDefault() {}, clipboardData: dt }) + } + const error = new Error(`Unknown event, got ${textOrImage?.type ?? 'unknown'}`) + console.error(error) + throw error }, input(text) { // Cause react hooks the input.value getter & setter @@ -51,7 +78,7 @@ export interface CustomEvents { for (const f of store[eventName] || []) { try { const hack = hacks[eventName] - if (hack) f(hack(...param)) + if (hack) f((hack as any)(...param)) else f(param as any) } catch (e) { console.error(e) diff --git a/src/extension/mock-service.ts b/src/extension/mock-service.ts index 1d7520b69dad..194d0fefc3f1 100644 --- a/src/extension/mock-service.ts +++ b/src/extension/mock-service.ts @@ -57,3 +57,11 @@ export const PeopleService: Partial = { + async encodeImage() { + return new Uint8Array() + }, + async decodeImage() { + return '' + }, +} diff --git a/src/extension/options-page/Developer.tsx b/src/extension/options-page/Developer.tsx index 581c7b0d1b05..7ad7e28933c5 100644 --- a/src/extension/options-page/Developer.tsx +++ b/src/extension/options-page/Developer.tsx @@ -4,7 +4,11 @@ import { AddProve } from './DeveloperComponents/AddProve' import { DecryptPostDeveloperMode } from './DeveloperComponents/DecryptPost' import { SeeMyProvePost } from './DeveloperComponents/SeeMyProvePost' import { FriendsDeveloperMode } from './DeveloperComponents/Friends' -import { debugModeSetting, disableOpenNewTabInBackgroundSettings } from '../../components/shared-settings/settings' +import { + debugModeSetting, + steganographyModeSetting, + disableOpenNewTabInBackgroundSettings, +} from '../../components/shared-settings/settings' import { useSettingsUI } from '../../components/shared-settings/createSettings' const useStyles = makeStyles(theme => ({ @@ -19,6 +23,7 @@ const DevPage = () => { Developer Settings
{useSettingsUI(debugModeSetting)} + {useSettingsUI(steganographyModeSetting)} {useSettingsUI(disableOpenNewTabInBackgroundSettings)} diff --git a/src/extension/service.ts b/src/extension/service.ts index 25374a6d8f3f..04be5c1d1cd2 100644 --- a/src/extension/service.ts +++ b/src/extension/service.ts @@ -13,6 +13,7 @@ interface Services { Crypto: typeof import('./background-script/CryptoService') People: typeof import('./background-script/PeopleService') Welcome: typeof import('./background-script/WelcomeService') + Steganography: typeof import('./background-script/SteganographyService') } const Services = {} as Services export default Services @@ -30,6 +31,7 @@ if (!('Services' in globalThis)) { register(createProxyToService('CryptoService'), 'Crypto', MockService.CryptoService) register(createProxyToService('WelcomeService'), 'Welcome', MockService.WelcomeService) register(createProxyToService('PeopleService'), 'People', MockService.PeopleService) + register(createProxyToService('SteganographyService'), 'Steganography', MockService.SteganographyService) } interface ServicesWithProgress { // Sorry you should add import at '../background-service.ts' diff --git a/src/polyfill/permissions.js b/src/polyfill/permissions.js index 9a3506bae306..cef4537bcac7 100644 --- a/src/polyfill/permissions.js +++ b/src/polyfill/permissions.js @@ -1,29 +1,29 @@ -;(()=>{ - if (typeof browser === 'undefined' || !browser) return - const _permissions = browser.permissions || {} - browser.permissions = new Proxy(_permissions, { - get (target, prop, receiver) { - if (prop === 'request') { - return ({origins}) => { - const item = localStorage.getItem('requestedUrls') - let requestedUrls = JSON.parse(item) || [] - for (let i of origins) { - if (!requestedUrls.includes(i)) requestedUrls.push(i) - } - localStorage.setItem('requestedUrls', JSON.stringify(requestedUrls)) - return Promise.resolve(true) - } - } else if (prop === 'getAll') { - return () => { - const item = localStorage.getItem('requestedUrls') - return Promise.resolve({origins: JSON.parse(item) || []}) - } - } else { - return Reflect.get(target, prop, receiver) - } - }, - set () { - return false - } - }) +;(() => { + if (typeof browser === 'undefined' || !browser) return + const _permissions = browser.permissions || {} + browser.permissions = new Proxy(_permissions, { + get(target, prop, receiver) { + if (prop === 'request') { + return ({ origins }) => { + const item = localStorage.getItem('requestedUrls') + let requestedUrls = JSON.parse(item) || [] + for (let i of origins) { + if (!requestedUrls.includes(i)) requestedUrls.push(i) + } + localStorage.setItem('requestedUrls', JSON.stringify(requestedUrls)) + return Promise.resolve(true) + } + } else if (prop === 'getAll') { + return () => { + const item = localStorage.getItem('requestedUrls') + return Promise.resolve({ origins: JSON.parse(item) || [] }) + } + } else { + return Reflect.get(target, prop, receiver) + } + }, + set() { + return false + }, + }) })() diff --git a/src/social-network-provider/facebook.com/UI/collectPosts.tsx b/src/social-network-provider/facebook.com/UI/collectPosts.tsx index c8798553aee1..6179a1579d74 100644 --- a/src/social-network-provider/facebook.com/UI/collectPosts.tsx +++ b/src/social-network-provider/facebook.com/UI/collectPosts.tsx @@ -3,6 +3,8 @@ import { deconstructPayload } from '../../../utils/type-transform/Payload' import { getEmptyPostInfoByElement, PostInfo, SocialNetworkUI } from '../../../social-network/ui' import { isMobileFacebook } from '../isMobile' import { getPersonIdentifierAtFacebook } from '../getPersonIdentifierAtFacebook' +import { downloadUrl } from '../../../utils/utils' +import Services from '../../../extension/service' const posts = new LiveSelector().querySelectorAll( isMobileFacebook ? '.story_body_container ' : '.userContent, .userContent+*+div>div>div>div>div', @@ -53,9 +55,12 @@ export function collectPostsFacebook(this: SocialNetworkUI) { this.posts.set(metadata, info) function collectPostInfo() { info.postContent.value = node.innerText - const postBy = getPostBy(metadata, info.postPayload.value !== null).identifier - info.postBy.value = postBy + info.postBy.value = getPostBy(metadata, info.postPayload.value !== null).identifier info.postID.value = getPostID(metadata) + getSteganographyContent(metadata).then(content => { + if (content && info.postContent.value.indexOf(content) === -1 && content.substr(0, 2) === '🎼') + info.postContent.value = content + }) } collectPostInfo() info.postPayload.value = deconstructPayload(info.postContent.value, this.payloadDecoder) @@ -107,3 +112,39 @@ function getPostID(node: DOMProxy): null | string { } } } +async function getSteganographyContent(node: DOMProxy) { + const parent = node.current.parentElement + if (!parent) return '' + const imgNodes = parent.querySelectorAll( + isMobileFacebook ? 'div>div>div>a>div>div>i.img' : '.uiScaledImageContainer img', + ) + if (!imgNodes.length) return '' + const imgUrls = isMobileFacebook + ? (getComputedStyle(imgNodes[0]).backgroundImage || '') + .slice(4, -1) + .replace(/['"]/g, '') + .split(',') + .filter(Boolean) + : Array.from(imgNodes) + .map(node => node.getAttribute('src') || '') + .filter(Boolean) + if (!imgUrls.length) return '' + const pass = getPostBy(node, false).identifier.toText() + return ( + await Promise.all( + imgUrls + .map(async url => { + try { + const image = new Uint8Array(await downloadUrl(url)) + const content = await Services.Steganography.decodeImage(image, { + pass, + }) + return content.indexOf('🎼') === 0 ? content : '' + } catch { + return '' + } + }) + .filter(Boolean), + ) + ).join('\n') +} diff --git a/src/social-network-provider/facebook.com/tasks/uploadToPostBox.ts b/src/social-network-provider/facebook.com/tasks/uploadToPostBox.ts new file mode 100644 index 000000000000..9d86f8a316ea --- /dev/null +++ b/src/social-network-provider/facebook.com/tasks/uploadToPostBox.ts @@ -0,0 +1,35 @@ +import { SocialNetworkUI, getActivatedUI } from '../../../social-network/ui' +import { untilDocumentReady } from '../../../utils/dom' +import { getUrl, downloadUrl, pasteImageToActiveElements } from '../../../utils/utils' +import Services from '../../../extension/service' + +export async function uploadToPostBoxFacebook( + text: string, + options: Parameters[1], +) { + const { warningText } = options + const { currentIdentity } = getActivatedUI() + const blankImage = await downloadUrl(getUrl('/maskbook-steganography.png')) + const secretImage = await Services.Steganography.encodeImage(new Uint8Array(blankImage), { + text, + pass: currentIdentity.value ? currentIdentity.value.identifier.toText() : '', + }) + + const image = new Uint8Array(secretImage) + await pasteImageToActiveElements(image) + await untilDocumentReady() + + try { + // Need a better way to find whether the image is pasted into + // throw new Error('auto uploading is undefined') + } catch { + uploadFail() + } + + async function uploadFail() { + console.warn('Image not uploaded to the post box') + if (confirm(warningText)) { + await Services.Steganography.downloadImage(image) + } + } +} diff --git a/src/social-network-provider/facebook.com/ui-provider.ts b/src/social-network-provider/facebook.com/ui-provider.ts index 997ba0bec2ab..74616f947284 100644 --- a/src/social-network-provider/facebook.com/ui-provider.ts +++ b/src/social-network-provider/facebook.com/ui-provider.ts @@ -5,6 +5,7 @@ import { sharedProvider } from './shared-provider' import { injectPostBoxFacebook } from './UI/injectPostBox' import { collectPeopleFacebook } from './UI/collectPeople' import { pasteIntoPostBoxFacebook } from './tasks/pasteIntoPostBox' +import { uploadToPostBoxFacebook } from './tasks/uploadToPostBox' import { getPostContentFacebook } from './tasks/getPostContent' import { resolveLastRecognizedIdentityFacebook } from './UI/resolveLastRecognizedIdentity' import { getProfileFacebook } from './tasks/getProfile' @@ -88,8 +89,9 @@ export const facebookUISelf = defineSocialNetworkUI({ injectPostInspector: injectPostInspectorFacebook, collectPeople: collectPeopleFacebook, collectPosts: collectPostsFacebook, - taskPasteIntoPostBox: pasteIntoPostBoxFacebook, taskPasteIntoBio: pasteIntoBioFacebook, + taskPasteIntoPostBox: pasteIntoPostBoxFacebook, + taskUploadToPostBox: uploadToPostBoxFacebook, taskGetPostContent: getPostContentFacebook, taskGetProfile: getProfileFacebook, }) diff --git a/src/social-network-provider/twitter.com/ui/tasks.ts b/src/social-network-provider/twitter.com/ui/tasks.ts index 74cb3370ac8e..9ff9ee0e895b 100644 --- a/src/social-network-provider/twitter.com/ui/tasks.ts +++ b/src/social-network-provider/twitter.com/ui/tasks.ts @@ -1,4 +1,11 @@ -import { dispatchCustomEvents, sleep, timeout } from '../../../utils/utils' +import { + dispatchCustomEvents, + sleep, + timeout, + downloadUrl, + getUrl, + pasteImageToActiveElements, +} from '../../../utils/utils' import { editProfileButtonSelector, editProfileTextareaSelector, @@ -8,12 +15,13 @@ import { postsSelector, } from '../utils/selector' import { geti18nString } from '../../../utils/i18n' -import { SocialNetworkUI, SocialNetworkUITasks } from '../../../social-network/ui' +import { SocialNetworkUI, SocialNetworkUITasks, getActivatedUI } from '../../../social-network/ui' import { fetchBioCard } from '../utils/status' import { bioCardParser, postParser } from '../utils/fetch' import { getText, hasFocus, postBoxInPopup } from '../utils/postBox' import { MutationObserverWatcher } from '@holoflows/kit' import { untilDocumentReady, untilElementAvailable } from '../../../utils/dom' +import Services from '../../../extension/service' /** * Wait for up to 5000 ms @@ -64,6 +72,35 @@ const taskPasteIntoPostBox: SocialNetworkUI['taskPasteIntoPostBox'] = (text, opt worker(abortCtr).then(undefined, e => fail(e)) } +const taskUploadToPostBox: SocialNetworkUI['taskUploadToPostBox'] = async (text, options) => { + const { warningText } = options + const { currentIdentity } = getActivatedUI() + const blankImage = await downloadUrl(getUrl('/maskbook-steganography.png')) + const secretImage = await Services.Steganography.encodeImage(new Uint8Array(blankImage), { + text, + pass: currentIdentity.value ? currentIdentity.value.identifier.toText() : '', + }) + + const image = new Uint8Array(secretImage) + + await pasteImageToActiveElements(image) + await untilDocumentReady() + + try { + // Need a better way to find whether the image is pasted into + // throw new Error('auto uploading is undefined') + } catch { + uploadFail() + } + + async function uploadFail() { + console.warn('Image not uploaded to the post box') + if (confirm(warningText)) { + await Services.Steganography.downloadImage(image) + } + } +} + const taskPasteIntoBio = async (text: string) => { const getValue = () => editProfileTextareaSelector().evaluate()!.value await untilDocumentReady() @@ -102,6 +139,7 @@ const taskGetProfile = async () => { export const twitterUITasks: SocialNetworkUITasks = { taskPasteIntoPostBox, + taskUploadToPostBox, taskPasteIntoBio, taskGetPostContent, taskGetProfile, diff --git a/src/social-network-provider/twitter.com/utils/fetch.ts b/src/social-network-provider/twitter.com/utils/fetch.ts index 6facd3ad2480..12c03fb8e9f1 100644 --- a/src/social-network-provider/twitter.com/utils/fetch.ts +++ b/src/social-network-provider/twitter.com/utils/fetch.ts @@ -1,10 +1,12 @@ import { bioCard } from './selector' -import { regexMatch } from '../../../utils/utils' +import { regexMatch, downloadUrl } from '../../../utils/utils' import { notNullable } from '../../../utils/assert' import { defaultTo, isUndefined, join } from 'lodash-es' import { nthChild } from '../../../utils/dom' import { PersonIdentifier } from '../../../database/type' import { twitterUrl } from './url' +import Services from '../../../extension/service' +import { getActivatedUI } from '../../../social-network/ui' /** * @example @@ -72,6 +74,31 @@ export const postContentParser = (node: HTMLElement) => { return join(sto) } +export const postImageParser = async (node: HTMLElement) => { + const parent = node.parentElement + if (!parent) return '' + const imgNodes = node.parentElement!.querySelectorAll('img[src*="twimg.com/media"]') + if (!imgNodes.length) return '' + const imgUrls = Array.from(imgNodes).map(node => node.getAttribute('src') || '') + if (!imgUrls.length) return '' + const { currentIdentity } = getActivatedUI() + const pass = currentIdentity.value ? currentIdentity.value.identifier.toText() : '' + + return ( + await Promise.all( + imgUrls + .map(async url => { + const image = new Uint8Array(await downloadUrl(url)) + const content = await Services.Steganography.decodeImage(image, { + pass, + }) + return /https:\/\/.+\..+\/%20(.+)%40/.test(content) ? content : '' + }) + .filter(Boolean), + ) + ).join('\n') +} + /** * @param node the '[data-testid="tweet"]' node * @return link to avatar. @@ -89,6 +116,6 @@ export const postParser = async (node: HTMLElement) => { // pid may not available at promoted tweet pid: pidLocation ? regexMatch(pidLocation!.href, /status\/(\d+)/, 1)! : undefined, avatar: avatarElement ? avatarElement.src : undefined, - content: postContentParser(node), + content: postContentParser(node) + (await postImageParser(node)), } } diff --git a/src/social-network/defaults/emptyDefinition.ts b/src/social-network/defaults/emptyDefinition.ts index f0c8ed850395..a635eb12e96c 100644 --- a/src/social-network/defaults/emptyDefinition.ts +++ b/src/social-network/defaults/emptyDefinition.ts @@ -49,5 +49,6 @@ export const emptyDefinition: SocialNetworkUIDefinition = { }, taskPasteIntoBio() {}, taskPasteIntoPostBox() {}, + taskUploadToPostBox() {}, version: 1, } diff --git a/src/social-network/ui.ts b/src/social-network/ui.ts index 90d01a999b98..3877c189eead 100644 --- a/src/social-network/ui.ts +++ b/src/social-network/ui.ts @@ -141,6 +141,17 @@ export interface SocialNetworkUIInjections { * These tasks may be called directly or call through @holoflows/kit/AutomatedTabTask */ export interface SocialNetworkUITasks { + /** + * This function should encode `text` into the base image and upload it to the post box. + * If failed, warning user to do it by themselves with `warningText` + */ + taskUploadToPostBox( + text: string, + options: { + warningText: string + }, + ): void + /** * This function should paste `text` into the post box. * If failed, warning user to do it by themselves with `warningText` @@ -208,6 +219,7 @@ export type PostInfo = { readonly postID: ValueRef readonly postContent: ValueRef readonly postPayload: ValueRef + readonly steganographyContent: ValueRef readonly commentsSelector?: LiveSelector readonly commentBoxSelector?: LiveSelector readonly decryptedPostContent: ValueRef diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 9db8e9857fb1..0d923ccc2ec8 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -17,6 +17,15 @@ export function getUrl(path: string, fallback: string = '') { return fallback || path } +/** + * Download given url return as ArrayBuffer + */ +export async function downloadUrl(url: string) { + const res = await fetch(url) + if (!res.ok) throw new Error('Fetch failed.') + return res.arrayBuffer() +} + /** * Dispatch a fake event. * @param event Event name @@ -26,6 +35,14 @@ export function dispatchCustomEvents(event: T, ... document.dispatchEvent(new CustomEvent(CustomEventId, { detail: JSON.stringify([event, x]) })) } +/** + * paste image to activeElements + * @param bytes + */ +export async function pasteImageToActiveElements(bytes: Uint8Array) { + return dispatchCustomEvents('paste', { type: 'image', value: Array.from(bytes) }) +} + Object.assign(globalThis, { dispatchCustomEvents }) /** diff --git a/yarn.lock b/yarn.lock index 35aa45065be7..70ad6373c114 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10744,7 +10744,7 @@ memorystream@^0.3.1: resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" integrity sha1-htcJCzDORV1j+64S3aUaR93K+bI= -meow@5.0.0: +meow@5.0.0, meow@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/meow/-/meow-5.0.0.tgz#dfc73d63a9afc714a5e371760eb5c88b91078aa4" integrity sha512-CbTqYU17ABaLefO8vCU153ZZlprKYWDljcndKKDCFcYQITzWCXZAVk4QMFZPgvzrnUQ3uItnIE/LoUOwrT15Ig== @@ -11338,6 +11338,13 @@ node-releases@^1.1.29: dependencies: semver "^5.3.0" +node-stego@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/node-stego/-/node-stego-0.8.0.tgz#08499d926d4fcd5822bdfc76d72cba4fe9d54ef8" + integrity sha512-8nzN4oboI72rwm2zlgmNCt1j5qoHHQSKOmhet5RBvijl38twMmYk+tQj007/vD/riFwXQEjeSLq2zVDXw/DxJg== + dependencies: + meow "^5.0.0" + nomnom@1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7"