diff --git a/.env b/.env index 879765af..453005fa 100644 --- a/.env +++ b/.env @@ -2,6 +2,7 @@ VITE_THEME=transparent:transparent,black:#0f0f0e,white:#FFFFFF,accent:#EB5E28,ac VITE_DVM_RELAYS=wss://relay.damus.io,wss://relay.snort.social,wss://bucket.coracle.social,wss://offchain.pub VITE_DEFAULT_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://relay.nostr.band,wss://relayable.org,wss://nostr.wine VITE_DEFAULT_FOLLOWS=1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef,fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52,cc8d072efdcc676fcbac14f6cd6825edc3576e55eb786a2a975ee034a6a026cb,d91191e30e00444b942c0e82cad470b32af171764c2275bee0bd99377efd4075,3335d373e6c1b5bc669b4b1220c08728ea8ce622e5a7cfeeb4c0001d91ded1de,0b118e40d6f3dfabb17f21a94a647701f140d8b063a9e84fe6e483644edc09cb,b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450,958b754a1d3de5b5eca0fe31d2d555f451325f8498a83da1997b7fcd5c39e88c,b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc,b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e,5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78,baf27a4cc4da49913e7fdecc951fd3b971c9279959af62b02b761a043c33384c,2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884,0fecf65daa26faf3f668e8143325a4c199a040b6345ed40a08614d7dd85b1823,1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411,f783ba3b12b91e375aba6594015b90bd95f7e132b03cc8c4c52ce0a7c36aab52,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,a1fc5dfd7ffcf563c89155b466751b580d115e136e2f8c90e8913385bbedb1cf,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240 +VITE_NIP96_URLS=https://nostrcheck.me,https://nostr.build,https://sove.rent,https://void.cat VITE_IMGPROXY_URL=https://imgproxy.coracle.social VITE_DUFFLEPUD_URL=https://dufflepud.onrender.com VITE_ENABLE_GROUPS=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 074f4313..266b0cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ - [x] Update lists to use new 30003 user bookmarks kind - [x] Add NIP 72 community support - [x] Add NIP 87 closed community support +- [x] Add NIP 96 file storage +- [x] Add NIP 98 auth support +- [x] Add DIP 01 imeta tag creation - [x] Re-work keys page, include group keys - [x] Add anonymous posting - [x] Add note options dialog to replies @@ -13,6 +16,7 @@ - [x] Fix throttled notifications derive loop - [x] Conservatively load from cache when on a slow network - [x] Add refresh button to feeds +- [x] Add image previews to note reply # 0.3.12 diff --git a/README.md b/README.md index 4240f41f..443e07a8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ If you like Coracle and want to support its development, you can donate sats via - [x] White-labeling support - [x] NIP 51 person lists - [x] Exportable copy of all user events +- [x] NIP96 - HTTP File Storage Integration - [ ] Reporting and basic distributed moderation - [ ] Content and person recommendations - [ ] Private groups including administration, moderation, and membership diff --git a/package.json b/package.json index 428164d3..e40a71aa 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "normalize-url": "^8.0.0", "nostr-tools": "^1.12.1", "npm-run-all": "^4.1.5", - "paravel": "^0.4.9", + "paravel": "^0.4.10", "qr-scanner": "^1.4.2", "qrcode": "^1.5.1", "ramda": "^0.29.1", diff --git a/src/app/shared/Compose.svelte b/src/app/shared/Compose.svelte index c2b5bbc1..54cb8367 100644 --- a/src/app/shared/Compose.svelte +++ b/src/app/shared/Compose.svelte @@ -220,7 +220,7 @@ const selection = window.getSelection() const textNode = document.createTextNode(text) const target = (last(input.childNodes) || input) as unknown as Node - const offset = target instanceof Text ? target.wholeText.length : target.childNodes.length + const offset = target instanceof Text ? target.textContent.length : target.childNodes.length selection.collapse(target, offset) selection.getRangeAt(0).insertNode(textNode) diff --git a/src/app/shared/NoteContentKind1808.svelte b/src/app/shared/NoteContentKind1808.svelte index ca781c22..925f0192 100644 --- a/src/app/shared/NoteContentKind1808.svelte +++ b/src/app/shared/NoteContentKind1808.svelte @@ -1,6 +1,5 @@ + +
+ {#each value as imeta} +
+ removeImage(imeta)} /> +
+ {/each} +
diff --git a/src/app/shared/NoteReply.svelte b/src/app/shared/NoteReply.svelte index 469db284..0c5b9894 100644 --- a/src/app/shared/NoteReply.svelte +++ b/src/app/shared/NoteReply.svelte @@ -1,14 +1,14 @@ - + diff --git a/src/app/views/NoteCreate.svelte b/src/app/views/NoteCreate.svelte index 79f2a0a1..f8202f5c 100644 --- a/src/app/views/NoteCreate.svelte +++ b/src/app/views/NoteCreate.svelte @@ -1,19 +1,18 @@ @@ -48,6 +53,21 @@ Allows {appName} to authenticate with relays that have access controls automatically.

+ +

+ Enter one or more urls for nostr media servers. You can find a full list of NIP-96 + compatible servers + here +

+ +
+ {item} +
+
+
diff --git a/src/engine/auth/commands.ts b/src/engine/auth/commands.ts new file mode 100644 index 00000000..9926656a --- /dev/null +++ b/src/engine/auth/commands.ts @@ -0,0 +1,28 @@ +import crypto from "crypto" +import {Fetch} from "hurdak" +import {createEvent} from "paravel" +import {generatePrivateKey} from "nostr-tools" +import {signer} from "src/engine/session/derived" + +export const nip98Fetch = async (url, method, body = null) => { + const tags = [ + ["u", url], + ["method", method], + ] + + if (body) { + tags.push(["payload", crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex")]) + } + + const template = createEvent(27235, {tags}) + const $signer = signer.get() + + const event = $signer.canSign() + ? await $signer.signAsUser(template) + : await $signer.signWithKey(template, generatePrivateKey()) + + const auth = btoa(JSON.stringify(event)) + const headers = {Authorization: `Nostr ${auth}`} + + return Fetch.fetchJson(url, {body, method, headers}) +} diff --git a/src/engine/auth/index.ts b/src/engine/auth/index.ts new file mode 100644 index 00000000..790681f8 --- /dev/null +++ b/src/engine/auth/index.ts @@ -0,0 +1 @@ +export * from "./commands" diff --git a/src/engine/index.ts b/src/engine/index.ts index e44a3c5c..01e4ce24 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -11,6 +11,7 @@ import {pubkey, sessions} from "./session" import {channels} from "./channels" export * from "./core" +export * from "./auth" export * from "./channels" export * from "./events" export * from "./groups" diff --git a/src/engine/media/commands.ts b/src/engine/media/commands.ts index afd5b6dd..27952ca6 100644 --- a/src/engine/media/commands.ts +++ b/src/engine/media/commands.ts @@ -1,27 +1,86 @@ -import {Fetch} from "hurdak" -import {createEvent} from "paravel" -import {generatePrivateKey} from "nostr-tools" -import {signer} from "src/engine/session/derived" +import {prop, nth, uniq, flatten, groupBy} from "ramda" +import {Fetch, tryFunc, sleep} from "hurdak" +import {now, cached, Tags} from "paravel" +import {joinPath} from "src/util/misc" +import type {Event} from "src/engine/events/model" +import {nip98Fetch} from "src/engine/auth/commands" +import {stripExifData, blobToFile} from "src/util/html" -export const uploadToNostrBuild = async body => { - const $signer = signer.get() - const url = "https://nostr.build/api/v2/upload/files" - const template = createEvent(27235, { - tags: [ - ["u", url], - ["method", "POST"], - ], - }) +export const getMediaProviderURL = cached({ + maxSize: 10, + getKey: ([url]) => url, + getValue: ([url]) => fetchMediaProviderURL(url), +}) - const event = await ($signer.canSign() - ? $signer.signAsUser(template) - : $signer.signWithKey(template, generatePrivateKey())) +const fetchMediaProviderURL = async host => + prop("api_url", await Fetch.fetchJson(joinPath(host, ".well-known/nostr/nip96.json"))) - return Fetch.fetchJson(url, { - body, - method: "POST", - headers: { - Authorization: `Nostr ${btoa(JSON.stringify(event))}`, - }, +const fileToFormData = file => { + const formData = new FormData() + + formData.append("file[]", file) + + return formData +} + +export const uploadFileToHost = async (url, file) => { + const startTime = now() + const apiUrl = await getMediaProviderURL(url) + const response = await nip98Fetch(apiUrl, "POST", fileToFormData(file)) + + // If the media provider uses delayed processing, we need to wait for the processing to be done + while (response.processing_url) { + const {status, nip94_event} = await nip98Fetch(response.processing_url, "GET") + + if (status === "success") { + return Tags.from(nip94_event).type("url").values().first() + } + + if (now() - startTime > 60) { + break + } + + await sleep(3000) + } + + return response.nip94_event +} + +export const uploadFilesToHost = (url, files) => + Promise.all(files.map(file => tryFunc(async () => await uploadFileToHost(url, file)))) + +export const uploadFileToHosts = (urls, file) => + Promise.all(urls.map(url => tryFunc(async () => await uploadFileToHost(url, file)))) + +export const uploadFilesToHosts = async (urls, files) => + flatten(await Promise.all(urls.map(url => uploadFilesToHost(url, files)))) + +export const compressFiles = (files, opts) => + Promise.all( + files.map(async f => { + if (f.type.match("image/(webp|gif)")) { + return f + } + + return blobToFile(await stripExifData(f, opts)) + }) + ) + +export const eventsToMeta = (events: Event[]) => { + const tagsByHash = groupBy( + tags => tags.type("ox").values().first(), + events.map(e => Tags.from(e)) + ) + + // Merge all nip94 tags together so we can supply as much imeta as possible + return Object.values(tagsByHash).map(groupedTags => { + return new Tags(uniq(groupedTags.flatMap(tags => tags.filter(nth(1)).all()))) }) } + +export const uploadFiles = async (urls, files, compressorOpts = {}) => { + const compressedFiles = await compressFiles(files, compressorOpts) + const nip94Events = await uploadFilesToHosts(urls, compressedFiles) + + return eventsToMeta(nip94Events) +} diff --git a/src/engine/session/utils/settings.ts b/src/engine/session/utils/settings.ts index 43414a17..8311a9a9 100644 --- a/src/engine/session/utils/settings.ts +++ b/src/engine/session/utils/settings.ts @@ -11,6 +11,7 @@ export const getDefaultSettings = () => ({ auto_authenticate: false, min_wot_score: 1, enable_reactions: true, + nip96_urls: env.get().NIP96_URLS, imgproxy_url: env.get().IMGPROXY_URL, dufflepud_url: env.get().DUFFLEPUD_URL, multiplextr_url: env.get().MULTIPLEXTR_URL, diff --git a/src/main.js b/src/main.js index 98b32055..45b9b337 100644 --- a/src/main.js +++ b/src/main.js @@ -44,6 +44,8 @@ const DUFFLEPUD_URL = import.meta.env.VITE_DUFFLEPUD_URL const MULTIPLEXTR_URL = import.meta.env.VITE_MULTIPLEXTR_URL +const NIP96_URLS = fromCsv(import.meta.env.VITE_NIP96_URLS) + const FORCE_RELAYS = fromCsv(import.meta.env.VITE_FORCE_RELAYS) const DVM_RELAYS = FORCE_RELAYS.length > 0 ? FORCE_RELAYS : fromCsv(import.meta.env.VITE_DVM_RELAYS) @@ -65,6 +67,7 @@ const ENABLE_JUKEBOX = JSON.parse(import.meta.env.VITE_ENABLE_JUKEBOX) // Prep our env env.set({ DEFAULT_FOLLOWS, + NIP96_URLS, IMGPROXY_URL, DUFFLEPUD_URL, MULTIPLEXTR_URL, diff --git a/src/partials/Channel.svelte b/src/partials/Channel.svelte index 3a4c2b1a..4b410114 100644 --- a/src/partials/Channel.svelte +++ b/src/partials/Channel.svelte @@ -45,8 +45,8 @@ } } - const addImage = url => { - textarea.value = (textarea.value + "\n\n" + url).trim() + const addImage = imeta => { + textarea.value += "\n" + imeta.type("url").values().first() } const send = async () => { @@ -125,7 +125,7 @@ class="w-full resize-none bg-gray-6 p-2 text-gray-2 outline-0 placeholder:text-gray-1" />
- + addImage(e.detail)}>