Compare commits

..

2 Commits

Author SHA1 Message Date
b
af036b1bb7 Merge pull request 'feat: configured semantic releases' (#307) from semantic-releases into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 2m4s
Reviewed-on: #307
2025-01-29 13:49:15 +00:00
c0b903929d feat: configured semantic releases
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 1m5s
2025-01-29 16:46:04 +03:00
16 changed files with 9036 additions and 358 deletions

View File

@ -26,9 +26,27 @@ jobs:
- name: Create Build
run: npm run build
- name: Release Build
- name: Deploy Build
run: |
npm -g install cloudron-surfer
surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io
surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io
surfer put dist/* / --all -d
surfer put dist/.well-known / --all
surfer put dist/.well-known / --all
- name: Create Empty Release (assets are posted later)
run: |
npm i
npm i -g semantic-release
# We do a semantic-release DRY RUN to make the job fail if there are no changes to release
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.nostrdev.com/sigit/sigit.io semantic-release --dry-run | grep -q "There are no relevant changes, so no new version is released." && exit 1
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.nostrdev.com/sigit/sigit.io semantic-release
- name: Upload assets to release
run: |
RELEASE_ID=`curl -k 'https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'`
RELEASE_BODY=`curl -k 'https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'`
# Update body
curl --data '{"draft": false,"body":"'"$RELEASE_BODY\n\nFor installation instructions, please visit https://docs.sigit.io/#/"'"}' -X PATCH --header 'Content-Type: application/json' -k https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/$RELEASE_ID?access_token=${{ secrets.RELEASE_TOKEN }}
# Upload assets
URL="https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}"
curl -k $URL -F attachment=@dist.zip

2
.gitignore vendored
View File

@ -8,7 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-zip
dist-ssr
*.local

31
.releaserc Normal file
View File

@ -0,0 +1,31 @@
{
"branches": [
"main"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
[
"@semantic-release/git",
{
"assets": [
"CHANGELOG.md",
"package.json"
]
}
],
[
"@saithodev/semantic-release-gitea",
{
"giteaUrl": "https://git.nostrdev.com/sigit/sigit.io",
"assets": [
{
"path": "dist-zip/dist.zip"
}
]
}
]
]
}

8906
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,8 @@
"preview": "vite preview",
"preinstall": "git config core.hooksPath .git-hooks",
"license-checker": "node licenseChecker.cjs",
"lint-staged": "lint-staged"
"lint-staged": "lint-staged",
"release": "commit-and-tag-version"
},
"dependencies": {
"@emotion/react": "11.11.4",
@ -30,8 +31,8 @@
"@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.11.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.9",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4",
@ -65,6 +66,12 @@
"use-immer": "^0.11.0"
},
"devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^10.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "11.0.0",
"@semantic-release/release-notes-generator": "^11.0.4",
"@types/crypto-js": "^4.2.2",
"@types/file-saver": "2.0.7",
"@types/lodash": "4.14.202",
@ -75,6 +82,7 @@
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
"commit-and-tag-version": "^11.2.2",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
@ -85,6 +93,7 @@
"typescript": "^5.2.2",
"vite": "^5.1.4",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-zip-pack": "^1.2.4",
"vite-tsconfig-paths": "4.3.2"
},
"lint-staged": {

View File

@ -12,9 +12,7 @@ import {
import _ from 'lodash'
import {
Event,
finalizeEvent,
generateSecretKey,
getEventHash,
getPublicKey,
kinds,
UnsignedEvent
@ -42,21 +40,17 @@ import {
getDTagForUserAppData,
getUserAppDataFromBlossom,
hexToNpub,
nip44Encrypt,
parseJson,
randomTimeUpTo2DaysInThePast,
SIGIT_RELAY,
unixNow,
uploadUserAppDataToBlossom
} from '../utils'
import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError'
export const useNDK = () => {
const dispatch = useAppDispatch()
const {
ndk,
fetchEvent,
fetchEventFromUserRelays,
fetchEventsFromUserRelays,
publish,
getNDKRelayList
@ -509,139 +503,10 @@ export const useNDK = () => {
[ndk, usersPubkey, getNDKRelayList]
)
/**
* Modified {@link UnsignedEvent Unsigned Event} that includes an id
*
* Fields id and created_at are required.
* @see {@link UnsignedEvent}
* @see {@link https://github.com/nostr-protocol/nips/blob/master/17.md#direct-message-kind}
*/
type UnsignedEventWithId = UnsignedEvent & {
id?: string
}
const sendPrivateDirectMessage = useCallback(
async (message: string, receiver: string, subject?: string) => {
if (!receiver) throw new SendDMError(SendDMErrorType.MISSING_RECIEVER)
// Get the direct message preferred relays list
// https://github.com/nostr-protocol/nips/blob/master/17.md#publishing
const preferredRelaysListEvent = await fetchEventFromUserRelays(
{
kinds: [NDKKind.DirectMessageReceiveRelayList],
authors: [receiver]
},
receiver,
UserRelaysType.Read
)
const isRelayTag = (tag: string[]): boolean => tag[0] === 'relay'
const finalRelaysList: string[] = []
if (preferredRelaysListEvent) {
const preferredRelaysList = preferredRelaysListEvent.tags
.filter((t) => isRelayTag(t))
.map((t) => t[1])
finalRelaysList.push(...preferredRelaysList)
}
if (!finalRelaysList.length) {
// Get receiver's read relay list
const ndkRelayList = await getNDKRelayList(receiver).catch((err) => {
// Log an error if retrieving relay list metadata fails
console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`,
err
)
return null
})
if (ndkRelayList?.readRelayUrls) {
finalRelaysList.push(...ndkRelayList.readRelayUrls)
}
}
if (!finalRelaysList.length) {
finalRelaysList.push(SIGIT_RELAY)
}
// Generate "sender"
const senderSecret = generateSecretKey()
const senderPubkey = getPublicKey(senderSecret)
// Prepare tags for the message
const tags: string[][] = [['p', receiver]]
// Conversation title
if (subject) tags.push(['subject', subject])
// Create private DM event containing the message and relevant metadata
// TODO: kinds.PrivateDirectMessage (unavailabe in nostr-tools 10/10/2024 at v2.7.0)
const dm: UnsignedEventWithId = {
pubkey: senderPubkey,
created_at: unixNow(),
kind: 14,
tags,
content: message
}
// Calculate the hash based on the UnverifiedEvent
dm.id = getEventHash(dm)
// Encrypt the private dm using the sender secret and the receiver's public key
const encryptedDm = nip44Encrypt(dm, senderSecret, receiver)
if (!encryptedDm) {
throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, {
context: {
receiver,
message,
kind: dm.kind
}
})
}
// Seal the message
// TODO: kinds.Seal (unavailabe in nostr-tools 10/10/2024 at v2.7.0)
const sealedMessage: UnsignedEvent = {
kind: 13, // seal
pubkey: senderPubkey,
content: encryptedDm,
created_at: randomTimeUpTo2DaysInThePast(),
tags: [] // no tags
}
// Finalize and sign the sealed event
const finalizedSeal = finalizeEvent(sealedMessage, senderSecret)
// Encrypt the seal and gift wrap
const finalizedGiftWrap = createWrap(finalizedSeal, receiver)
const ndkEvent = new NDKEvent(ndk, finalizedGiftWrap)
// Publish the finalized gift wrap event (the encrypted DM) to the relays
const publishedOnRelays = await ndkEvent.publish(
NDKRelaySet.fromRelayUrls(finalRelaysList, ndk, true)
)
// Handle cases where publishing to the relays failed
if (publishedOnRelays.size === 0) {
throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, {
context: {
receiver,
count: publishedOnRelays.size
}
})
}
// Return true indicating that the DM was successfully sent
return true
},
[fetchEventFromUserRelays, getNDKRelayList, ndk]
)
return {
getUsersAppData,
subscribeForSigits,
updateUsersAppData,
sendNotification,
sendPrivateDirectMessage
sendNotification
}
}

View File

@ -45,7 +45,6 @@ import {
uploadToFileStorage,
DEFAULT_TOOLBOX,
settleAllFullfilfedPromises,
parseNostrEvent,
uploadMetaToFileStorage
} from '../../utils'
import { Container } from '../../components/Container'
@ -73,7 +72,6 @@ import { getSigitFile, SigitFile } from '../../utils/file.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { Autocomplete } from '@mui/material'
import _, { truncate } from 'lodash'
import { SendDMError } from '../../types/errors/SendDMError.ts'
import * as React from 'react'
import { AvatarIconButton } from '../../components/UserAvatarIconButton'
import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk'
@ -88,8 +86,7 @@ export const CreatePage = () => {
const navigate = useNavigate()
const location = useLocation()
const { findMetadata, fetchEventsFromUserRelays } = useNDKContext()
const { updateUsersAppData, sendNotification, sendPrivateDirectMessage } =
useNDK()
const { updateUsersAppData, sendNotification } = useNDK()
const { uploadedFiles } = location.state || {}
const [currentFile, setCurrentFile] = useState<File>()
@ -929,29 +926,7 @@ export const CreatePage = () => {
toast.error('Failed to publish notifications')
})
const isFirstSigner =
signers.length > 0 && signers[0].pubkey === usersPubkey
// Don't send notification if creator is next signer
if (signers.length > 0 && !isFirstSigner) {
// Send DM to the next signer
setLoadingSpinnerDesc('Sending DMs')
const nextSigner = signers[0].pubkey
const createSignatureEvent = parseNostrEvent(meta.createSignature)
const { id } = createSignatureEvent
try {
await sendPrivateDirectMessage(
`Sigit created, visit ${window.location.origin}/#/sign/${id}`,
npubToHex(nextSigner)!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
}
const isFirstSigner = signers[0].pubkey === usersPubkey
if (isFirstSigner) {
navigate(appPrivateRoutes.sign, { state: { meta } })
} else {

View File

@ -1,3 +1,4 @@
import ClearIcon from '@mui/icons-material/Clear'
import InputIcon from '@mui/icons-material/Input'
import IosShareIcon from '@mui/icons-material/IosShare'
import {
@ -8,12 +9,36 @@ import {
ListSubheader,
useTheme
} from '@mui/material'
import { useState } from 'react'
import { toast } from 'react-toastify'
import { localCache } from '../../../services'
import { LoadingSpinner } from '../../../components/LoadingSpinner'
import { Container } from '../../../components/Container'
import { Footer } from '../../../components/Footer/Footer'
export const CacheSettingsPage = () => {
const theme = useTheme()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const handleClearData = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Clearing cache data')
localCache
.clearCacheData()
.then(() => {
toast.success('cleared cached data')
})
.catch((err) => {
console.log('An error occurred in clearing cache data', err)
toast.error(err.message || 'An error occurred in clearing cache data')
})
.finally(() => {
setIsLoading(false)
})
}
const listItem = (label: string) => {
return (
<ListItemText
@ -28,6 +53,7 @@ export const CacheSettingsPage = () => {
return (
<>
<Container>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<List
sx={{
width: '100%',
@ -61,6 +87,13 @@ export const CacheSettingsPage = () => {
</ListItemIcon>
{listItem('Import (coming soon)')}
</ListItemButton>
<ListItemButton onClick={handleClearData}>
<ListItemIcon>
<ClearIcon sx={{ color: theme.palette.error.main }} />
</ListItemIcon>
{listItem('Clear Cache')}
</ListItemButton>
</List>
</Container>
<Footer />

View File

@ -28,8 +28,7 @@ import {
signEventForMetaFile,
unixNow,
updateMarks,
uploadMetaToFileStorage,
parseNostrEvent
uploadMetaToFileStorage
} from '../../utils'
import { CurrentUserMark, Mark } from '../../types/mark.ts'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
@ -37,14 +36,12 @@ import { convertToSigitFile, SigitFile } from '../../utils/file.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
import { useNDK } from '../../hooks/useNDK.ts'
import { SendDMError } from '../../types/errors/SendDMError.ts'
export const SignPage = () => {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const { updateUsersAppData, sendNotification, sendPrivateDirectMessage } =
useNDK()
const { updateUsersAppData, sendNotification } = useNDK()
const usersAppData = useAppSelector((state) => state.userAppData)
@ -605,65 +602,6 @@ export const SignPage = () => {
toast.error('Failed to publish notifications')
})
// Send DMs
setLoadingSpinnerDesc('Sending DMs')
const createSignatureEvent = parseNostrEvent(meta.createSignature)
const { id } = createSignatureEvent
if (isLastSigner) {
// Final sign sends to everyone (creator, signers, viewers - /verify)
const areSent: boolean[] = Array(users.length).fill(false)
for (let i = 0; i < users.length; i++) {
try {
areSent[i] = await sendPrivateDirectMessage(
`Sigit completed, visit ${window.location.origin}/#/verify/${id}`,
npubToHex(users[i])!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
}
if (areSent.some((r) => r)) {
toast.success(
`DMs sent ${areSent.filter((r) => r).length}/${users.length}`
)
}
} else {
// Notify the creator and the next signer (/sign).
try {
await sendPrivateDirectMessage(
`Sigit signed by ${usersNpub}, visit ${window.location.origin}/#/sign/${id}`,
npubToHex(submittedBy!)!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
// No need to notify creator twice, skipping
const currentSignerIndex = signers.indexOf(usersNpub)
const nextSigner = signers[currentSignerIndex + 1]
if (nextSigner !== submittedBy) {
try {
await sendPrivateDirectMessage(
`You're the next signer, visit ${window.location.origin}/#/sign/${id}`,
npubToHex(nextSigner)!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
}
}
setIsLoading(false)
}

86
src/services/cache/index.ts vendored Normal file
View File

@ -0,0 +1,86 @@
import { IDBPDatabase, openDB } from 'idb'
import { Event } from 'nostr-tools'
import { CachedEvent } from '../../types'
import { SchemaV2 } from './schema'
class LocalCache {
// Static property to hold the single instance of LocalCache
private static instance: LocalCache | null = null
private db!: IDBPDatabase<SchemaV2>
// Private constructor to prevent direct instantiation
private constructor() {}
// Method to initialize the database
private async init() {
this.db = await openDB<SchemaV2>('sigit-cache', 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
db.createObjectStore('userMetadata', { keyPath: 'event.pubkey' })
}
if (oldVersion < 2) {
const v6 = db as unknown as IDBPDatabase<SchemaV2>
v6.createObjectStore('userRelayListMetadata', {
keyPath: 'event.pubkey'
})
}
}
})
}
// Static method to get the single instance of LocalCache
public static async getInstance(): Promise<LocalCache> {
// If the instance doesn't exist, create it
if (!LocalCache.instance) {
LocalCache.instance = new LocalCache()
await LocalCache.instance.init()
}
// Return the single instance of LocalCache
return LocalCache.instance
}
// Method to add user metadata
public async addUserMetadata(event: Event) {
await this.db.put('userMetadata', { event, cachedAt: Date.now() })
}
// Method to get user metadata by key
public async getUserMetadata(key: string): Promise<CachedEvent | null> {
const data = await this.db.get('userMetadata', key)
return data || null
}
// Method to delete user metadata by key
public async deleteUserMetadata(key: string) {
await this.db.delete('userMetadata', key)
}
public async addUserRelayListMetadata(event: Event) {
await this.db.put('userRelayListMetadata', { event, cachedAt: Date.now() })
}
public async getUserRelayListMetadata(
key: string
): Promise<CachedEvent | null> {
const data = await this.db.get('userRelayListMetadata', key)
return data || null
}
public async deleteUserRelayListMetadata(key: string) {
await this.db.delete('userRelayListMetadata', key)
}
// Method to clear cache data
public async clearCacheData() {
// Clear the 'userMetadata' store in the IndexedDB database
await this.db.clear('userMetadata')
// Reload the current page to ensure any cached data is reset
window.location.reload()
}
}
// Export the single instance of LocalCache
export const localCache = await LocalCache.getInstance()

16
src/services/cache/schema.ts vendored Normal file
View File

@ -0,0 +1,16 @@
import { DBSchema } from 'idb'
import { CachedEvent } from '../../types'
export interface SchemaV1 extends DBSchema {
userMetadata: {
key: string
value: CachedEvent
}
}
export interface SchemaV2 extends SchemaV1 {
userRelayListMetadata: {
key: string
value: CachedEvent
}
}

View File

@ -1 +1,2 @@
export * from './cache'
export * from './signer'

View File

@ -1,23 +0,0 @@
import { Jsonable } from '.'
export enum SendDMErrorType {
'MISSING_RECIEVER' = 'Sending DM failed. Reciever is required.',
'ENCRYPTION_FAILED' = 'Sending DM failed. An error occurred in encrypting dm message.',
'RELAY_PUBLISH_FAILED' = 'Sending DM failed. Publishing events failed.'
}
export class SendDMError extends Error {
public readonly context?: Jsonable
constructor(
message: string,
options: { cause?: Error; context?: Jsonable } = {}
) {
const { cause, context } = options
super(message, { cause })
this.name = this.constructor.name
this.context = context
}
}

View File

@ -6,7 +6,6 @@ import {
Event,
EventTemplate,
UnsignedEvent,
VerifiedEvent,
finalizeEvent,
generateSecretKey,
getEventHash,
@ -215,12 +214,6 @@ export const toUnixTimestamp = (date: number | Date) => {
export const fromUnixTimestamp = (unix: number) => {
return unix * 1000
}
export const randomTimeUpTo2DaysInThePast = (): number => {
const now = Date.now()
const twoDaysInMilliseconds = 2 * 24 * 60 * 60 * 1000
const randomPastTime = now - Math.floor(Math.random() * twoDaysInMilliseconds)
return toUnixTimestamp(randomPastTime)
}
/**
* Generate nip44 conversation key
@ -270,21 +263,19 @@ export const countLeadingZeroes = (hex: string) => {
/**
* Function to create a wrapped event with PoW
* @param event Original event to be wrapped (can be unsigned or verified)
* @param event Original event to be wrapped
* @param receiver Public key of the receiver
* @param difficulty PoW difficulty level (default is 20)
* @returns
*/
//
export const createWrap = (
event: UnsignedEvent | VerifiedEvent,
receiver: string
) => {
export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
// Generate a random secret key and its corresponding public key
const randomKey = generateSecretKey()
const pubkey = getPublicKey(randomKey)
// Encrypt the event content using nip44 encryption
const content = nip44Encrypt(event, randomKey, receiver)
const content = nip44Encrypt(unsignedEvent, randomKey, receiver)
// Initialize nonce and leadingZeroes for PoW calculation
let nonce = 0
@ -295,12 +286,11 @@ export const createWrap = (
// eslint-disable-next-line no-constant-condition
while (true) {
// Create an unsigned event with the necessary fields
// TODO: kinds.GiftWrap (wrong kind number in nostr-tools 10/11/2024 at v2.7.2)
const event: UnsignedEvent = {
kind: 1059, // Event kind
content, // Encrypted content
pubkey, // Public key of the creator
created_at: randomTimeUpTo2DaysInThePast(),
created_at: unixNow(), // Current timestamp
tags: [
// Tags including receiver and nonce
['p', receiver],

View File

@ -37,6 +37,7 @@ export const getRelayMapFromNDKRelayList = (ndkRelayList: NDKRelayList) => {
export const getDefaultRelayMap = (): RelayMap => ({
[SIGIT_RELAY]: { write: true, read: true }
})
/**
* Publishes relay map.
* @param relayMap - relay map.

View File

@ -2,6 +2,7 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
import zipPack from 'vite-plugin-zip-pack'
export default defineConfig({
plugins: [
@ -9,7 +10,8 @@ export default defineConfig({
tsconfigPaths(),
nodePolyfills({
include: ['os']
})
}),
zipPack()
],
build: {
target: 'ES2022'