···11-import * as Uint8arrays from "uint8arrays"
22-33-import * as Base64 from "@oddjs/odd/common/base64"
44-import * as Capabilities from "@oddjs/odd/capabilities"
55-import * as Crypto from "@oddjs/odd/components/crypto/implementation"
66-import * as Depot from "@oddjs/odd/components/depot/implementation"
77-import * as DID from "@oddjs/odd/did/index"
88-import * as Fission from "@oddjs/odd/common/fission"
99-import * as Path from "@oddjs/odd/path/index"
1010-import * as TypeChecks from "@oddjs/odd/common/type-checks"
1111-import * as Ucan from "@oddjs/odd/ucan/index"
1212-1313-import { Implementation, RequestOptions } from "@oddjs/odd/components/capabilities/implementation"
1414-import { Maybe } from "@oddjs/odd/common/types"
1515-import { VERSION } from "@oddjs/odd/common/version"
1616-1717-1818-// 🧩
1919-2020-2121-export type Dependencies = {
2222- crypto: Crypto.Implementation
2323- depot: Depot.Implementation
2424-}
2525-2626-2727-2828-// 🛠
2929-3030-3131-export async function collect(
3232- endpoints: Fission.Endpoints,
3333- dependencies: Dependencies
3434-): Promise<Maybe<Capabilities.Capabilities>> {
3535- const url = new URL(self.location.href)
3636- const username = url.searchParams.get("username") ?? ""
3737- if (!username) return null
3838-3939- const info = await retry(
4040- () => getClassifiedViaPostMessage(endpoints, dependencies.crypto),
4141- {
4242- tries: 20,
4343- timeout: 60000,
4444- timeoutMessage: "Trying to retrieve UCAN(s) and readKey(s) from the auth lobby timed out after 60 seconds."
4545- }
4646- )
4747-4848- const secrets = await translateClassifiedInfo(dependencies, info)
4949-5050- if (!secrets) {
5151- throw new Error("Failed to retrieve secrets from lobby url parameters")
5252- }
5353-5454- url.searchParams.delete("authorised")
5555- url.searchParams.delete("cancelled")
5656- url.searchParams.delete("newUser")
5757- url.searchParams.delete("username")
5858-5959- history.replaceState(null, document.title, url.toString())
6060-6161- return { ...secrets, username }
6262-}
6363-6464-6565-/**
6666- * Redirects to a lobby.
6767- *
6868- * NOTE: Only works on the main thread, as it uses `window.location`.
6969- */
7070-export async function request(
7171- endpoints: Fission.Endpoints,
7272- dependencies: Dependencies,
7373- options: RequestOptions = {}
7474-): Promise<void> {
7575- const { permissions } = options
7676-7777- const app = permissions?.app
7878- const fs = permissions?.fs
7979- const platform = permissions?.platform
8080- const raw = permissions?.raw
8181- const sharing = permissions?.sharing
8282-8383- const exchangeDid = await DID.exchange(dependencies.crypto)
8484- const writeDid = await DID.write(dependencies.crypto)
8585- const sharedRepo = false
8686- const redirectTo = options.returnUrl || window.location.href
8787-8888- // Compile params
8989- const params = [
9090- ["didExchange", exchangeDid],
9191- ["didWrite", writeDid],
9292- ["redirectTo", redirectTo],
9393- ["sdk", VERSION.toString()],
9494- ["sharedRepo", sharedRepo ? "t" : "f"],
9595- ["sharing", sharing ? "t" : "f"]
9696-9797- ].concat(
9898- app ? [["appFolder", `${app.creator}/${app.name}`]] : [],
9999- fs?.private ? fs.private.map(p => ["privatePath", Path.toPosix(p, { absolute: true })]) : [],
100100- fs?.public ? fs.public.map(p => ["publicPath", Path.toPosix(p, { absolute: true })]) : [],
101101- raw ? [["raw", Base64.urlEncode(JSON.stringify(raw))]] : [],
102102- options.extraParams ? Object.entries(options.extraParams) : []
103103-104104- ).concat((() => {
105105- const apps = platform?.apps
106106-107107- switch (typeof apps) {
108108- case "string": return [["app", apps]]
109109- case "object": return apps.map(a => ["app", a])
110110- default: return []
111111- }
112112-113113- })())
114114-115115- // And, go!
116116- window.location.href = endpoints.lobby + "?" +
117117- params
118118- .map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(v))
119119- .join("&")
120120-}
121121-122122-123123-124124-// COLLECTION HELPERS
125125-126126-127127-type LobbyClassifiedInfo = {
128128- sessionKey: string
129129- secrets: string
130130- iv: string
131131-}
132132-133133-type LobbySecrets = {
134134- fs: Record<string, { key: string; bareNameFilter: string }>
135135- ucans: string[]
136136-}
137137-138138-async function getClassifiedViaPostMessage(
139139- endpoints: Fission.Endpoints,
140140- crypto: Crypto.Implementation
141141-): Promise<LobbyClassifiedInfo> {
142142- const didExchange = await DID.exchange(crypto)
143143- const iframe: HTMLIFrameElement = await new Promise(resolve => {
144144- const iframe = document.createElement("iframe")
145145- iframe.id = "odd-secret-exchange"
146146- iframe.style.width = "0"
147147- iframe.style.height = "0"
148148- iframe.style.border = "none"
149149- iframe.style.display = "none"
150150- document.body.appendChild(iframe)
151151-152152- iframe.onload = () => {
153153- resolve(iframe)
154154- }
155155-156156- iframe.src = `${endpoints.lobby}/exchange.html`
157157- })
158158-159159- return new Promise((resolve, reject) => {
160160- function stop() {
161161- globalThis.removeEventListener("message", listen)
162162- document.body.removeChild(iframe)
163163- reject()
164164- }
165165-166166- function listen(event: MessageEvent<string>) {
167167- if (new URL(event.origin).host !== new URL(endpoints.lobby).host) return stop()
168168- if (event.data == null) return stop()
169169-170170- let classifiedInfo
171171-172172- try {
173173- classifiedInfo = JSON.parse(event.data)
174174- } catch {
175175- stop()
176176- }
177177-178178- if (!isLobbyClassifiedInfo(classifiedInfo)) stop()
179179- globalThis.removeEventListener("message", listen)
180180-181181- try {
182182- document.body.removeChild(iframe)
183183- } catch {
184184- resolve(classifiedInfo)
185185- }
186186- }
187187-188188- globalThis.addEventListener("message", listen)
189189-190190- if (iframe.contentWindow == null) {
191191- throw new Error("Can't import UCANs & readKey(s): No access to its contentWindow")
192192- }
193193-194194- const message = {
195195- webnative: "exchange-secrets",
196196- didExchange
197197- }
198198-199199- iframe.contentWindow.postMessage(message, iframe.src)
200200- })
201201-}
202202-203203-function isLobbyClassifiedInfo(obj: unknown): obj is LobbyClassifiedInfo {
204204- return TypeChecks.isObject(obj)
205205- && TypeChecks.isString(obj.sessionKey)
206206- && TypeChecks.isString(obj.secrets)
207207- && TypeChecks.isString(obj.iv)
208208-}
209209-210210-function isLobbySecrets(obj: unknown): obj is LobbySecrets {
211211- return TypeChecks.isObject(obj)
212212- && TypeChecks.isObject(obj.fs)
213213- && Object.values(obj.fs).every(a => TypeChecks.hasProp(a, "key") && TypeChecks.hasProp(a, "bareNameFilter"))
214214- && Array.isArray(obj.ucans)
215215- && obj.ucans.every(a => TypeChecks.isString(a))
216216-}
217217-218218-async function translateClassifiedInfo(
219219- { crypto }: Dependencies,
220220- classifiedInfo: LobbyClassifiedInfo
221221-): Promise<{ fileSystemSecrets: Capabilities.FileSystemSecret[]; ucans: Ucan.Ucan[] }> {
222222- // Extract session key
223223- const rawSessionKey = await crypto.keystore.decrypt(
224224- Uint8arrays.fromString(classifiedInfo.sessionKey, "base64pad")
225225- )
226226-227227- // The encrypted session key and read keys can be encoded in both UTF-16 and UTF-8.
228228- // This is because keystore-idb uses UTF-16 by default, and that's what the ODD SDK used before.
229229- // ---
230230- // This easy way of detection works because the decrypted session key is encoded in base 64.
231231- // That means it'll only ever use the first byte to encode it, and if it were UTF-16 it would
232232- // split up the two bytes. Hence we check for the second byte here.
233233- const isUtf16 = rawSessionKey[1] === 0
234234-235235- const sessionKey = isUtf16
236236- ? Uint8arrays.fromString(
237237- new TextDecoder("utf-16").decode(rawSessionKey),
238238- "base64pad"
239239- )
240240- : rawSessionKey
241241-242242- // Decrypt secrets
243243- const secretsStr = await crypto.aes.decrypt(
244244- Uint8arrays.fromString(classifiedInfo.secrets, "base64pad"),
245245- sessionKey,
246246- Crypto.SymmAlg.AES_GCM,
247247- Uint8arrays.fromString(classifiedInfo.iv, "base64pad")
248248- )
249249-250250- const secrets: unknown = JSON.parse(
251251- Uint8arrays.toString(secretsStr, "utf8")
252252- )
253253-254254- if (!isLobbySecrets(secrets)) throw new Error("Invalid secrets received")
255255-256256- const fileSystemSecrets: Capabilities.FileSystemSecret[] =
257257- isLobbySecrets(secrets)
258258- ? Object
259259- .entries(secrets.fs)
260260- .map(([posixPath, { bareNameFilter, key }]) => {
261261- return {
262262- bareNameFilter: bareNameFilter,
263263- path: Path.fromPosix(posixPath),
264264- readKey: Uint8arrays.fromString(key, "base64pad")
265265- }
266266- })
267267- : []
268268-269269- const ucans: Ucan.Ucan[] = secrets.ucans.map(
270270- (u: string) => Ucan.decode(u)
271271- )
272272-273273- return {
274274- fileSystemSecrets,
275275- ucans,
276276- }
277277-}
278278-279279-280280-281281-// HELPERS
282282-283283-284284-async function retry<T>(
285285- action: () => Promise<T>,
286286- options: { tries: number; timeout: number; timeoutMessage: string }
287287-): Promise<T> {
288288- return new Promise((resolve, reject) => {
289289- if (options.tries > 0) {
290290- const unoMas = () => {
291291- retry(action, { ...options, tries: options.tries - 1 })
292292- }
293293-294294- const timeoutId = setTimeout(unoMas, options.timeout)
295295-296296- action()
297297- .then(resolve, unoMas)
298298- .finally(() => clearTimeout(timeoutId))
299299-300300- } else {
301301- reject(new Error(options.timeoutMessage))
302302-303303- }
304304- })
305305-}
306306-307307-308308-309309-// 🛳
310310-311311-312312-export function implementation(
313313- dependencies: Dependencies
314314-): Implementation {
315315- const endpoints = Fission.PRODUCTION
316316-317317- return {
318318- collect: () => collect(endpoints, dependencies),
319319- request: (...args) => request(endpoints, dependencies, ...args)
320320- }
321321-}
-2
src/Javascript/UI/index.ts
···1616import * as Misc from "./misc"
1717import * as ServiceWorker from "./service-worker"
1818import * as Tracks from "./tracks"
1919-import * as UserLayer from "./user-layer"
201921202221···5453 Backdrop.init(app)
5554 Misc.init(app)
5655 Tracks.init(app)
5757- UserLayer.init(app)
5856 })
5957 .catch(
6058 Errors.failure
-76
src/Javascript/UI/user-layer.ts
···11-import type { Program as OddProgram } from "@oddjs/odd"
22-import type { App } from "./elm/types.js"
33-44-import { ODD_CONFIG } from "../common"
55-66-77-// 🏔️
88-99-1010-let app: App
1111-let odd
1212-1313-1414-1515-// 🚀
1616-1717-1818-export function init(a: App) {
1919- app = a
2020-2121- app.ports.authenticateWithFission.subscribe(async () => {
2222- const program = await oddProgram()
2323- await program.capabilities.request({
2424- returnUrl: location.origin + "?action=authenticate/fission"
2525- })
2626- })
2727-2828- app.ports.collectFissionCapabilities.subscribe(() => {
2929- // The ODD SDK should collect the capabilities for us,
3030- // if everything is valid, we'll receive a session.
3131- oddProgram().then(
3232- () => {
3333- history.replaceState({}, "", location.origin)
3434- app.ports.collectedFissionCapabilities.send(null)
3535- }
3636- ).catch(
3737- err => console.error(err)
3838- )
3939- })
4040-}
4141-4242-4343-4444-// Fission ~ ODD
4545-// -------------
4646-4747-4848-async function oddProgram(): Promise<OddProgram> {
4949- try {
5050- await loadOdd()
5151- } catch (err) {
5252- console.trace(err)
5353- throw new Error("Failed to load the ODD SDK")
5454- }
5555-5656- const capComponent = await import("../Odd/components/capabilities.js")
5757-5858- const crypto = await odd.defaultCryptoComponent(ODD_CONFIG)
5959- const storage = await odd.defaultStorageComponent(ODD_CONFIG)
6060- const depot = await odd.defaultDepotComponent({ storage }, ODD_CONFIG)
6161-6262- return odd.program({
6363- ...ODD_CONFIG,
6464- capabilities: capComponent.implementation({
6565- crypto,
6666- depot
6767- }),
6868- fileSystem: { loadImmediately: false }
6969- })
7070-}
7171-7272-7373-async function loadOdd() {
7474- if (odd) return
7575- odd = await import("@oddjs/odd")
7676-}
···27272828### User layer
29293030-This layer will use a single service on which to store your data. Your data being your settings, favourites, playlists, etc. You can choose between these services:
3030+This (optional) layer will use a single service on which to store your data externally. Your data being your settings, favourites, playlists, etc. You can choose between these services:
31313232- [Dropbox](https://www.dropbox.com/)
3333-- [Fission](https://fission.codes/)
3434-- [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) <small>(Browser)</small>
3533- [IPFS](https://ipfs.io/) <small>(using MFS)</small>
3634- [RemoteStorage](https://remotestorage.io/)
3735