diff --git a/frontend/src/components/BaseLayout.vue b/frontend/src/components/BaseLayout.vue
index 21b3d75..6fb817c 100644
--- a/frontend/src/components/BaseLayout.vue
+++ b/frontend/src/components/BaseLayout.vue
@@ -6,6 +6,11 @@
+
@@ -14,12 +19,15 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/federation.js b/frontend/src/federation.js
new file mode 100644
index 0000000..9afe33c
--- /dev/null
+++ b/frontend/src/federation.js
@@ -0,0 +1,324 @@
+class ServerSet {
+ constructor(servers, unreachable_neighbors) {
+ if (!servers || !Array.isArray(servers)) {
+ throw new Error('no servers')
+ }
+ if (!unreachable_neighbors || typeof unreachable_neighbors.queryUnreachable !== 'function' || typeof unreachable_neighbors.unreachable !== 'function') {
+ throw new Error('no unreachable_neighbors')
+ }
+ this.servers = [...new Set(servers)] // deduplicate
+ this.unreachable_neighbors = unreachable_neighbors;
+ }
+
+ add(server) {
+ console.log('adding server', server)
+ if (!server || typeof server !== 'string') {
+ throw new Error('server must be a string')
+ }
+ if (server in this.servers) {
+ console.log('server already in set', server)
+ return
+ }
+ this.servers.push(server);
+ }
+
+ async get(auth, target) {
+ if (!auth || typeof auth.buildAuthHeader !== 'function') {
+ throw new Error('no auth')
+ }
+ for (const server of this.servers) {
+ try {
+ if (this.unreachable_neighbors.queryUnreachable(server)) {
+ continue
+ }
+ const url = "https://" + server + target // TODO https
+ return await fetch(url, {
+ method: 'GET',
+ headers: {
+ ...auth.buildAuthHeader(url)
+ },
+ credentials: 'omit'
+ }).catch(err => {
+ console.error('get from server failed', server, err)
+ this.unreachable_neighbors.unreachable(server)
+ }
+ ).then(response => response.json())
+ } catch (e) {
+ console.error('get from server failed', server, e)
+ }
+ }
+ throw new Error('all servers failed')
+ }
+
+ async post(auth, target, data) {
+ if (!auth || typeof auth.buildAuthHeader !== 'function') {
+ throw new Error('no auth')
+ }
+ for (const server of this.servers) {
+ try {
+ if (this.unreachable_neighbors.queryUnreachable(server)) {
+ continue
+ }
+ const url = "https://" + server + target // TODO https
+ return await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...auth.buildAuthHeader(url, data)
+ },
+ credentials: 'omit',
+ body: JSON.stringify(data)
+ }).catch(err => {
+ console.error('post to server failed', server, err)
+ this.unreachable_neighbors.unreachable(server)
+ }
+ ).then(response => response.json())
+ } catch (e) {
+ console.error('post to server failed', server, e)
+ }
+ }
+ throw new Error('all servers failed')
+ }
+
+ async patch(auth, target, data) {
+ if (!auth || typeof auth.buildAuthHeader !== 'function') {
+ throw new Error('no auth')
+ }
+ for (const server of this.servers) {
+ try {
+ if (this.unreachable_neighbors.queryUnreachable(server)) {
+ continue
+ }
+ const url = "https://" + server + target // TODO https
+ return await fetch(url, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...auth.buildAuthHeader(url, data)
+ },
+ credentials: 'omit',
+ body: JSON.stringify(data)
+ }).catch(err => {
+ console.error('patch to server failed', server, err)
+ this.unreachable_neighbors.unreachable(server)
+ }
+ ).then(response => response.json())
+ } catch (e) {
+ console.error('patch to server failed', server, e)
+ }
+ }
+ throw new Error('all servers failed')
+ }
+
+ async put(auth, target, data) {
+ if (!auth || typeof auth.buildAuthHeader !== 'function') {
+ throw new Error('no auth')
+ }
+ for (const server of this.servers) {
+ try {
+ if (this.unreachable_neighbors.queryUnreachable(server)) {
+ continue
+ }
+ const url = "https://" + server + target // TODO https
+ return await fetch(url, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...auth.buildAuthHeader(url, data)
+ },
+ credentials: 'omit',
+ body: JSON.stringify(data)
+ }).catch(err => {
+ console.error('put to server failed', server, err)
+ this.unreachable_neighbors.unreachable(server)
+ }
+ ).then(response => response.json())
+ } catch (e) {
+ console.error('put to server failed', server, e)
+ }
+ }
+ throw new Error('all servers failed')
+ }
+
+ async delete(auth, target) {
+ if (!auth || typeof auth.buildAuthHeader !== 'function') {
+ throw new Error('no auth')
+ }
+ for (const server of this.servers) {
+ try {
+ if (this.unreachable_neighbors.queryUnreachable(server)) {
+ continue
+ }
+ const url = "https://" + server + target // TODO https
+ return await fetch(url, {
+ method: 'DELETE',
+ headers: {
+ ...auth.buildAuthHeader(url)
+ },
+ credentials: 'omit'
+ }).catch(err => {
+ console.error('delete from server failed', server, err)
+ this.unreachable_neighbors.unreachable(server)
+ }
+ )
+ } catch (e) {
+ console.error('delete from server failed', server, e)
+ }
+ }
+ throw new Error('all servers failed')
+ }
+
+ async getRaw(auth, target) {
+ if (!auth || typeof auth.buildAuthHeader !== 'function') {
+ throw new Error('no auth')
+ }
+ for (const server of this.servers) {
+ try {
+ if (this.unreachable_neighbors.queryUnreachable(server)) {
+ continue
+ }
+ const url = "https://" + server + target // TODO https
+ return await fetch(url, {
+ method: 'GET',
+ headers: {
+ ...auth.buildAuthHeader(url)
+ },
+ credentials: 'omit'
+ }).catch(err => {
+ console.error('get from server failed', server, err)
+ this.unreachable_neighbors.unreachable(server)
+ }
+ )
+ } catch (e) {
+ console.error('get from server failed', server, e)
+ }
+ }
+ throw new Error('all servers failed')
+ }
+}
+
+class ServerSetUnion {
+ constructor(serverSets) {
+ if (!serverSets || !Array.isArray(serverSets)) {
+ throw new Error('no serverSets')
+ }
+ this.serverSets = serverSets;
+ }
+
+ add(serverset) {
+ if (!serverset || !(serverset instanceof ServerSet)) {
+ throw new Error('no serverset')
+ }
+ if (this.serverSets.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
+ console.warn('serverset already in union', serverset)
+ return
+ }
+ this.serverSets.push(serverset)
+ }
+
+ async get(auth, target) {
+ try {
+ return await this.serverSets.reduce(async (acc, serverset) => {
+ return acc.then(async (acc) => {
+ return acc.concat(await serverset.get(auth, target))
+ })
+ }, Promise.resolve([]))
+ } catch (e) {
+ throw new Error('all servers failed')
+ }
+ }
+
+ async post(auth, target, data) {
+ try {
+ return await this.serverSets.reduce(async (acc, serverset) => {
+ return acc.then(async (acc) => {
+ return acc.concat(await serverset.post(auth, target, data))
+ })
+ }, Promise.resolve([]))
+ } catch (e) {
+ throw new Error('all servers failed')
+ }
+ }
+
+ async patch(auth, target, data) {
+ try {
+ return await this.serverSets.reduce(async (acc, serverset) => {
+ return acc.then(async (acc) => {
+ return acc.concat(await serverset.patch(auth, target, data))
+ })
+ }, Promise.resolve([]))
+ } catch (e) {
+ throw new Error('all servers failed')
+ }
+ }
+
+ async put(auth, target, data) {
+ try {
+ return await this.serverSets.reduce(async (acc, serverset) => {
+ return acc.then(async (acc) => {
+ return acc.concat(await serverset.put(auth, target, data))
+ })
+ }, Promise.resolve([]))
+ } catch (e) {
+ throw new Error('all servers failed')
+ }
+ }
+
+ async delete(auth, target) {
+ try {
+ return await this.serverSets.reduce(async (acc, serverset) => {
+ return acc.then(async (acc) => {
+ return acc.concat(await serverset.delete(auth, target))
+ })
+ }, Promise.resolve([]))
+ } catch (e) {
+ throw new Error('all servers failed')
+ }
+ }
+}
+
+
+class authMethod {
+ constructor(method, auth) {
+ this.method = method;
+ this.auth = auth;
+ }
+
+ buildAuthHeader(url, data) {
+ return this.method(this.auth, {url, data})
+ }
+
+}
+
+function createSignAuth(username, signKey) {
+ const context = {username, signKey}
+ if (!context.signKey || !context.username || typeof context.username !== 'string'
+ || !(context.signKey instanceof Uint8Array) || context.signKey.length !== 64) {
+ throw new Error('no signKey or username')
+ }
+ return new authMethod(({signKey, username}, {url, data}) => {
+ const json = JSON.stringify(data)
+ const signature = nacl.crypto_sign_detached(nacl.encode_utf8(url + (data ? json : "")), signKey)
+ return {'Authorization': 'Signature ' + username + ':' + nacl.to_hex(signature)}
+ }, context)
+}
+
+function createTokenAuth(token) {
+ const context = {token}
+ if (!context.token) {
+ throw new Error('no token')
+ }
+ return new authMethod(({token}, {url, data}) => {
+ return {'Authorization': 'Token ' + token}
+ }, context)
+}
+
+function createNullAuth() {
+ return new authMethod(() => {
+ return {}
+ }, {})
+}
+
+export {ServerSet, ServerSetUnion, createSignAuth, createTokenAuth, createNullAuth};
+
+
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 66ab5c7..2277ac2 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -5,10 +5,11 @@ import App from './App.vue'
import './scss/toolshed.scss'
import router from './router'
+import store from './store';
import _nacl from 'js-nacl';
-const app = createApp(App).use(BootstrapIconsPlugin);
+const app = createApp(App).use(store).use(BootstrapIconsPlugin);
_nacl.instantiate((nacl) => {
window.nacl = nacl
diff --git a/frontend/src/neigbors.js b/frontend/src/neigbors.js
new file mode 100644
index 0000000..bbdfc06
--- /dev/null
+++ b/frontend/src/neigbors.js
@@ -0,0 +1,48 @@
+class NeighborsCache {
+ constructor() {
+ //this._max_age = 1000 * 60 * 60; // 1 hour
+ //this._max_age = 1000 * 60 * 5; // 5 minutes
+ this._max_age = 1000 * 15; // 15 seconds
+ this._cache = JSON.parse(localStorage.getItem('neighbor-cache')) || {};
+ }
+
+ reachable(domain) {
+ console.log('reachable neighbor ' + domain)
+ if (domain in this._cache) {
+ delete this._cache[domain];
+ localStorage.setItem('neighbor-cache', JSON.stringify(this._cache));
+ }
+ }
+
+ unreachable(domain) {
+ console.log('unreachable neighbor ' + domain)
+ this._cache[domain] = {time: Date.now()};
+ localStorage.setItem('neighbor-cache', JSON.stringify(this._cache));
+ }
+
+ queryUnreachable(domain) {
+ //return false if unreachable
+ if (domain in this._cache) {
+ if (this._cache[domain].time > Date.now() - this._max_age) {
+ console.log('skip unreachable neighbor ' + domain + ' ' + Math.ceil(
+ Date.now()/1000 - this._cache[domain].time/1000) + 's/' + Math.ceil(this._max_age/1000) + 's')
+ return true
+ } else {
+ delete this._cache[domain];
+ localStorage.setItem('neighbor-cache', JSON.stringify(this._cache));
+ }
+ }
+ return false;
+ }
+
+ list() {
+ return Object.entries(this._cache).map(([domain, elem]) => {
+ return {
+ domain: domain,
+ time: elem.time
+ }
+ })
+ }
+}
+
+export default NeighborsCache;
\ No newline at end of file
diff --git a/frontend/src/router.js b/frontend/src/router.js
index 1602093..9d8f707 100644
--- a/frontend/src/router.js
+++ b/frontend/src/router.js
@@ -2,6 +2,7 @@ import {createRouter, createWebHistory} from 'vue-router'
import Index from '@/views/Index.vue';
import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue';
+import store from '@/store';
const routes = [
@@ -20,7 +21,7 @@ const router = createRouter({
router.beforeEach((to/*, from*/) => {
// instead of having to check every route record with
// to.matched.some(record => record.meta.requiresAuth)
- if (to.meta.requiresAuth && false) {
+ if (to.meta.requiresAuth && !store.getters.isLoggedIn) {
// this route requires auth, check if logged in
// if not, redirect to login page.
console.log("Not logged in, redirecting to login page")
diff --git a/frontend/src/store.js b/frontend/src/store.js
new file mode 100644
index 0000000..d978fac
--- /dev/null
+++ b/frontend/src/store.js
@@ -0,0 +1,144 @@
+import {createStore} from 'vuex';
+import router from '@/router';
+import FallBackResolver from "@/dns";
+import NeighborsCache from "@/neigbors";
+import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
+
+
+export default createStore({
+ state: {
+ local_loaded: false,
+ last_load: {},
+ user: null,
+ token: null,
+ keypair: null,
+ remember: false,
+ home_servers: null,
+ resolver: new FallBackResolver(),
+ unreachable_neighbors: new NeighborsCache(),
+ },
+ mutations: {
+ setUser(state, user) {
+ state.user = user;
+ if (state.remember)
+ localStorage.setItem('user', user);
+ },
+ 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);
+ },
+ setHomeServers(state, home_servers) {
+ state.home_servers = home_servers;
+ },
+ logout(state) {
+ state.user = null;
+ state.token = null;
+ state.keypair = null;
+ 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)
+ }
+ }
+ state.cache_loaded = true;
+ }
+ },
+ actions: {
+ 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}))
+ //const data = await fetch('/auth/token/', {
+ // method: 'POST',
+ // headers: {'Content-Type': 'application/json'},
+ // body: JSON.stringify({username: username, password: password}),
+ // credentials: 'omit'
+ //}).then(r => r.json())
+ 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)
+ return true;
+ } else {
+ return false;
+ }
+ },
+ async lookupServer({state}, {username}) {
+ const domain = username.split('@')[1]
+ if (domain === 'localhost')
+ return ['localhost:5173'];
+ if (domain === 'example.com')
+ return ['localhost:5173'];
+ if (domain === 'example.jedi')
+ return ['localhost:5173'];
+ const request = '_toolshed-server._tcp.' + domain + '.'
+ return await state.resolver.query(request, 'SRV').then(
+ (result) => result.map(
+ (answer) => answer.target + ':' + answer.port))
+ },
+ async getHomeServers({state, dispatch, commit}) {
+ if (state.home_servers)
+ return state.home_servers
+ const promise = dispatch('lookupServer', {username: state.user}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
+ commit('setHomeServers', promise)
+ return promise
+ },
+ async getFriendServers({state, dispatch, commit}, {username}) {
+ return dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
+ },
+ },
+ 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;
+ },
+ signAuth(state) {
+ return createSignAuth(state.user, state.keypair.signSk)
+ },
+ tokenAuth(state) {
+ return createTokenAuth(state.token)
+ },
+ nullAuth(state) {
+ return createNullAuth({})
+ },
+ }
+})
\ No newline at end of file
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
index 7a2097b..b53ec8e 100644
--- a/frontend/src/views/Login.vue
+++ b/frontend/src/views/Login.vue
@@ -24,7 +24,9 @@
+ {{ errors.username }}
@@ -65,6 +67,7 @@
diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue
index f0091d0..4f0d05a 100644
--- a/frontend/src/views/Register.vue
+++ b/frontend/src/views/Register.vue
@@ -44,6 +44,7 @@
@@ -51,6 +52,7 @@
@@ -58,6 +60,7 @@
@@ -86,6 +89,10 @@