add setting to always send authorization on blossom uploads

This commit is contained in:
hzrd149 2025-05-16 08:23:12 -05:00
parent 37bada4390
commit 37fe7a05f4
7 changed files with 219 additions and 56 deletions

View File

@ -60,7 +60,7 @@
"applesauce-signers": "^1.2.0",
"applesauce-wallet": "^1.0.0",
"bech32": "^2.0.0",
"blossom-client-sdk": "^4.0.0",
"blossom-client-sdk": "next",
"blurhash": "^2.0.5",
"canvas-confetti": "^1.9.3",
"chart.js": "^4.4.9",

10
pnpm-lock.yaml generated
View File

@ -137,8 +137,8 @@ importers:
specifier: ^2.0.0
version: 2.0.0
blossom-client-sdk:
specifier: ^4.0.0
version: 4.0.0
specifier: next
version: 0.0.0-next-20250516125901
blurhash:
specifier: ^2.0.5
version: 2.0.5
@ -2325,8 +2325,8 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
blossom-client-sdk@4.0.0:
resolution: {integrity: sha512-1tjFVwSRPo2fq1HskHzfneWSBa4JmfyveFSiyPas/weyLpMKD7lgN8/F2AxdB9v7TaEFO/xhPwE/KTBGcyofWw==}
blossom-client-sdk@0.0.0-next-20250516125901:
resolution: {integrity: sha512-yd44m5TeET5G1TBCrdphJK5at0xcpXTi8cIGZd538DPKhJcIxC3Te2Jbi2wUP9y68bSkti5+6XhFj3Xc6tVUvQ==}
engines: {node: '>=18'}
blurhash@2.0.5:
@ -8193,7 +8193,7 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
blossom-client-sdk@4.0.0:
blossom-client-sdk@0.0.0-next-20250516125901:
dependencies:
'@cashu/cashu-ts': 2.5.2
'@noble/hashes': 1.8.0

View File

@ -1,5 +1,6 @@
import { BlobDescriptor, createUploadAuth, ServerType, Signer } from "blossom-client-sdk";
import { multiServerUpload, MultiServerUploadOptions } from "blossom-client-sdk/actions/multi-server";
import localSettings from "../../services/local-settings";
export async function simpleMultiServerUpload<T extends ServerType = ServerType>(
servers: T[],
@ -7,10 +8,14 @@ export async function simpleMultiServerUpload<T extends ServerType = ServerType>
signer: Signer,
opts?: MultiServerUploadOptions<T, File>,
): Promise<BlobDescriptor> {
const isMedia = file.type.startsWith("image/") || file.type.startsWith("video/");
const auth = localSettings.alwaysAuthUpload.value || undefined;
const results = await multiServerUpload(servers, file, {
isMedia: file.type.startsWith("image/") || file.type.startsWith("video/"),
isMedia,
mediaUploadBehavior: "any",
mediaUploadFallback: true,
auth,
...opts,
onAuth: (_server, blob, type) => createUploadAuth(signer, blob, { type }),
});

View File

@ -52,6 +52,7 @@ const enableKeyboardShortcuts = new BooleanLocalStorageEntry("enable-keyboard-sh
// privacy
const debugApi = new BooleanLocalStorageEntry("debug-api", false);
const alwaysAuthUpload = new BooleanLocalStorageEntry("always-auth-upload", true);
// relay authentication
const defaultAuthenticationMode = new LocalStorageEntry<RelayAuthMode>("default-authentication-mode", "ask");
@ -90,6 +91,7 @@ const localSettings = {
ntfyTopic,
ntfyServer,
cacheRelayURL,
alwaysAuthUpload,
};
if (import.meta.env.DEV) {

View File

@ -1,15 +1,31 @@
import { getProfilePointersFromList } from "applesauce-core/helpers";
import { getProfilePointersFromList, mergeRelaySets } from "applesauce-core/helpers";
import { SerializedSocialGraph, SocialGraph } from "nostr-social-graph";
import { kinds } from "nostr-tools";
import { Filter, NostrEvent, kinds as nostrKinds } from "nostr-tools";
import { ProfilePointer } from "nostr-tools/nip19";
import { distinct, filter, mergeMap, share, startWith, Subject, take, tap } from "rxjs";
import {
bufferTime,
combineLatest,
distinct,
filter,
ignoreElements,
merge,
mergeMap,
Observable,
of,
scan,
startWith,
Subject,
Subscription,
take,
tap,
} from "rxjs";
import { SOCIAL_GRAPH_FALLBACK_PUBKEY } from "../const";
import { logger } from "../helpers/debug";
import accounts from "./accounts";
import idbKeyValueStore from "./database/kv";
import { eventStore } from "./event-store";
import replaceableEventLoader from "./replaceable-loader";
import { logger } from "../helpers/debug";
const log = logger.extend("SocialGraph");
const cacheKey = "social-graph";
@ -28,38 +44,161 @@ export async function saveSocialGraph() {
await idbKeyValueStore.setItem(cacheKey, socialGraph.serialize());
}
export function crawlFollowGraph(root: string | ProfilePointer, maxDistance: number = 2) {
log(`Started crawling follow graph`);
const loadUser = (user: ProfilePointer) => {
const pubkey = typeof user === "string" ? user : user.pubkey;
/** Make a request to load a list of users in the social graph and return an observable that completes with finished loading */
type GraphLoaderRequest = (pointers: ProfilePointer) => Observable<NostrEvent>;
replaceableEventLoader.next({ kind: kinds.Contacts, ...user });
return eventStore.filters({ kinds: [kinds.Contacts], authors: [pubkey] });
export function createBatchUserLoader(
request: (relays: string[], filters: Filter[]) => Observable<NostrEvent>,
kinds: number[] = [nostrKinds.Contacts, nostrKinds.Metadata, nostrKinds.Mutelist],
): GraphLoaderRequest {
const input = new Subject<ProfilePointer>();
const output = input.pipe(
// skip duplicates
distinct((user) => user.pubkey),
// buffer for 1 second
bufferTime(1_000),
// merge users into a single request
mergeMap((users) => {
const filter: Filter = { kinds, authors: users.map((u) => u.pubkey) };
const relays = mergeRelaySets(...users.map((u) => u.relays));
return request(relays, [filter]);
}),
);
return (user) => {
input.next(user);
return output;
};
}
const queue = new Subject<[ProfilePointer, number]>();
export function graphLoader(root: string | ProfilePointer, maxDistance: number = 2, request: GraphLoaderRequest) {
return new Observable<{ total: number; loaded: number }>((observer) => {
// Keep track of all loaded and discovered users
const loaded = new Set<string>();
const found = new Set<string>();
return queue.pipe(
// Queue of users to load
const queue: [ProfilePointer, number][] = [];
// Add root to queue
queue.push([typeof root === "string" ? { pubkey: root } : root, 0]);
// Keep track of all subscriptions
const subscriptions: Subscription[] = [];
// Start loading users
while (queue.length > 0) {
const [pointer, distance] = queue.shift()!;
if (distance > maxDistance) continue;
// Don't load the same user twice
if (loaded.has(pointer.pubkey)) continue;
loaded.add(pointer.pubkey);
found.add(pointer.pubkey);
// Update progress
observer.next({ total: found.size, loaded: loaded.size });
// Load the user
const sub = request(pointer)
.pipe(
filter((e) => e.kind === nostrKinds.Contacts && e.pubkey === pointer.pubkey),
take(1),
)
.subscribe((event) => {
// Temp: add events to the store
eventStore.add(event);
const contacts = getProfilePointersFromList(event);
log(`Loaded contacts for ${pointer.pubkey}`, contacts);
if (distance < maxDistance) {
for (const pointer of contacts) {
if (found.has(pointer.pubkey)) continue;
found.add(pointer.pubkey);
// Add the user to the queue to be loaded
queue.push([pointer, distance + 1]);
}
}
// Update progress
observer.next({ total: found.size, loaded: loaded.size });
});
subscriptions.push(sub);
}
// complete the loader when queue is empty
// observer.complete();
// return cleanup
return () => {
for (const subscription of subscriptions) subscription.unsubscribe();
};
});
}
export function crawlFollowGraph(
root: string | ProfilePointer,
maxDistance: number = 2,
): Observable<{ total: number; loaded: number }> {
log(`Started crawling follow graph`);
const loaded = new Subject<NostrEvent>();
const input = new Subject<[ProfilePointer, number]>();
const queue = input.pipe(
// Don't load users who are too far
filter(([_user, distance]) => distance <= maxDistance),
// only load users once
distinct(([user]) => user.pubkey),
// Start with the root user
startWith([typeof root === "string" ? { pubkey: root } : root, 0] as const),
// create new observable to load each user
mergeMap(([user, distance]) =>
loadUser(user).pipe(
take(1),
tap((event) => {
const contacts = getProfilePointersFromList(event);
// Add the user to the queue to be loaded
for (const pointer of contacts) queue.next([pointer, distance + 1]);
}),
),
),
// only create a single loader
share(),
);
// Create observable for progress
const progress = combineLatest({
total: queue.pipe(scan((acc) => acc + 1, 0)),
loaded: loaded.pipe(scan((acc) => acc + 1, 0)),
});
const loader = queue.pipe(
// create new observable to load each user
mergeMap(([user, distance]) => {
log(`Loading contacts for ${user.pubkey}`);
replaceableEventLoader.next({ kind: nostrKinds.Contacts, ...user });
const event = eventStore.getReplaceable(nostrKinds.Contacts, user.pubkey);
return (
event
? of(event)
: eventStore.inserts.pipe(
// Wait for the contacts event to be loaded
filter((e) => e.kind === nostrKinds.Contacts && e.pubkey === user.pubkey),
// Take the first event
take(1),
)
).pipe(
// Add contacts to the queue
tap((event) => {
loaded.next(event);
const contacts = getProfilePointersFromList(event);
log(`Loaded contacts for ${user.pubkey}`, contacts);
// Add the user to the queue to be loaded
for (const pointer of contacts) input.next([pointer, distance + 1]);
}),
// Ignore loaded events
ignoreElements(),
);
}),
);
return merge(loader, progress);
}
/** Exports the social graph to a file */

View File

@ -30,6 +30,7 @@ export default function PostSettings() {
watch("mediaUploadService");
const addClientTag = useObservable(localSettings.addClientTag);
const alwaysAuthUpload = useObservable(localSettings.alwaysAuthUpload);
return (
<SimpleView
@ -120,11 +121,25 @@ export default function PostSettings() {
<FormControl>
<Flex alignItems="center">
<FormLabel htmlFor="mirrorBlobsOnShare" mb="0">
Always mirror media
Mirror media when sharing
</FormLabel>
<Switch id="mirrorBlobsOnShare" {...register("mirrorBlobsOnShare")} />
</Flex>
<FormHelperText>Copy all media to your personal blossom servers when sharing notes</FormHelperText>
<FormHelperText>Mirror all media to your personal blossom servers when sharing notes</FormHelperText>
</FormControl>
<FormControl>
<Flex alignItems="center">
<FormLabel htmlFor="alwaysAuthUpload" mb="0">
Always authenticate media uploads
</FormLabel>
<Switch
id="alwaysAuthUpload"
isChecked={alwaysAuthUpload}
onChange={() => localSettings.alwaysAuthUpload.next(!localSettings.alwaysAuthUpload.value)}
/>
</Flex>
<FormHelperText>Always authenticate with media servers when uploading media</FormHelperText>
</FormControl>
</SimpleView>
);

View File

@ -10,20 +10,29 @@ import {
NumberInputField,
Text,
} from "@chakra-ui/react";
import { useActiveAccount, useObservable } from "applesauce-react/hooks";
import { SocialGraph } from "nostr-social-graph";
import { useActiveAccount } from "applesauce-react/hooks";
import { useCallback, useEffect, useState } from "react";
import { tap, throttleTime } from "rxjs";
import SimpleView from "../../../components/layout/presets/simple-view";
import UserAvatar from "../../../components/user/user-avatar";
import { UserAvatarLink } from "../../../components/user/user-avatar-link";
import UserDnsIdentity from "../../../components/user/user-dns-identity";
import UserName from "../../../components/user/user-name";
import { crawlFollowGraph, exportGraph, importGraph, socialGraph } from "../../../services/social-graph";
import { useAppTitle } from "../../../hooks/use-app-title";
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
import pool from "../../../services/pool";
import {
createBatchUserLoader,
exportGraph,
graphLoader,
importGraph,
socialGraph,
} from "../../../services/social-graph";
import { useUnmount } from "react-use";
import { Subscription } from "rxjs";
export default function SocialGraphSettings() {
useAppTitle("Social Graph");
const [followDistances, setFollowDistances] = useState<{ distance: number; count: number; randomUsers: string[] }[]>(
[],
);
@ -50,28 +59,21 @@ export default function SocialGraphSettings() {
setFollowDistances(generateFollowDistances());
}, [socialGraph, generateFollowDistances]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState<Subscription>();
const handleLoadGraph = () => {
if (account?.pubkey && socialGraph) {
setLoading(true);
// Create and start the loader
crawlFollowGraph(account.pubkey, distance)
.pipe(
// send event to graph
tap((e) => socialGraph.handleEvent(e)),
// Update the follow distance every second while loading
throttleTime(1000),
tap(() => {
socialGraph.recalculateFollowDistances();
setFollowDistances(generateFollowDistances());
setLoading(
graphLoader(account.pubkey, distance, createBatchUserLoader(pool.request.bind(pool))).subscribe({
next: (progress) => {
console.log(progress);
},
}),
)
.subscribe({
complete: () => setLoading(false),
});
);
}
};
useUnmount(() => {
loading?.unsubscribe();
});
const displayMaxPeople = useBreakpointValue({ base: 4, lg: 5, xl: 10 }) || 4;