Merge branch 'rcv-services' into ep/spec-2
@@ -0,0 +1,15 @@
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Web.Embedded where
|
||||
|
||||
import Data.FileEmbed (embedDir, embedFile)
|
||||
import Simplex.Messaging.Server.Web (EmbeddedContent (..))
|
||||
|
||||
embeddedContent :: EmbeddedContent
|
||||
embeddedContent =
|
||||
EmbeddedContent
|
||||
{ indexHtml = $(embedFile "apps/common/Web/static/index.html"),
|
||||
linkHtml = $(embedFile "apps/common/Web/static/link.html"),
|
||||
mediaContent = $(embedDir "apps/common/Web/static/media/"),
|
||||
wellKnown = $(embedDir "apps/common/Web/static/.well-known/")
|
||||
}
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 632 B After Width: | Height: | Size: 632 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -8,23 +8,23 @@ module SMPWeb
|
||||
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.String (fromString)
|
||||
import Web.Embedded (embeddedContent)
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server.Information
|
||||
import Simplex.Messaging.Server.Main (simplexmqSource)
|
||||
import qualified Simplex.Messaging.Server.Web as Web
|
||||
import Simplex.Messaging.Server.Web (render, serverInfoSubsts, timedTTLText)
|
||||
import Simplex.Messaging.Server.Web.Embedded as E
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
|
||||
smpGenerateSite :: ServerInformation -> Maybe TransportHost -> FilePath -> IO ()
|
||||
smpGenerateSite si onionHost path =
|
||||
Web.generateSite (serverInformation si onionHost) smpLinkPages path
|
||||
Web.generateSite embeddedContent (serverInformation si onionHost) smpLinkPages path
|
||||
|
||||
smpLinkPages :: [String]
|
||||
smpLinkPages = ["contact", "invitation", "a", "c", "g", "r", "i"]
|
||||
|
||||
serverInformation :: ServerInformation -> Maybe TransportHost -> ByteString
|
||||
serverInformation ServerInformation {config, information} onionHost = render E.indexHtml substs
|
||||
serverInformation ServerInformation {config, information} onionHost = render (Web.indexHtml embeddedContent) substs
|
||||
where
|
||||
substs = [("smpConfig", Just "y"), ("xftpConfig", Nothing)] <> substConfig <> serverInfoSubsts simplexmqSource information <> [("onionHost", strEncode <$> onionHost), ("iniFileName", Just "smp-server.ini")]
|
||||
substConfig =
|
||||
|
||||
@@ -9,6 +9,7 @@ module XFTPWeb
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Maybe (isJust)
|
||||
import Data.String (fromString)
|
||||
import Web.Embedded (embeddedContent)
|
||||
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server.Expiration (ExpirationConfig (..))
|
||||
@@ -16,15 +17,14 @@ import Simplex.Messaging.Server.Information (ServerPublicInfo)
|
||||
import Simplex.Messaging.Server.Main (simplexmqSource)
|
||||
import qualified Simplex.Messaging.Server.Web as Web
|
||||
import Simplex.Messaging.Server.Web (render, serverInfoSubsts, timedTTLText)
|
||||
import Simplex.Messaging.Server.Web.Embedded as E
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
|
||||
xftpGenerateSite :: XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> FilePath -> IO ()
|
||||
xftpGenerateSite cfg info onionHost path =
|
||||
Web.generateSite (xftpServerInformation cfg info onionHost) [] path
|
||||
Web.generateSite embeddedContent (xftpServerInformation cfg info onionHost) [] path
|
||||
|
||||
xftpServerInformation :: XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> ByteString
|
||||
xftpServerInformation XFTPServerConfig {fileExpiration, logStatsInterval, allowNewFiles, newFileBasicAuth} information onionHost = render E.indexHtml substs
|
||||
xftpServerInformation XFTPServerConfig {fileExpiration, logStatsInterval, allowNewFiles, newFileBasicAuth} information onionHost = render (Web.indexHtml embeddedContent) substs
|
||||
where
|
||||
substs = [("smpConfig", Nothing), ("xftpConfig", Just "y")] <> substConfig <> serverInfoSubsts simplexmqSource information <> [("onionHost", strEncode <$> onionHost), ("iniFileName", Just "file-server.ini")]
|
||||
substConfig =
|
||||
|
||||
@@ -24,33 +24,33 @@ extra-source-files:
|
||||
CHANGELOG.md
|
||||
cbits/sha512.h
|
||||
cbits/sntrup761.h
|
||||
src/Simplex/Messaging/Server/Web/index.html
|
||||
src/Simplex/Messaging/Server/Web/link.html
|
||||
src/Simplex/Messaging/Server/Web/media/apk_icon.png
|
||||
src/Simplex/Messaging/Server/Web/media/apple_store.svg
|
||||
src/Simplex/Messaging/Server/Web/media/contact.js
|
||||
src/Simplex/Messaging/Server/Web/media/contact_page_mobile.png
|
||||
src/Simplex/Messaging/Server/Web/media/f_droid.svg
|
||||
src/Simplex/Messaging/Server/Web/media/favicon.ico
|
||||
src/Simplex/Messaging/Server/Web/media/GilroyBold.woff2
|
||||
src/Simplex/Messaging/Server/Web/media/GilroyLight.woff2
|
||||
src/Simplex/Messaging/Server/Web/media/GilroyMedium.woff2
|
||||
src/Simplex/Messaging/Server/Web/media/GilroyRegular.woff2
|
||||
src/Simplex/Messaging/Server/Web/media/GilroyRegularItalic.woff2
|
||||
src/Simplex/Messaging/Server/Web/media/google_play.svg
|
||||
src/Simplex/Messaging/Server/Web/media/logo-dark.png
|
||||
src/Simplex/Messaging/Server/Web/media/logo-light.png
|
||||
src/Simplex/Messaging/Server/Web/media/logo-symbol-dark.svg
|
||||
src/Simplex/Messaging/Server/Web/media/logo-symbol-light.svg
|
||||
src/Simplex/Messaging/Server/Web/media/moon.svg
|
||||
src/Simplex/Messaging/Server/Web/media/qrcode.js
|
||||
src/Simplex/Messaging/Server/Web/media/script.js
|
||||
src/Simplex/Messaging/Server/Web/media/style.css
|
||||
src/Simplex/Messaging/Server/Web/media/sun.svg
|
||||
src/Simplex/Messaging/Server/Web/media/swiper-bundle.min.css
|
||||
src/Simplex/Messaging/Server/Web/media/swiper-bundle.min.js
|
||||
src/Simplex/Messaging/Server/Web/media/tailwind.css
|
||||
src/Simplex/Messaging/Server/Web/media/testflight.png
|
||||
apps/common/Web/static/index.html
|
||||
apps/common/Web/static/link.html
|
||||
apps/common/Web/static/media/apk_icon.png
|
||||
apps/common/Web/static/media/apple_store.svg
|
||||
apps/common/Web/static/media/contact.js
|
||||
apps/common/Web/static/media/contact_page_mobile.png
|
||||
apps/common/Web/static/media/f_droid.svg
|
||||
apps/common/Web/static/media/favicon.ico
|
||||
apps/common/Web/static/media/GilroyBold.woff2
|
||||
apps/common/Web/static/media/GilroyLight.woff2
|
||||
apps/common/Web/static/media/GilroyMedium.woff2
|
||||
apps/common/Web/static/media/GilroyRegular.woff2
|
||||
apps/common/Web/static/media/GilroyRegularItalic.woff2
|
||||
apps/common/Web/static/media/google_play.svg
|
||||
apps/common/Web/static/media/logo-dark.png
|
||||
apps/common/Web/static/media/logo-light.png
|
||||
apps/common/Web/static/media/logo-symbol-dark.svg
|
||||
apps/common/Web/static/media/logo-symbol-light.svg
|
||||
apps/common/Web/static/media/moon.svg
|
||||
apps/common/Web/static/media/qrcode.js
|
||||
apps/common/Web/static/media/script.js
|
||||
apps/common/Web/static/media/style.css
|
||||
apps/common/Web/static/media/sun.svg
|
||||
apps/common/Web/static/media/swiper-bundle.min.css
|
||||
apps/common/Web/static/media/swiper-bundle.min.js
|
||||
apps/common/Web/static/media/tailwind.css
|
||||
apps/common/Web/static/media/testflight.png
|
||||
|
||||
flag swift
|
||||
description: Enable swift JSON format
|
||||
@@ -249,7 +249,6 @@ library
|
||||
Simplex.Messaging.Server.Main.GitCommit
|
||||
Simplex.Messaging.Server.Main.Init
|
||||
Simplex.Messaging.Server.Web
|
||||
Simplex.Messaging.Server.Web.Embedded
|
||||
Simplex.Messaging.Server.MsgStore
|
||||
Simplex.Messaging.Server.MsgStore.Journal
|
||||
Simplex.Messaging.Server.MsgStore.Journal.SharedLock
|
||||
@@ -345,7 +344,6 @@ library
|
||||
if !flag(client_library)
|
||||
build-depends:
|
||||
case-insensitive ==1.2.*
|
||||
, file-embed >=0.0.10 && <0.1
|
||||
, hashable ==1.4.*
|
||||
, ini ==0.4.1
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
@@ -412,15 +410,18 @@ executable smp-server
|
||||
main-is: Main.hs
|
||||
other-modules:
|
||||
SMPWeb
|
||||
Web.Embedded
|
||||
Paths_simplexmq
|
||||
hs-source-dirs:
|
||||
apps/smp-server
|
||||
apps/common
|
||||
default-extensions:
|
||||
StrictData
|
||||
ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts
|
||||
build-depends:
|
||||
base
|
||||
, bytestring
|
||||
, file-embed >=0.0.10 && <0.1
|
||||
, simple-logger
|
||||
, simplexmq
|
||||
, text
|
||||
@@ -448,15 +449,18 @@ executable xftp-server
|
||||
main-is: Main.hs
|
||||
other-modules:
|
||||
XFTPWeb
|
||||
Web.Embedded
|
||||
Paths_simplexmq
|
||||
hs-source-dirs:
|
||||
apps/xftp-server
|
||||
apps/common
|
||||
default-extensions:
|
||||
StrictData
|
||||
ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts
|
||||
build-depends:
|
||||
base
|
||||
, bytestring
|
||||
, file-embed >=0.0.10 && <0.1
|
||||
, simple-logger
|
||||
, simplexmq
|
||||
default-language: Haskell2010
|
||||
@@ -502,6 +506,7 @@ test-suite simplexmq-test
|
||||
XFTPWebTests
|
||||
SMPWeb
|
||||
XFTPWeb
|
||||
Web.Embedded
|
||||
Paths_simplexmq
|
||||
if flag(client_postgres)
|
||||
other-modules:
|
||||
@@ -522,6 +527,7 @@ test-suite simplexmq-test
|
||||
tests
|
||||
apps/smp-server
|
||||
apps/xftp-server
|
||||
apps/common
|
||||
default-extensions:
|
||||
StrictData
|
||||
-- add -fhpc to ghc-options to run tests with coverage
|
||||
@@ -539,6 +545,7 @@ test-suite simplexmq-test
|
||||
, crypton-x509-store
|
||||
, crypton-x509-validation
|
||||
, directory
|
||||
, file-embed >=0.0.10 && <0.1
|
||||
, filepath
|
||||
, generic-random ==1.5.*
|
||||
, hashable
|
||||
|
||||
@@ -19,6 +19,7 @@ module Simplex.Messaging.Parsers
|
||||
sumTypeJSON,
|
||||
taggedObjectJSON,
|
||||
singleFieldJSON,
|
||||
singleFieldJSON_,
|
||||
defaultJSON,
|
||||
textP,
|
||||
pattern SingleFieldJSONTag,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
module Simplex.Messaging.Server.Web
|
||||
( EmbeddedWebParams (..),
|
||||
WebHttpsParams (..),
|
||||
EmbeddedContent (..),
|
||||
serveStaticFiles,
|
||||
attachStaticFiles,
|
||||
serveStaticPageH2,
|
||||
@@ -41,7 +42,6 @@ import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server (AttachHTTP)
|
||||
import Simplex.Messaging.Server.CLI (simplexmqCommit)
|
||||
import Simplex.Messaging.Server.Information
|
||||
import Simplex.Messaging.Server.Web.Embedded as E
|
||||
import Simplex.Messaging.Transport (simplexMQVersion)
|
||||
import Simplex.Messaging.Util (ifM, tshow)
|
||||
import System.Directory (canonicalizePath, createDirectoryIfMissing, doesFileExist)
|
||||
@@ -62,6 +62,13 @@ data WebHttpsParams = WebHttpsParams
|
||||
key :: FilePath
|
||||
}
|
||||
|
||||
data EmbeddedContent = EmbeddedContent
|
||||
{ indexHtml :: ByteString,
|
||||
linkHtml :: ByteString,
|
||||
mediaContent :: [(FilePath, ByteString)],
|
||||
wellKnown :: [(FilePath, ByteString)]
|
||||
}
|
||||
|
||||
serveStaticFiles :: EmbeddedWebParams -> IO ()
|
||||
serveStaticFiles EmbeddedWebParams {webStaticPath, webHttpPort, webHttpsParams} = do
|
||||
forM_ webHttpPort $ \port -> flip forkFinally (\e -> logError $ "HTTP server crashed: " <> tshow e) $ do
|
||||
@@ -118,14 +125,14 @@ staticFiles root = S.staticApp settings . changeWellKnownPath
|
||||
_ -> req
|
||||
pfxLen = B.length "/.well-known/"
|
||||
|
||||
generateSite :: ByteString -> [String] -> FilePath -> IO ()
|
||||
generateSite indexContent linkPages sitePath = do
|
||||
generateSite :: EmbeddedContent -> ByteString -> [String] -> FilePath -> IO ()
|
||||
generateSite embedded indexContent linkPages sitePath = do
|
||||
createDirectoryIfMissing True sitePath
|
||||
B.writeFile (sitePath </> "index.html") indexContent
|
||||
copyDir "media" E.mediaContent
|
||||
copyDir "media" $ mediaContent embedded
|
||||
-- `.well-known` path is re-written in changeWellKnownPath,
|
||||
-- staticApp does not allow hidden folders.
|
||||
copyDir "well-known" E.wellKnown
|
||||
copyDir "well-known" $ wellKnown embedded
|
||||
forM_ linkPages createLinkPage
|
||||
logInfo $ "Generated static site contents at " <> tshow sitePath
|
||||
where
|
||||
@@ -134,7 +141,7 @@ generateSite indexContent linkPages sitePath = do
|
||||
forM_ content $ \(path, s) -> B.writeFile (sitePath </> dir </> path) s
|
||||
createLinkPage path = do
|
||||
createDirectoryIfMissing True $ sitePath </> path
|
||||
B.writeFile (sitePath </> path </> "index.html") E.linkHtml
|
||||
B.writeFile (sitePath </> path </> "index.html") $ linkHtml embedded
|
||||
|
||||
-- | Serve static files via HTTP/2 directly (without WAI).
|
||||
-- Path traversal protection: resolved path must stay under canonicalRoot.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
|
||||
module Simplex.Messaging.Server.Web.Embedded where
|
||||
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.FileEmbed (embedDir, embedFile)
|
||||
|
||||
indexHtml :: ByteString
|
||||
indexHtml = $(embedFile "src/Simplex/Messaging/Server/Web/index.html")
|
||||
|
||||
linkHtml :: ByteString
|
||||
linkHtml = $(embedFile "src/Simplex/Messaging/Server/Web/link.html")
|
||||
|
||||
mediaContent :: [(FilePath, ByteString)]
|
||||
mediaContent = $(embedDir "src/Simplex/Messaging/Server/Web/media/")
|
||||
|
||||
wellKnown :: [(FilePath, ByteString)]
|
||||
wellKnown = $(embedDir "src/Simplex/Messaging/Server/Web/.well-known/")
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@simplex-chat/xftp-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "XFTP file transfer protocol client for web/browser environments",
|
||||
"license": "AGPL-3.0-only",
|
||||
"repository": {
|
||||
|
||||
@@ -52,3 +52,23 @@ export function parseXFTPServer(address: string): XFTPServer {
|
||||
export function formatXFTPServer(srv: XFTPServer): string {
|
||||
return "xftp://" + base64urlEncode(srv.keyHash) + "@" + srv.host + ":" + srv.port
|
||||
}
|
||||
|
||||
// Extract unique XFTP servers referenced in a file description's chunk replicas.
|
||||
export function getDescriptionServers(fd: {chunks: {replicas: {server: string}[]}[]}): XFTPServer[] {
|
||||
const seen = new Set<string>()
|
||||
const servers: XFTPServer[] = []
|
||||
for (const chunk of fd.chunks) {
|
||||
for (const replica of chunk.replicas) {
|
||||
if (!seen.has(replica.server)) {
|
||||
seen.add(replica.server)
|
||||
servers.push(parseXFTPServer(replica.server))
|
||||
}
|
||||
}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// Build an HTTPS origin from an XFTP server address.
|
||||
export function serverOrigin(server: XFTPServer): string {
|
||||
return server.port === "443" ? `https://${server.host}` : `https://${server.host}:${server.port}`
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export default defineConfig(({mode}) => {
|
||||
servers = ['xftp://fp@localhost:443']
|
||||
} else {
|
||||
// In production mode, use the preset servers
|
||||
servers = [...presets.simplex, ...presets.flux]
|
||||
servers = [...presets.simplex]
|
||||
define['__XFTP_SERVERS__'] = JSON.stringify(servers)
|
||||
define['__XFTP_PROXY_PORT__'] = JSON.stringify(null)
|
||||
}
|
||||
@@ -94,6 +94,7 @@ export default defineConfig(({mode}) => {
|
||||
outDir: resolve(__dirname, 'dist-web'),
|
||||
emptyOutDir: true,
|
||||
target: 'esnext',
|
||||
minify: false,
|
||||
chunkSizeWarningLimit: 1200,
|
||||
rollupOptions: {
|
||||
external: ['node:http2', 'url'],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
newXFTPAgent, closeXFTPAgent,
|
||||
decodeDescriptionURI, downloadFileRaw
|
||||
} from '../src/agent.js'
|
||||
import {getDescriptionServers} from '../src/protocol/address.js'
|
||||
import {XFTPPermanentError} from '../src/client.js'
|
||||
|
||||
const DECRYPT_WEIGHT = 0.15
|
||||
@@ -20,6 +21,8 @@ export function initDownload(app: HTMLElement, hash: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const wrongServer = !getDescriptionServers(fd).map(s => s.host).includes(window.location.hostname)
|
||||
|
||||
const size = fd.redirect ? fd.redirect.size : fd.size
|
||||
app.innerHTML = `
|
||||
<div class="card">
|
||||
@@ -62,6 +65,11 @@ export function initDownload(app: HTMLElement, hash: string) {
|
||||
showStage(errorStage)
|
||||
}
|
||||
|
||||
if (wrongServer) {
|
||||
readyStage.innerHTML = `<p class="error">${t('wrongServer', 'This file is not hosted on this server.')}</p>`
|
||||
return
|
||||
}
|
||||
|
||||
dlBtn.addEventListener('click', startDownload)
|
||||
retryBtn.addEventListener('click', () => showStage(readyStage))
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
newXFTPAgent, closeXFTPAgent, uploadFile, encodeDescriptionURI,
|
||||
type EncryptedFileMetadata
|
||||
} from '../src/agent.js'
|
||||
import {getDescriptionServers, serverOrigin} from '../src/protocol/address.js'
|
||||
import {XFTPPermanentError} from '../src/client.js'
|
||||
|
||||
const MAX_SIZE = 100 * 1024 * 1024
|
||||
@@ -170,7 +171,11 @@ export function initUpload(app: HTMLElement) {
|
||||
})
|
||||
if (aborted) return
|
||||
|
||||
const url = window.location.origin + window.location.pathname + '#' + result.uri
|
||||
const descServers = getDescriptionServers(result.rcvDescription)
|
||||
const origin = descServers.length > 0
|
||||
? serverOrigin(descServers[0])
|
||||
: window.location.origin
|
||||
const url = origin + window.location.pathname + '#' + result.uri
|
||||
shareLink.value = url
|
||||
showStage(completeStage)
|
||||
app.dispatchEvent(new CustomEvent('xftp:upload-complete', {detail: {url}, bubbles: true}))
|
||||
|
||||