import {createStore} from 'vuex'; import router from '@/router'; import FallBackResolver from "@/dns"; import NeighborsCache from "@/neigbors"; import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation"; import {parseIdentityRecord, serializeIdentityRecord} from "@/identity"; import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.js"; //import sharedStatePlugin from "@/../extras/shared-state-plugin"; //import persistentStatePlugin from "@/../extras/persistent-state-plugin"; function splitGroupHandle(handle) { const [name, domain] = handle.slice(1).split('@') return {name, domain} } const defaultPreferenceDefinitions = [ { key: 'ui.compact_mode', type: 'boolean', default: false, label: 'Compact mode', description: 'Show denser item rows and reduce spacing in lists.' }, { key: 'ui.default_search_scope', type: 'enum', options: ['inventory', 'friends', 'all'], default: 'inventory', label: 'Default search scope', description: 'Choose where the global search starts.' }, { key: 'notifications.desktop_enabled', type: 'boolean', default: true, label: 'Desktop notifications', description: 'Enable in-browser notifications for important updates.' }, { key: 'files.max_upload_mb', type: 'integer', default: 25, label: 'Default upload size limit (MB)', description: 'Used as a prefill hint in upload dialogs.' }, { key: 'ui.experimental_flags', type: 'json', default: {}, label: 'Experimental flags', description: 'Optional JSON toggles for feature previews.' }, ] const parseStoredPreferences = (storageKey) => { try { const raw = localStorage.getItem(storageKey) if (!raw) { return {} } const parsed = JSON.parse(raw) return parsed && typeof parsed === 'object' ? parsed : {} } catch (_error) { return {} } } export default createStore({ state: { local_loaded: false, last_load: {}, user: null, user_profile: null, token: null, keypair: null, remember: false, friends: [], friendProfiles: {}, groups: [], groupInvites: [], groupMemberships: [], idmap: {identities: [], groups: []}, idmapLoaded: false, item_map: {}, location_map: {}, home_servers: null, all_friends_servers: null, messages: [], resolver: new FallBackResolver(), unreachable_neighbors: new NeighborsCache(), tags: [], properties: [], files: [], categories: [], availability_policies: [], domains: [], storage_locations: [], active_workflows: [], preferenceDefinitions: [], accountPreferences: {}, devicePreferences: {}, preferencesLoaded: false, }, mutations: { setInventoryItems(state, {url, items}) { state.item_map[url] = items; }, setLocationsForKey(state, {url, locations}) { state.location_map[url] = locations; }, setFriends(state, friends) { state.friends = friends; }, setFriendProfile(state, {username, profile}) { state.friendProfiles = {...state.friendProfiles, [username]: profile}; }, setFriendProfiles(state, profiles) { state.friendProfiles = profiles; }, setGroups(state, groups) { state.groups = groups; }, setGroupInvites(state, invites) { state.groupInvites = invites; }, setGroupMemberships(state, memberships) { state.groupMemberships = memberships; }, setIdMap(state, idmap) { state.idmap = idmap; state.idmapLoaded = true; }, setHomeServers(state, home_servers) { state.home_servers = home_servers; }, setAllFriendsServers(state, servers) { state.all_friends_servers = servers; }, setTags(state, tags) { state.tags = tags; }, setProperties(state, properties) { state.properties = properties; }, setCategories(state, categories) { state.categories = categories; }, setAvailabilityPolicies(state, availability_policies) { state.availability_policies = availability_policies; }, setDomains(state, domains) { state.domains = domains; }, setStorageLocations(state, storage_locations) { state.storage_locations = storage_locations; }, setFiles(state, files) { state.files = files; }, setActiveWorkflows(state, workflows) { state.active_workflows = workflows; }, setPreferenceDefinitions(state, definitions) { state.preferenceDefinitions = definitions; }, setAccountPreferences(state, preferences) { state.accountPreferences = preferences; }, setDevicePreferences(state, preferences) { state.devicePreferences = preferences; }, setAccountPreference(state, {key, value}) { state.accountPreferences = {...state.accountPreferences, [key]: value}; }, setDevicePreference(state, {key, value}) { state.devicePreferences = {...state.devicePreferences, [key]: value}; }, deleteAccountPreference(state, key) { const prefs = {...state.accountPreferences}; delete prefs[key]; state.accountPreferences = prefs; }, deleteDevicePreference(state, key) { const prefs = {...state.devicePreferences}; delete prefs[key]; state.devicePreferences = prefs; }, setPreferencesLoaded(state, loaded) { state.preferencesLoaded = loaded; }, setUser(state, user) { state.user = user; if (state.remember) localStorage.setItem('user', user); }, setUserProfile(state, profile) { state.user_profile = profile; }, setToken(state, token) { state.token = token; if (state.remember) localStorage.setItem('token', token); }, setKey(state, keypair) { state.keypair = nacl.crypto_sign_keypair_from_seed(nacl.from_hex(keypair)) if (state.remember) localStorage.setItem('keypair', nacl.to_hex(state.keypair.signSk).slice(0, 64)) }, setRemember(state, remember) { state.remember = remember; if (!remember) { localStorage.removeItem('user'); localStorage.removeItem('token'); localStorage.removeItem('keypair'); } localStorage.setItem('remember', remember); }, setUserFromIdentityRecord(state, {user, keypair}) { try { localStorage.removeItem('user'); localStorage.removeItem('token'); localStorage.removeItem('keypair'); state.user = user; state.keypair = keypair; if (state.remember) { localStorage.setItem('user', user); localStorage.setItem('keypair', key) } } catch (e) { console.error('Invalid identity record') } }, logout(state) { state.user = null; state.user_profile = null; state.token = null; state.keypair = null; state.accountPreferences = {}; state.preferenceDefinitions = []; state.preferencesLoaded = false; // Note: devicePreferences are NOT cleared on logout (device-specific) localStorage.removeItem('user'); localStorage.removeItem('token'); localStorage.removeItem('keypair'); router.push('/login'); }, load_local(state) { if (state.local_loaded) return; const remember = localStorage.getItem('remember'); const user = localStorage.getItem('user'); const token = localStorage.getItem('token'); const keypair = localStorage.getItem('keypair'); if (user && token) { this.commit('setUser', user); this.commit('setToken', token); if (keypair) { this.commit('setKey', keypair) } router.push('/'); } state.cache_loaded = true; } }, actions: { async loadUserPreferences({state, commit, dispatch, getters}) { if (state.preferencesLoaded) return const deviceKey = 'toolshed.preferences.device' commit('setDevicePreferences', parseStoredPreferences(deviceKey)) let definitions = defaultPreferenceDefinitions let accountPreferences = {} try { const servers = await dispatch('getHomeServers') definitions = await servers.get(getters.nullAuth, '/auth/preferences/') || defaultPreferenceDefinitions accountPreferences = await servers.get(getters.signAuth, '/auth/self/preferences/') || {} } catch (error) { console.error('Failed to load user preferences:', error) } commit('setPreferenceDefinitions', definitions) commit('setAccountPreferences', accountPreferences) commit('setPreferencesLoaded', true) }, async setAccountPreference({state, commit, dispatch, getters}, {key, value}) { const servers = await dispatch('getHomeServers') await servers.put(getters.signAuth, '/auth/self/preferences/', {[key]: value}) commit('setAccountPreference', {key, value}) }, async resetAccountPreference({state, commit, dispatch, getters}, key) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/auth/self/preferences/' + key + '/') commit('deleteAccountPreference', key) }, async setDevicePreference({state, commit}, {key, value}) { commit('setDevicePreference', {key, value}) localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences)) }, async resetDevicePreference({state, commit}, key) { commit('deleteDevicePreference', key) localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences)) }, async login({commit, dispatch, state, getters}, {username, password, remember}) { commit('setRemember', remember); const data = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) .then(set => set.post(getters.nullAuth, '/auth/token/', {username, password})) if (data.token && data.key) { commit('setToken', data.token); commit('setUser', username); commit('setKey', data.key); const s = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) commit('setHomeServers', s) await dispatch('fetchUserProfile', {force: true}) return true; } else { return false; } }, async fetchUserProfile({state, commit, dispatch, getters}, {force = false} = {}) { if (!force && state.user_profile && state.last_load.user_profile > Date.now() - 1000 * 60) { return state.user_profile } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/auth/user/') commit('setUserProfile', data) state.last_load.user_profile = Date.now() return data }, async updateUserProfilePicture({state, commit, dispatch, getters}, {file = null} = {}) { const servers = await dispatch('getHomeServers') const payload = file ? {profile_picture: {data: file.data, mime_type: file.mime_type}} : {profile_picture: null} const data = await servers.patch(getters.signAuth, '/auth/user/', payload) commit('setUserProfile', data) state.last_load.user_profile = Date.now() if (data.profile_picture) { state.last_load.files = 0 } return data }, async lookupServer({state}, {username}) { const domain = username.split('@')[1] const request = '_toolshed-server._tcp.' + domain + '.' return await state.resolver.query(request, 'SRV').then( (result) => result.map( // Must match the browser's real Host header (federation.js signs/fetches // "https://"+server+target); browser omits :443 for default HTTPS, so // keeping it here would break signature checks on the receiving end. (answer) => answer.port === 443 ? answer.target : answer.target + ':' + answer.port)) }, async getHomeServers({state, dispatch, commit, getters}) { if (state.home_servers) return state.home_servers // Reading isLoggedIn first forces its lazy hydration of state.user from localStorage, // needed here since routes without requiresAuth (e.g. short-id redirect) skip that // check; fail clearly if still not logged in rather than crashing on username.split. if (!getters.isLoggedIn) { throw new Error('Not logged in') } const promise = dispatch('lookupServer', {username: state.user}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) commit('setHomeServers', promise) return promise }, async getAllKnownServers({state, dispatch, commit}) { const friends = await dispatch('fetchFriends') if (state.all_friends_servers) return state.all_friends_servers const promise = (async () => { const servers = new ServerSetUnion([]) const home = await dispatch('getHomeServers') servers.add(home) for (const friend of friends) { try { const s = await dispatch('lookupServer', {username: friend.username}) servers.add(new ServerSet(s, state.unreachable_neighbors)) } catch (e) { // Skip an unresolvable/unreachable friend rather than aborting the whole lookup. console.error('could not resolve server for friend', friend.username, e) } } return servers })().catch(e => { // Don't cache a permanently-rejected promise - allow a retry next time. state.all_friends_servers = null throw e }) commit('setAllFriendsServers', promise) return promise }, async getFriendServers({state, dispatch, commit}, {username}) { return dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) }, async fetchBackendVersion({dispatch, getters}) { const servers = await dispatch('getHomeServers') return await servers.get(getters.nullAuth, '/api/version/') }, async fetchInventoryItems({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const items = await servers.get(getters.signAuth, '/api/inventory_items/') items.map(item => item.files.map(file => file.owner = item.owner)) commit('setInventoryItems', {url: '/', items}) return items }, async createInventoryItem({state, dispatch, getters}, item) { const servers = item.owner_group ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) : await dispatch('getHomeServers') const data = { availability_policy: 'private', ...item, owner_group: item.owner_group ? item.owner_group.slice(1) : null } const reply = await servers.post(getters.signAuth, '/api/inventory_items/', data) state.last_load.files = 0 return reply }, async updateInventoryItem({state, dispatch, getters}, item) { const servers = item.owner_group ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) : await dispatch('getHomeServers') const data = {availability_policy: 'friends', ...item} data.files = data.files.map(file => file.id) return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data) }, async deleteInventoryItem({state, dispatch, getters}, item) { const servers = item.owner_group ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) : await dispatch('getHomeServers') const ret = await servers.delete(getters.signAuth, '/api/inventory_items/' + item.id + '/') dispatch('fetchInventoryItems') return ret }, async fetchSearchResults({state, commit, dispatch, getters}, {query}) { const servers = await dispatch('getAllKnownServers') const items = await servers.get(getters.signAuth, '/api/search/?query=' + query) items.map(item => item.files.map(file => file.owner = item.owner)) commit('setInventoryItems', {url: '/search/' + query, items}) return items }, async fetchFriends({commit, dispatch, getters, state}) { const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/friends/') commit('setFriends', data) return data }, async fetchFriendProfile({commit, dispatch, getters}, {username}) { try { const servers = await dispatch('getFriendServers', {username}); // username here is already a full handle (username@domain), see FriendSerializer const profile = await servers.get(getters.signAuth, '/auth/user/' + username + '/'); if (profile) { commit('setFriendProfile', {username, profile}); } return profile; } catch (error) { console.error(`Failed to fetch profile for ${username}:`, error); return null; } }, async fetchForeignItem({dispatch, getters}, {owner, id}) { try { const servers = await dispatch('getFriendServers', {username: owner}); // owner is a full handle (username@domain); this endpoint looks the item up by // owner, not requester (see toolshed/api/inventory.py get_shared_item). const item = await servers.get(getters.signAuth, '/api/inventory_items/' + owner + '/' + id + '/'); if (item && item.files) { item.files.forEach(file => file.owner = item.owner) } return item; } catch (error) { console.error(`Failed to fetch item ${id} for ${owner}:`, error); return null; } }, async fetchItemByHandle({dispatch, getters}, {handle, id}) { if (handle.startsWith('#')) { try { const items = await dispatch('fetchGroupInventoryItems', {groupHandle: handle}) return items.find(item => item.id === parseInt(id)) || null } catch (error) { console.error(`Failed to fetch group item ${id} for ${handle}:`, error); return null; } } return dispatch('fetchForeignItem', {owner: handle, id}) }, async fetchFriendRequests({state, dispatch, getters}) { const servers = await dispatch('getHomeServers') return await servers.get(getters.signAuth, '/api/friendrequests/') }, async requestFriend({state, dispatch, getters}, {username}) { if (username in state.friends) { return true; } const home_servers = await dispatch('getHomeServers') const home_reply = await home_servers.post(getters.signAuth, '/api/friendrequests/', { befriender: state.user, befriendee: username }) if (home_reply.status !== 'pending' || !home_reply.secret) return false; const befriendee_servers = await dispatch('getFriendServers', {username}) const ext_reply = await befriendee_servers.post(getters.signAuth, '/api/friendrequests/', { befriender: state.user, befriendee: username, befriender_key: nacl.to_hex(state.keypair.signPk), secret: home_reply.secret }) return true; }, async acceptFriend({state, dispatch, getters}, {id, secret, befriender}) { const home_servers = await dispatch('getHomeServers') const home_reply = await home_servers.post(getters.signAuth, '/api/friends/', { friend_request_id: id, secret: secret }) const ext_servers = await dispatch('getFriendServers', {username: befriender}) const ext_reply = await ext_servers.post(getters.signAuth, '/api/friendrequests/', { befriender: state.user, befriendee: befriender, befriender_key: nacl.to_hex(state.keypair.signPk), secret: secret }) return true }, async declineFriend({state, dispatch, getters}, {id}) { const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/friendrequests/' + id + '/') }, async dropFriend({state, dispatch, getters}, {id}) { const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/friends/' + id + '/') }, async fetchGroups({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/groups/') commit('setGroups', data) return data }, async fetchIdMap({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const idmap = await servers.get(getters.signAuth, '/api/idmap/') commit('setIdMap', idmap) return idmap }, // handle is "#name@domain". async fetchGroup({dispatch, getters}, {handle}) { const {domain} = splitGroupHandle(handle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) return await servers.get(getters.signAuth, `/api/groups/${handle.slice(1)}/`) }, async createGroup({dispatch, getters}, {name}) { const servers = await dispatch('getHomeServers') return await servers.post(getters.signAuth, '/api/groups/', {name}) }, async removeGroupMember({dispatch, getters}, {groupHandle, identityId}) { const {domain} = splitGroupHandle(groupHandle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) return await servers.delete( getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/members/' + identityId + '/') }, async fetchGroupInvites({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/groupinvites/') commit('setGroupInvites', data) return data }, async fetchGroupMemberships({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/groupmemberships/') commit('setGroupMemberships', data) return data }, async inviteToGroup({state, dispatch, getters}, {groupHandle, invitee}) { const {domain} = splitGroupHandle(groupHandle) const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const group_reply = await group_servers.post( getters.signAuth, '/api/groups/' + groupHandle.slice(1) + '/invites/', {invitee}) if (!group_reply.secret) { return false } const invitee_servers = await dispatch('getFriendServers', {username: invitee}) await invitee_servers.post(getters.signAuth, '/api/groupinvites/', { group: groupHandle, inviter: state.user, inviter_key: nacl.to_hex(state.keypair.signPk), invitee, secret: group_reply.secret }) return true }, async acceptGroupInvite({state, dispatch, getters}, invite) { const group_domain = invite.group.split('@')[1] const group_servers = await dispatch('getFriendServers', {username: 'x@' + group_domain}) await group_servers.post(getters.signAuth, '/api/group_invites/accept/', { group: invite.group, invitee: state.user, invitee_key: nacl.to_hex(state.keypair.signPk), secret: invite.secret }) const home_servers = await dispatch('getHomeServers') await home_servers.post(getters.signAuth, '/api/groupinvites/' + invite.id + '/accept/', {}) return true }, async declineGroupInvite({dispatch, getters}, invite) { const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/') }, async fetchGroupInventoryItems({commit, dispatch, getters}, {groupHandle}) { const {domain} = splitGroupHandle(groupHandle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupHandle.slice(1)) // Keyed by the full handle, not a bare id: a group pk is only unique within its own // backend's database, so two different domains could otherwise collide on the same // item_map key - the handle already carries the domain, so no separate namespacing is needed. commit('setInventoryItems', {url: '/group/' + groupHandle, items}) return items }, async fetchFiles({state, commit, dispatch, getters}) { if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) { return state.files } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/files/') data.map(file => file.owner = state.user) commit('setFiles', data) state.last_load.files = Date.now() return data }, async fetchFilesByItem({state, commit, dispatch, getters}, {id}) { const servers = await dispatch('getHomeServers') return await servers.get(getters.signAuth, '/api/item_files/' + id + '/') }, async pushFile({state, dispatch, getters}, {item_id, file}) { const servers = await dispatch('getHomeServers') const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', file) if (data.name) { data.owner = state.user state.files.push(data) return data } }, async deleteFile({state, dispatch, getters}, {id}) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/api/files/' + id + '/') state.files = state.files.filter(file => file.id !== id) }, async deleteItemFile({state, dispatch, getters}, {item_id, file_id}) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/api/item_files/' + item_id + '/' + file_id + '/') state.files = state.files.filter(file => file.id !== file_id) }, async stageFile({state, dispatch, getters}, {lifetime_id, file}) { const servers = await dispatch('getHomeServers') const data = await servers.post(getters.signAuth, '/api/staged_files/' + lifetime_id + '/', file) if (data.hash) { return data.hash } }, async unstageFile({state, dispatch, getters}, {lifetime_id, file_hash}) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/api/staged_files/' + lifetime_id + '/' + file_hash + '/') }, async commitStagedFile({state, dispatch, getters}, {item_id, file_hash}) { const servers = await dispatch('getHomeServers') const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', {file_hash}) if (data.name) { data.owner = state.user state.files.push(data) return data } }, async fetchTags({state, commit, dispatch, getters}) { if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) { return state.tags } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/tags/') commit('setTags', data) state.last_load.tags = Date.now() return data }, async fetchProperties({state, commit, dispatch, getters}) { if (state.last_load.properties > Date.now() - 1000 * 60 * 60 * 24) { return state.properties } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/properties/') commit('setProperties', data) state.last_load.properties = Date.now() return data }, async fetchCategories({state, commit, dispatch, getters}) { if (state.last_load.categories > Date.now() - 1000 * 60 * 60 * 24) { return state.categories } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/categories/') commit('setCategories', data) state.last_load.categories = Date.now() return data }, async fetchAvailabilityPolicies({state, commit, dispatch, getters}) { if (state.last_load.availability_policies > Date.now() - 1000 * 60 * 60 * 24) { return state.availability_policies } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/availability_policies/') commit('setAvailabilityPolicies', data.map(policy => ({slug: policy[0], text: policy[1]}))) state.last_load.availability_policies = Date.now() return data }, async fetchStorageLocations({state, commit, dispatch, getters}) { if (state.last_load.storage_locations > Date.now() - 1000 * 60 * 60 * 24) { return state.storage_locations } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/storage_locations/') commit('setStorageLocations', data) state.last_load.storage_locations = Date.now() return data },async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) { const {domain} = splitGroupHandle(groupHandle) const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) const locations = await servers.get(getters.signAuth, '/api/storage_locations/?group=' + groupHandle.slice(1)) commit('setLocationsForKey', {url: '/group/' + groupHandle, locations}) return locations }, async fetchStorageLocationByHandle({dispatch, getters}, {handle, id}) { if (handle.startsWith('#')) { try { const locations = await dispatch('fetchGroupStorageLocations', {groupHandle: handle}) return locations.find(location => location.id === parseInt(id)) || null } catch (error) { console.error(`Failed to fetch group storage location ${id} for ${handle}:`, error); return null; } } const locations = await dispatch('fetchStorageLocations') return locations.find(location => location.id === parseInt(id)) || null }, async deleteStorageLocation({state, dispatch, getters}, location) { const servers = location.owner_group ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain}) : await dispatch('getHomeServers') const ret = await servers.delete(getters.signAuth, '/api/storage_locations/' + location.id + '/') dispatch('fetchStorageLocations') return ret }, // location.owner_group is the group's own "#name@domain" handle - see // createInventoryItem's own comment. async createStorageLocation({state, dispatch, getters}, location) { const servers = location.owner_group ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain}) : await dispatch('getHomeServers') const data = {...location, owner_group: location.owner_group ? location.owner_group.slice(1) : null} if (data.parent === '') data.parent = null const reply = await servers.post(getters.signAuth, '/api/storage_locations/', data) state.last_load.storage_locations = 0 return reply }, async updateStorageLocation({state, dispatch, getters}, location) { const servers = location.owner_group ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain}) : await dispatch('getHomeServers') const data = {...location} if (data.parent === '') data.parent = null const reply = await servers.patch(getters.signAuth, '/api/storage_locations/' + location.id + '/', data) dispatch('fetchStorageLocations') return reply }, async fetchInfo({state, commit, dispatch, getters}) { const last_load_info = Math.min( state.last_load.tags, state.last_load.properties, state.last_load.categories, state.last_load.availability_policies) if (last_load_info > Date.now() - 1000 * 60 * 60 * 24) { return state.info } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/info/') commit('setTags', data.tags) commit('setProperties', data.properties) commit('setCategories', data.categories) commit('setAvailabilityPolicies', data.availability_policies.map(policy => ({ slug: policy[0], text: policy[1] }))) commit('setDomains', data.domains) state.last_load.tags = Date.now() state.last_load.properties = Date.now() state.last_load.categories = Date.now() state.last_load.availability_policies = Date.now() return data }, async userIdentityRecord({state}, {password}) { return await serializeIdentityRecord(state.user, state.keypair, password); }, async fetchActiveWorkflows({state, commit, dispatch, getters}) { if (state.last_load.active_workflows > Date.now() - 1000 * 60 * 5) { // Cache for 5 minutes return state.active_workflows } const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/workflows/') const workflows = data.map(deserializeWorkflowPayload) commit('setActiveWorkflows', workflows) state.last_load.active_workflows = Date.now() return workflows }, async createWorkflow({state, commit, dispatch, getters}, workflowData) { const servers = await dispatch('getHomeServers') // The backend stores payload as an opaque string; the frontend (de)serializes it. // See docs/implementation.md#workflow-payload-is-an-opaque-string. const data = await servers.post(getters.signAuth, '/api/workflows/', serializeWorkflowPayload(workflowData)) state.last_load.active_workflows = 0 // Invalidate cache return deserializeWorkflowPayload(data) }, async updateWorkflow({state, commit, dispatch, getters}, workflow) { const servers = await dispatch('getHomeServers') const data = await servers.patch(getters.signAuth, '/api/workflows/' + workflow.id + '/', serializeWorkflowPayload(workflow)) state.last_load.active_workflows = 0 // Invalidate cache return deserializeWorkflowPayload(data) }, async updateWorkflowStep({state, commit, dispatch, getters}, {workflowId, currentStep, payload}) { const servers = await dispatch('getHomeServers') const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/update_step/', { current_step: currentStep, payload: JSON.stringify(payload) }) state.last_load.active_workflows = 0 // Invalidate cache return deserializeWorkflowPayload(data) }, async deleteWorkflow({state, commit, dispatch, getters}, workflowId) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/api/workflows/' + workflowId + '/') state.last_load.active_workflows = 0 // Invalidate cache return true } }, getters: { isLoggedIn(state) { if (!state.local_loaded) { state.remember = localStorage.getItem('remember') === 'true' state.user = localStorage.getItem('user') state.token = localStorage.getItem('token') const keypair = localStorage.getItem('keypair') if (keypair) state.keypair = nacl.crypto_sign_keypair_from_seed(nacl.from_hex(keypair)) state.local_loaded = true } return state.user !== null && (state.token !== null || state.keypair !== null) }, allUserProfiles(state) { // Combine user's own profile with all friend profiles const combined = {...state.friendProfiles}; if (state.user && state.user_profile) { combined[state.user] = state.user_profile; } return combined; }, signAuth(state) { return createSignAuth(state.user, state.keypair.signSk) }, tokenAuth(state) { return createTokenAuth(state.token) }, nullAuth(state) { return createNullAuth({}) }, inventory_items(state) { return state.item_map['/'] || [] }, groupInventoryItems: (state) => (groupHandle) => state.item_map['/group/' + groupHandle] || [], groupStorageLocations: (state) => (groupHandle) => state.location_map['/group/' + groupHandle] || [], identityIdByHandle(state) { return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id])) }, groupIdByHandle(state) { return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id])) }, identityHandleById(state) { return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username])) }, groupHandleById(state) { return Object.fromEntries(state.idmap.groups.map(g => [g.id, g.handle])) }, loaded_items(state) { return Object.entries(state.item_map).reduce((acc, [url, items]) => { return acc.concat(items) }, []) }, notifications(state) { // supported types: error, warning, info, login, success, friend const u = state.unreachable_neighbors.list().map(elem => { return { type: 'error', title: elem.domain + ' unreachable', msg: 'The neighbor ' + elem.domain + ' is currently unreachable. Please try again later.', time: elem.time } }) return [...u, { type: 'info', title: 'Welcome to the Toolshed', msg: 'This is a federated social network. You can add friends from other servers and share items with them.', time: Date.now() }, { type: 'warning', title: 'Lorem ipsum', msg: 'Aliquam ex eros, imperdiet vulputate hendrerit et.', time: Date.now() - 1000 * 60 * 60 * 2 }, { type: 'login', title: 'Login from 192.186.1.8', time: Date.now() - 1000 * 60 * 60 * 5 }, { type: 'friend', title: 'New connection', msg: 'Christina accepted your request.', time: Date.now() - 1000 * 60 * 60 * 14 }, { type: 'success', title: 'Lorem ipsum', msg: 'Aliquam ex eros, imperdiet vulputate hendrerit et.', time: Date.now() - 1000 * 60 * 60 * 24 }] }, getPreference: (state) => (key, fallbackDefault = null) => { if (Object.prototype.hasOwnProperty.call(state.devicePreferences, key) && state.devicePreferences[key] !== null) { return state.devicePreferences[key] } if (Object.prototype.hasOwnProperty.call(state.accountPreferences, key) && state.accountPreferences[key] !== null) { return state.accountPreferences[key] } const definition = state.preferenceDefinitions.find((pref) => pref.key === key) if (definition) { return definition.default } return fallbackDefault }, /** Extracts the name from a handle like "git:tools#tag:drill"; returns non-handles unchanged. */ getNameFromHandle: () => (handle) => { if (typeof handle !== 'string') { return handle; } const match = handle.match(/#(?:tag|property):(.+)$/); return match ? match[1] : handle; }, } })