cleanup on channel context

This commit is contained in:
Roland Osborne 2023-01-04 15:27:29 -08:00
parent fbd46b3f3f
commit b1b26b3fe4
5 changed files with 174 additions and 240 deletions

View File

@ -1,6 +1,6 @@
import { checkResponse, fetchWithTimeout } from './fetchUtil'; import { checkResponse, fetchWithTimeout } from './fetchUtil';
export async function addChannelTopic(token, channelId, datatype, message, assets ): string { export async function addChannelTopic(token, channelId, datatype, message, assets ) {
if (message == null && (assets == null || assets.length === 0)) { if (message == null && (assets == null || assets.length === 0)) {
let topic = await fetchWithTimeout(`/content/channels/${channelId}/topics?agent=${token}`, let topic = await fetchWithTimeout(`/content/channels/${channelId}/topics?agent=${token}`,

View File

@ -14,216 +14,107 @@ import { setChannelSubject } from 'api/setChannelSubject';
import { setChannelCard } from 'api/setChannelCard'; import { setChannelCard } from 'api/setChannelCard';
import { clearChannelCard } from 'api/clearChannelCard'; import { clearChannelCard } from 'api/clearChannelCard';
import { UploadContext } from 'context/UploadContext'; import { UploadContext } from 'context/UploadContext';
import CryptoJS from 'crypto-js';
import { JSEncrypt } from 'jsencrypt'
export function useChannelContext() { export function useChannelContext() {
const [state, setState] = useState({ const [state, setState] = useState({
init: false, offsync: false,
channels: new Map(), channels: new Map(),
}); });
const upload = useContext(UploadContext); const upload = useContext(UploadContext);
const access = useRef(null); const access = useRef(null);
const revision = useRef(null); const setRevision = useRef(null);
const curRevision = useRef(null);
const channels = useRef(new Map()); const channels = useRef(new Map());
const next = useRef(null); const syncing = useRef(false);
const updateState = (value) => { const updateState = (value) => {
setState((s) => ({ ...s, ...value })) setState((s) => ({ ...s, ...value }))
} }
const unsealKey = (seals, sealKey) => { const sync = async () => {
let unsealedKey; if (!syncing.current && setRevision.current !== curRevision.current) {
if (seals?.length) { syncing.current = true;
seals.forEach(seal => {
if (seal.publicKey === sealKey.public) {
let crypto = new JSEncrypt();
crypto.setPrivateKey(sealKey.private);
unsealedKey = crypto.decrypt(seal.sealedKey);
}
});
}
return unsealedKey;
}
const updateChannels = async () => { try {
let delta = await getChannels(access.current, revision.current); const token = access.current;
for (let channel of delta) { const revision = curRevision.current;
if (channel.data) { const delta = await getChannels(token, setRevision.current);
let cur = channels.current.get(channel.id); for (let channel of delta) {
if (cur == null) { if (channel.data) {
cur = { id: channel.id, data: { } } let cur = channels.current.get(channel.id);
} if (cur == null) {
if (cur.data.detailRevision !== channel.data.detailRevision) { cur = { id: channel.id, data: { } }
if (channel.data.channelDetail != null) { }
cur.data.channelDetail = channel.data.channelDetail; if (cur.data.detailRevision !== channel.data.detailRevision) {
if (channel.data.channelDetail != null) {
cur.data.channelDetail = channel.data.channelDetail;
}
else {
let detail = await getChannelDetail(token, channel.id);
cur.data.channelDetail = detail;
}
cur.data.unsealedSubject = null;
cur.data.detailRevision = channel.data.detailRevision;
}
if (cur.data.topicRevision !== channel.data.topicRevision) {
if (channel.data.channelSummary != null) {
cur.data.channelSummary = channel.data.channelSummary;
}
else {
let summary = await getChannelSummary(token, channel.id);
cur.data.channelSummary = summary;
}
cur.data.unsealedSummary = null;
cur.data.topicRevision = channel.data.topicRevision;
}
cur.revision = channel.revision;
channels.current.set(channel.id, cur);
} }
else { else {
let detail = await getChannelDetail(access.current, channel.id); channels.current.delete(channel.id);
cur.data.channelDetail = detail;
} }
cur.data.unsealedChannel = null;
cur.data.detailRevision = channel.data.detailRevision;
} }
if (cur.data.topicRevision !== channel.data.topicRevision) { setRevision.current = revision;
if (channel.data.channelSummary != null) { updateState({ offsync: false, channels: channels.current });
cur.data.channelSummary = channel.data.channelSummary;
}
else {
let summary = await getChannelSummary(access.current, channel.id);
cur.data.channelSummary = summary;
}
cur.data.unsealedSummary = null;
cur.data.topicRevision = channel.data.topicRevision;
}
cur.revision = channel.revision;
channels.current.set(channel.id, { ...cur });
} }
else { catch(err) {
channels.current.delete(channel.id); console.log(err);
syncing.current = false;
updateState({ offsync: true });
return;
} }
}
}
const setChannels = async (rev) => { syncing.current = false;
let force = false; await sync();
if (rev == null) {
force = true;
rev = revision.current;
}
if (next.current == null) {
next.current = rev;
if (force || revision.current !== rev) {
await updateChannels();
updateState({ init: true, channels: channels.current });
revision.current = rev;
}
let r = next.current;
next.current = null;
if (revision.current !== r) {
setChannels(r);
}
}
else {
next.current = rev;
} }
} }
const actions = { const actions = {
setToken: (token) => { setToken: (token) => {
if (access.current || syncing.current) {
throw new Error("invalid session state");
}
access.current = token; access.current = token;
channels.current = new Map();
curRevision.current = null;
setRevision.current = null;
setState({ offsync: false, channels: new Map() });
}, },
clearToken: () => { clearToken: () => {
access.current = null; access.current = null;
channels.current = new Map();
revision.current = null;
setState({ init: false, channels: new Map() });
}, },
setRevision: async (rev) => { setRevision: async (rev) => {
setChannels(rev); curRevision.current = rev;
await sync();
}, },
addBasicChannel: async (cards, subject) => { addChannel: async (type, subject, cards) => {
return await addChannel(access.current, 'superbasic', cards, { subject }); return await addChannel(access.current, type, cards, subject);
}, },
addSealedChannel: async (cards, subject, keys) => { removeChannel: async (channelId) => {
const key = CryptoJS.lib.WordArray.random(256 / 8); return await removeChannel(access.current, channelId);
const iv = CryptoJS.lib.WordArray.random(128 / 8);
const encrypted = CryptoJS.AES.encrypt(JSON.stringify({ subject }), key, { iv: iv });
const subjectEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64)
const subjectIv = iv.toString();
const keyHex = key.toString();
let seals = [];
let crypto = new JSEncrypt();
keys.forEach(publicKey => {
crypto.setPublicKey(publicKey);
const sealedKey = crypto.encrypt(keyHex);
seals.push({ publicKey, sealedKey });
});
const data = { subjectEncrypted, subjectIv, seals };
return await addChannel(access.current, 'sealed', cards, data);
}, },
unsealChannelSubject: (channelId, sealKey) => { setChannelSubject: async (channelId, type, subject) => {
try { return await setChannelSubject(access.current, channelId, type, subject);
const channel = channels.current.get(channelId);
const { subjectEncrypted, subjectIv, seals } = JSON.parse(channel.data.channelDetail.data);
const unsealedKey = unsealKey(seals, sealKey);
if (unsealKey) {
const iv = CryptoJS.enc.Hex.parse(subjectIv);
const key = CryptoJS.enc.Hex.parse(unsealedKey);
const enc = CryptoJS.enc.Base64.parse(subjectEncrypted);
const cipher = CryptoJS.lib.CipherParams.create({ ciphertext: enc, iv: iv });
const dec = CryptoJS.AES.decrypt(cipher, key, { iv: iv });
channel.data.unsealedChannel = JSON.parse(dec.toString(CryptoJS.enc.Utf8));
channels.current.set(channel.id, { ...channel });
updateState({ channels: channels.current });
}
}
catch(err) {
console.log(err);
}
},
isUnsealed: (channelId, sealKey) => {
try {
const channel = channels.current.get(channelId);
const { seals } = JSON.parse(channel.data.channelDetail.data);
for (let i = 0; i < seals.length; i++) {
if (seals[i].publicKey === sealKey.public) {
return sealKey.private != null;
}
}
}
catch(err) {
console.log(err);
}
return false;
},
unsealChannelSummary: (channelId, sealKey) => {
try {
const channel = channels.current.get(channelId);
const { seals } = JSON.parse(channel.data.channelDetail.data);
const { messageEncrypted, messageIv } = JSON.parse(channel.data.channelSummary.lastTopic.data);
const unsealedKey = unsealKey(seals, sealKey);
if (unsealKey) {
const iv = CryptoJS.enc.Hex.parse(messageIv);
const key = CryptoJS.enc.Hex.parse(unsealedKey);
const enc = CryptoJS.enc.Base64.parse(messageEncrypted);
const cipher = CryptoJS.lib.CipherParams.create({ ciphertext: enc, iv: iv });
const dec = CryptoJS.AES.decrypt(cipher, key, { iv: iv });
channel.data.unsealedSummary = JSON.parse(dec.toString(CryptoJS.enc.Utf8));
channels.current.set(channel.id, { ...channel });
updateState({ channels: channels.current });
}
}
catch(err) {
console.log(err);
}
},
setChannelSubject: async (channelId, subject) => {
return await setChannelSubject(access.current, channelId, 'superbasic', { subject });
},
setChannelSealedSubject: async (channelId, subject, sealKey) => {
const channel = channels.current.get(channelId);
let { seals, subjectEncrypted, subjectIv } = JSON.parse(channel.data.channelDetail.data);
if (seals?.length) {
seals.forEach(seal => {
if (seal.publicKey === sealKey.public) {
let crypto = new JSEncrypt();
crypto.setPrivateKey(sealKey.private);
const unsealedKey = crypto.decrypt(seal.sealedKey);
const key = CryptoJS.enc.Hex.parse(unsealedKey);
const iv = CryptoJS.lib.WordArray.random(128 / 8);
const encrypted = CryptoJS.AES.encrypt(JSON.stringify({ subject }), key, { iv: iv });
subjectEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64)
subjectIv = iv.toString();
}
});
}
const data = { subjectEncrypted, subjectIv, seals };
return await setChannelSubject(access.current, channelId, 'sealed', data);
}, },
setChannelCard: async (channelId, cardId) => { setChannelCard: async (channelId, cardId) => {
return await setChannelCard(access.current, channelId, cardId); return await setChannelCard(access.current, channelId, cardId);
@ -231,47 +122,28 @@ export function useChannelContext() {
clearChannelCard: async (channelId, cardId) => { clearChannelCard: async (channelId, cardId) => {
return await clearChannelCard(access.current, channelId, cardId); return await clearChannelCard(access.current, channelId, cardId);
}, },
removeChannel: async (channelId) => { unsealChannelSubject: async (channelId, unsealed, revision) => {
return await removeChannel(access.current, channelId); const channel = channels.current.get(channelId);
}, if (channel.revision === revision) {
removeChannelTopic: async (channelId, topicId) => { channel.data.unsealedSubject = unsealed;
await removeChannelTopic(access.current, channelId, topicId); channels.current.set(channelId, channel);
try { updateState({ channels: channels.current });
await setChannels(null);
}
catch (err) {
console.log(err);
} }
}, },
setChannelTopicSubject: async (channelId, topicId, data) => { unsealChannelSummary: async (channelId, unsealed, revision) => {
await setChannelTopicSubject(access.current, channelId, topicId, 'superbasictopic', data); const channel = channels.current.get(channelId);
try { if (channel.revision === revision) {
await setChannels(null); channel.data.unsealedSummary = unsealed;
} channels.current.set(channelId, chanel);
catch (err) { updateState({ channels: channels.current });
console.log(err);
} }
}, },
setSealedChannelTopicSubject: async (channelId, topicId, data, sealKey) => { addTopic: async (channelId, type, message, files) => {
const iv = CryptoJS.lib.WordArray.random(128 / 8);
const key = CryptoJS.enc.Hex.parse(sealKey);
const encrypted = CryptoJS.AES.encrypt(JSON.stringify({ message: data }), key, { iv: iv });
const messageEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64)
const messageIv = iv.toString();
await setChannelTopicSubject(access.current, channelId, topicId, 'sealedtopic', { messageEncrypted, messageIv });
try {
await setChannels(null);
}
catch (err) {
console.log(err);
}
},
addChannelTopic: async (channelId, message, files) => {
if (files?.length) { if (files?.length) {
const topicId = await addChannelTopic(access.current, channelId, null, null, null); const topicId = await addChannelTopic(access.current, channelId, null, null, null);
upload.actions.addTopic(access.current, channelId, topicId, files, async (assets) => { upload.actions.addTopic(access.current, channelId, topicId, files, async (assets) => {
message.assets = assets; const subject = message(assets);
await setChannelTopicSubject(access.current, channelId, topicId, 'superbasictopic', message); await setChannelTopicSubject(access.current, channelId, topicId, type, subject);
}, async () => { }, async () => {
try { try {
await removeChannelTopic(access.current, channelId, topicId); await removeChannelTopic(access.current, channelId, topicId);
@ -282,7 +154,8 @@ export function useChannelContext() {
}); });
} }
else { else {
await addChannelTopic(access.current, channelId, 'superbasictopic', message, files); const subject = message([]);
await addChannelTopic(access.current, channelId, type, subject);
} }
try { try {
await setChannels(null); await setChannels(null);
@ -290,21 +163,16 @@ export function useChannelContext() {
catch (err) { catch (err) {
console.log(err); console.log(err);
} }
}, },
addSealedChannelTopic: async (channelId, sealKey, message) => { removeTopic: async (channelId, topicId) => {
const iv = CryptoJS.lib.WordArray.random(128 / 8); await removeChannelTopic(access.current, channelId, topicId);
const key = CryptoJS.enc.Hex.parse(sealKey);
const encrypted = CryptoJS.AES.encrypt(JSON.stringify({ message }), key, { iv: iv });
const messageEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64)
const messageIv = iv.toString();
await addChannelTopic(access.current, channelId, 'sealedtopic', { messageEncrypted, messageIv });
}, },
getChannel: (channelId) => { setTopicSubject: async (channelId, topicId, type, subject) => {
return channels.current.get(channelId); await setChannelTopicSubject(access.current, channelId, topicId, type, subject);
}, },
getChannelRevision: (channelId) => { getChannelTopicAssetUrl: (channelId, topicId, assetId) => {
let channel = channels.current.get(channelId); return getChannelTopicAssetUrl(access.current, channelId, topicId, assetId);
return channel?.revision;
}, },
getChannelTopics: async (channelId, revision, count, begin, end) => { getChannelTopics: async (channelId, revision, count, begin, end) => {
return await getChannelTopics(access.current, channelId, revision, count, begin, end); return await getChannelTopics(access.current, channelId, revision, count, begin, end);
@ -312,10 +180,10 @@ export function useChannelContext() {
getChannelTopic: async (channelId, topicId) => { getChannelTopic: async (channelId, topicId) => {
return await getChannelTopic(access.current, channelId, topicId); return await getChannelTopic(access.current, channelId, topicId);
}, },
getChannelTopicAssetUrl: (channelId, topicId, assetId) => { resync: async () => {
return getChannelTopicAssetUrl(access.current, channelId, topicId, assetId); await sync();
} },
} };
return { state, actions } return { state, actions }
} }

View File

@ -7,6 +7,7 @@ import { getProfileImageUrl } from 'api/getProfileImageUrl';
export function useProfileContext() { export function useProfileContext() {
const [state, setState] = useState({ const [state, setState] = useState({
offsync: false,
identity: {}, identity: {},
imageUrl: null, imageUrl: null,
}); });
@ -24,36 +25,41 @@ export function useProfileContext() {
syncing.current = true; syncing.current = true;
try { try {
const token = access.current;
const revision = curRevision.current; const revision = curRevision.current;
const identity = await getProfile(access.current); const identity = await getProfile(access.current);
const imageUrl = identity.image ? getProfileImageUrl(access.current, revision) : null; const imageUrl = identity.image ? getProfileImageUrl(token, revision) : null;
updateState({ identity, imageUrl });
setRevision.current = revision; setRevision.current = revision;
updateState({ offsync: false, identity, imageUrl });
} }
catch(err) { catch(err) {
console.log(err); console.log(err);
syncing.current = false; syncing.current = false;
updateState({ offsync: true });
return; return;
} }
syncing.current = false; syncing.current = false;
sync(); await sync();
} }
} }
const actions = { const actions = {
setToken: (token) => { setToken: (token) => {
if (access.current || syncing.current) {
throw new Error("invalid session state");
}
access.current = token; access.current = token;
curRevision.current = null;
setRevision.current = null;
setState({ offsync: false, identity: {}, imageUrl: null });
}, },
clearToken: () => { clearToken: () => {
access.current = null; access.current = null;
curRevision.current = null;
setRevision.current = null;
setState({ identity: {}, imageUrl: null });
}, },
setRevision: (rev) => { setRevision: async (rev) => {
curRevision.current = rev; curRevision.current = rev;
sync(); await sync();
}, },
setProfileData: async (name, location, description) => { setProfileData: async (name, location, description) => {
await setProfileData(access.current, name, location, description); await setProfileData(access.current, name, location, description);
@ -64,6 +70,9 @@ export function useProfileContext() {
getHandleStatus: async (name) => { getHandleStatus: async (name) => {
return await getUsername(name, access.current); return await getUsername(name, access.current);
}, },
resync: async () => {
await sync();
},
} }
return { state, actions } return { state, actions }

View File

@ -0,0 +1,61 @@
import React, { useState, useEffect, useContext } from 'react';
import {render, act, screen, waitFor, fireEvent} from '@testing-library/react'
import { ChannelContextProvider, ChannelContext } from 'context/ChannelContext';
import * as fetchUtil from 'api/fetchUtil';
let channelContext = null;
function ChannelView() {
const [renderCount, setRenderCount] = useState(0);
const channel = useContext(ChannelContext);
channelContext = channel;
useEffect(() => {
setRenderCount(renderCount + 1);
}, [channel.state]);
return (
<div>
<span data-testid="count">{ renderCount }</span>
</div>
);
}
function ChannelTestApp() {
return (
<ChannelContextProvider>
<ChannelView />
</ChannelContextProvider>
)
}
const realFetchWithTimeout = fetchUtil.fetchWithTimeout;
const realFetchWithCustomTimeout = fetchUtil.fetchWithCustomTimeout;
let fetching = (url, options) => Promise.resolve({ json: () => Promise.resolve([])});
beforeEach(() => {
const mockFetch = jest.fn().mockImplementation((url, options) => fetching(url, options));
fetchUtil.fetchWithTimeout = mockFetch;
fetchUtil.fetchWithCustomTimeout = mockFetch;
});
afterEach(() => {
fetchUtil.fetchWithTimeout = realFetchWithTimeout;
fetchUtil.fetchWithCustomTimeout = realFetchWithCustomTimeout;
});
test('testing channel sync', async () => {
render(<ChannelTestApp />);
await waitFor(async () => {
expect(channelContext).not.toBe(null);
});
await act( async () => {
channelContext.actions.setToken('abc123');
await channelContext.actions.setRevision(1);
});
});

View File

@ -60,7 +60,7 @@ afterEach(() => {
fetchUtil.fetchWithCustomTimeout = realFetchWithCustomTimeout; fetchUtil.fetchWithCustomTimeout = realFetchWithCustomTimeout;
}); });
test('testing', async () => { test('testing profile sync', async () => {
render(<ProfileTestApp />); render(<ProfileTestApp />);
await waitFor(async () => { await waitFor(async () => {
@ -107,10 +107,6 @@ test('testing', async () => {
await profileContext.actions.clearToken(); await profileContext.actions.clearToken();
}); });
await waitFor(async () => {
expect(screen.getByTestId('name').textContent).toBe("");
});
}); });