diff --git a/package-lock.json b/package-lock.json index 7bc19cd..f1556fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@reduxjs/toolkit": "2.2.1", "axios": "1.6.7", "crypto-hash": "3.0.0", + "crypto-js": "^4.2.0", "file-saver": "2.0.5", "jszip": "3.10.1", "lodash": "4.17.21", @@ -31,6 +32,7 @@ "tseep": "1.2.1" }, "devDependencies": { + "@types/crypto-js": "^4.2.2", "@types/file-saver": "2.0.7", "@types/lodash": "4.14.202", "@types/react": "^18.2.56", @@ -1938,6 +1940,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -2630,6 +2638,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", diff --git a/package.json b/package.json index fb823c4..20655c0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@reduxjs/toolkit": "2.2.1", "axios": "1.6.7", "crypto-hash": "3.0.0", + "crypto-js": "^4.2.0", "file-saver": "2.0.5", "jszip": "3.10.1", "lodash": "4.17.21", @@ -34,6 +35,7 @@ "tseep": "1.2.1" }, "devDependencies": { + "@types/crypto-js": "^4.2.2", "@types/file-saver": "2.0.7", "@types/lodash": "4.14.202", "@types/react": "^18.2.56", diff --git a/src/controllers/ApiController.ts b/src/controllers/ApiController.ts new file mode 100644 index 0000000..83fd0c7 --- /dev/null +++ b/src/controllers/ApiController.ts @@ -0,0 +1,80 @@ +import axios, { AxiosError, AxiosInstance } from "axios" +import { PostMediaResponse } from "../types/media" +import sha256 from 'crypto-js/sha256' +import Hex from 'crypto-js/enc-hex' +import { NostrController } from "." + +export class ApiController { + private mediaUrl: string = 'https://media.nquiz.io' + private api: AxiosInstance + private baseUrl: string = '' + + private nostrController = NostrController.getInstance() + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl ?? this.baseUrl + + this.api = axios.create({ + withCredentials: true, + baseURL: this.baseUrl, + headers: { + 'Content-Type': 'application/json' + } + }) + } + + uploadMediaImage = async ( + file: Buffer, + ): Promise => { + const mediaUrl = this.mediaUrl + + const formData = new FormData() + const imageBlob = new Blob([file]) + formData.append('mediafile', imageBlob) + + const endpointUrl = 'v1/media' + const tags = [ + ['u', `${mediaUrl}/${endpointUrl}`.replace('https', 'http')], //nostrcheck api, gets http protocol because it is inside a docker container in localhost + ['method', 'POST'], + ['payload', this.hashString('{}')] + ] + + const authToken = await this.nostrController.createNostrHttpAuthToken(tags) + + console.info('Auth token created for media server.', authToken) + + return axios + .post(`${mediaUrl}/api/v1/media`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: `Nostr ${authToken}` + } + }) + .then((res) => { + console.info( + `POST ${mediaUrl}/api/v1/media | res.data:`, + res.data + ) + // Create correct api url, because res.url is not including 'api/v1/' + if (!res || !res.data) { + console.warn('Image upload: no data returned after upload') + } + + const data = res.data + data.directLink = `${data.url.replace(mediaUrl, `${mediaUrl}/api/v1`)}` + + return data + }) + .catch((err: AxiosError) => { + console.info(`POST ${mediaUrl}/api/v1/media | err:`, err) + throw { + code: 500, + message: err.response?.data || err.response || err.message || err + } + }) + } + + hashString = (payload: any): string => { + return sha256(payload).toString(Hex) + } +} \ No newline at end of file diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index a2b0f1e..5016ac8 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -359,4 +359,39 @@ export class NostrController extends EventEmitter { generateDelegatedKey = (): string => { return NDKPrivateKeySigner.generate().privateKey! } + + /** + * Creates Nostr HTTP Auth token + * @param npub npub in hex format + * @param nostrTags tags to be included in the authevent (auth token) + */ +createNostrHttpAuthToken = async ( + nostrTags: string[][] = [] +): Promise => { + const createdAt = Math.round(Date.now() / 1000) + + let authEvent = new NDKEvent(undefined) + authEvent.kind = 27235 + authEvent.tags = nostrTags + authEvent.content = `sigit-${createdAt}` + authEvent.created_at = createdAt + + await this.signEvent(authEvent.rawEvent() as UnsignedEvent) + + console.info('Signed auth event') + + const base64Encoded = this.base64EncodeSignedEvent(authEvent.rawEvent()) + + return base64Encoded +} + +base64EncodeSignedEvent = (event: NostrEvent) => { + try { + const authEventSerialized = JSON.stringify(event) + const token = btoa(authEventSerialized) + return token + } catch (error) { + throw new Error('An error occurred in JSON.stringy of signedAuthEvent') + } +} } diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index e3cb803..bd64e06 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,5 +1,5 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy' -import { List, ListItem, ListSubheader, TextField } from '@mui/material' +import { CircularProgress, List, ListItem, ListSubheader, TextField } from '@mui/material' import { UnsignedEvent, nip19, kinds, VerifiedEvent } from 'nostr-tools' import { useEffect, useMemo, useState } from 'react' import { useParams } from 'react-router-dom' @@ -15,6 +15,8 @@ import { Dispatch } from '../../store/store' import { setMetadataEvent } from '../../store/actions' import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoginMethods } from '../../store/auth/types' +import { ApiController } from '../../controllers/ApiController' +import { PostMediaResponse } from '../../types/media' export const ProfilePage = () => { const { npub } = useParams() @@ -27,9 +29,11 @@ export const ProfilePage = () => { const [pubkey, setPubkey] = useState() const [profileMetadata, setProfileMetadata] = useState() const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) + const [avatarLoading, setAvatarLoading] = useState(false) const metadataState = useSelector((state: State) => state.metadata) const keys = useSelector((state: State) => state.auth?.keyPair) const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth) + const [uploadingImage, setUploadingImage] = useState() const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) @@ -55,6 +59,10 @@ export const ProfilePage = () => { metadataState as VerifiedEvent ) if (metadataContent) { + if (!metadataContent.lud16 || metadataContent.lud16.length < 1) { + metadataContent.lud16 = getLud16() + } + setProfileMetadata(metadataContent) setIsLoading(false) } @@ -74,7 +82,13 @@ export const ProfilePage = () => { if (metadataEvent) { const metadataContent = metadataController.extractProfileMetadataContent(metadataEvent) - if (metadataContent) setProfileMetadata(metadataContent) + if (metadataContent) { + if (!metadataContent.lud16 || metadataContent.lud16.length < 1) { + metadataContent.lud16 = getLud16() + } + + setProfileMetadata(metadataContent) + } } setIsLoading(false) @@ -84,6 +98,16 @@ export const ProfilePage = () => { } }, [isUsersOwnProfile, metadataState, pubkey, metadataController]) + const getLud16 = () => { + let lud16 = '' + + if (npub) { + if (npub) lud16 = `${npub}@npub.cash` + } + + return lud16 + } + const editItem = ( key: keyof ProfileMetadata, label: string, @@ -178,6 +202,59 @@ export const ProfilePage = () => { setSavingProfileMetadata(false) } + const onProfileImageChange = (event: any) => { + const file = event?.target?.files[0] + + if (file) { + setUploadingImage(true) + + const apiController = new ApiController() + + apiController.uploadMediaImage(file).then((imageResponse: PostMediaResponse) => { + if (imageResponse && imageResponse.directLink) { + setTimeout(() => { + setProfileMetadata((prev) => ({ + ...prev, + picture: imageResponse.directLink + })) + }, 200) + } else { + toast.error(`Uploading image failed. Please try again.`) + } + }) + .catch((err) => { + console.error('Error uploading image to media server.', err) + }) + .finally(() => { + setUploadingImage(false) + }) + } + } + + const progressSpinner = (show = false) => { + if (!show) return undefined + + return + } + + const generateRobotAvatar = () => { + setAvatarLoading(true) + + const robotAvatarLink = `https://robohash.org/${npub}.png?set=set3` + + setProfileMetadata((prev) => ({ + ...prev, + picture: '' + })) + + setTimeout(() => { + setProfileMetadata((prev) => ({ + ...prev, + picture: robotAvatarLink + })) + }) + } + return ( <> {isLoading && } @@ -214,10 +291,27 @@ export const ProfilePage = () => { onError={(event: any) => { event.target.src = placeholderAvatar }} + onLoad={() => { + setAvatarLoading(false) + }} className={styles.img} src={profileMetadata.picture || placeholderAvatar} alt='Profile Image' /> + + Generate Robot Avatar + + {editItem('name', 'Username')} @@ -225,6 +319,7 @@ export const ProfilePage = () => { {editItem('nip05', 'Nostr Address (nip05)')} {editItem('lud16', 'Lightning Address (lud16)')} {editItem('about', 'About', true, 4)} + {editItem('website', 'Website')} {isUsersOwnProfile && ( <> {usersPubkey && diff --git a/src/types/media.ts b/src/types/media.ts new file mode 100644 index 0000000..07e25e7 --- /dev/null +++ b/src/types/media.ts @@ -0,0 +1,19 @@ +export interface PostMediaResponse { + result: boolean + description: string + status: string + id: number + pubkey: string + url: string + directLink: string + hash: string + magnet: string + tags: any[] +} + +export interface GetMediaResponse extends PostMediaResponse {} + +export interface MediaErrorResponse { + description: string + result: boolean +} diff --git a/src/types/profile.ts b/src/types/profile.ts index 46f120f..e65d602 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -3,6 +3,7 @@ export interface ProfileMetadata { display_name?: string picture?: string about?: string + website?: string nip05?: string lud16?: string }