diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..38adffa --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..8d6c5ef --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,29 @@ +# frontend + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). + +## Customize configuration + +See [Vite Configuration Reference](https://vitejs.dev/config/). + +## Project Setup + +```sh +npm install +``` + +### Compile and Hot-Reload for Development + +```sh +npm run dev +``` + +### Compile and Minify for Production + +```sh +npm run build +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b72e945 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + Toolshed + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6ab03b4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "frontend", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "bootstrap-icons-vue": "^1.10.3", + "dns-query": "^0.11.2", + "js-nacl": "^1.4.0", + "vue": "^3.2.47", + "vue-router": "^4.1.6", + "vuex": "^4.1.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.0.0", + "sass": "^1.62.1", + "vite": "^4.1.4" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..54ad06d --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,28 @@ + + + + + + + diff --git a/frontend/src/components/BaseLayout.vue b/frontend/src/components/BaseLayout.vue new file mode 100644 index 0000000..b5ff611 --- /dev/null +++ b/frontend/src/components/BaseLayout.vue @@ -0,0 +1,294 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/dns.js b/frontend/src/dns.js new file mode 100644 index 0000000..70f2ba5 --- /dev/null +++ b/frontend/src/dns.js @@ -0,0 +1,30 @@ +import {query} from 'dns-query'; + +class FallBackResolver { + constructor() { + this._servers = ['1.1.1.1', '8.8.8.8']; + this._cache = JSON.parse(localStorage.getItem('dns-cache')) || {}; + } + + async query(domain, type) { + const key = domain + ':' + type; + if (key in this._cache && this._cache[key].time > Date.now() - 1000 * 60 * 60) { + const age_seconds = Math.ceil(Date.now() / 1000 - this._cache[key].time / 1000); + console.log('cache hit', key, this._cache[key].ttl - age_seconds); + return [this._cache[key].data]; + } + const result = await query( + {question: {type: type, name: domain}}, + { + endpoints: this._servers, + } + ) + const first = result.answers[0]; + this._cache[key] = {time: Date.now(), ...first}; // TODO hadle multiple answers + localStorage.setItem('dns-cache', JSON.stringify(this._cache)); + console.log('cache miss', key, first.ttl); + return [first.data]; + } +} + +export default FallBackResolver; \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..095e5e0 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,19 @@ +import {createApp} from 'vue' +//import { BootstrapVue, BootstrapVueIcons } from 'bootstrap-vue' +import { BootstrapIconsPlugin } from 'bootstrap-icons-vue'; +import App from './App.vue' + +import './assets/css/toolshed.css' +import './assets/js/app.js' + +import router from './router' +import store from './store'; + +import _nacl from 'js-nacl'; + +const app = createApp(App).use(router).use(store).use(BootstrapIconsPlugin); + +_nacl.instantiate((nacl) => { + window.nacl = nacl + app.mount('#app') +}); diff --git a/frontend/src/neigbors.js b/frontend/src/neigbors.js new file mode 100644 index 0000000..8a205c0 --- /dev/null +++ b/frontend/src/neigbors.js @@ -0,0 +1,38 @@ +class NeighborsCache { + constructor() { + //this._max_age = 1000 * 60 * 60; // 1 hour + this._max_age = 1000 * 60 * 5; // 5 minutes + 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; + } +} + +export default NeighborsCache; \ No newline at end of file diff --git a/frontend/src/router.js b/frontend/src/router.js new file mode 100644 index 0000000..75ea992 --- /dev/null +++ b/frontend/src/router.js @@ -0,0 +1,49 @@ +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'; +import Profile from '@/views/Profile.vue'; +import Settings from '@/views/Settings.vue'; +import Inventory from '@/views/Inventory.vue'; +import Friends from "@/views/Friends.vue"; +import InventoryNew from "@/views/InventoryNew.vue"; +import InventoryEdit from "@/views/InventoryEdit.vue"; +import InventoryDetail from "@/views/InventoryDetail.vue"; + + +const routes = [ + {path: '/', component: Index, meta: {requiresAuth: true}}, + {path: '/profile', component: Profile, meta: {requiresAuth: true}}, + {path: '/settings', component: Settings, meta: {requiresAuth: true}}, + {path: '/inventory', component: Inventory, meta: {requiresAuth: true}}, + {path: '/inventory/:id', component: InventoryDetail, meta: {requiresAuth: true}}, + {path: '/inventory/:id/edit', component: InventoryEdit, meta: {requiresAuth: true}}, + {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, + {path: '/friends', component: Friends, meta: {requiresAuth: true}}, + {path: '/login', component: Login, meta: {requiresAuth: false}}, + {path: '/register', component: Register, meta: {requiresAuth: false}}, +] + +const router = createRouter({ + // 4. Provide the history implementation to use. We are using the hash history for simplicity here. + history: createWebHistory(), + linkActiveClass: "active", + routes, // short for `routes: routes` +}) + +router.beforeEach((to, from) => { + // instead of having to check every route record with + // to.matched.some(record => record.meta.requiresAuth) + if (to.meta.requiresAuth && !store.getters.isLoggedIn) { + // this route requires auth, check if logged in + // if not, redirect to login page. + return { + path: '/login', + // save the location we were at to come back later + query: {redirect: to.fullPath}, + } + } +}) + +export default router \ No newline at end of file diff --git a/frontend/src/store.js b/frontend/src/store.js new file mode 100644 index 0000000..b3475fc --- /dev/null +++ b/frontend/src/store.js @@ -0,0 +1,186 @@ +import {createStore} from 'vuex'; +import router from '@/router'; +import FallBackResolver from "@/dns"; +import NeighborsCache from "@/neigbors"; + + +export default createStore({ + state: { + user: null, + token: null, + keypair: null, + remember: false, + friends: [], + item_map: {}, + resolver: new FallBackResolver(), + unreachable_neighbors: new NeighborsCache(), + /*wk: new Wellknown({ + update: true, // Will load latest definitions from updateURL. + updateURL: new URL(), // URL to load the latest definitions. (default: project URL) + persist: false, // True to persist the loaded definitions (nodejs: in filesystem, browser: localStorage) + localStoragePrefix: 'dnsquery_', // Prefix for files persisted. + maxAge: 300000, // Max age of persisted data to be used in ms. + timeout: 5000 // Timeout when loading updates. + })*/ + }, + 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); + }, + setInventoryItems(state, {url, items}) { + state.item_map[url] = items; + }, + logout(state) { + state.user = null; + state.token = null; + state.keypair = null; + localStorage.removeItem('user'); + localStorage.removeItem('token'); + localStorage.removeItem('keypair'); + router.push('/login'); + }, + init(state) { + 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) + } else { + } + router.push('/'); + } + } + }, + actions: { + async login({commit, dispatch, state}, {username, password, remember}) { + //this.setRemember(remember) + this.commit('setRemember', remember); + /*const response = await fetch('http://10.23.42.128:8000/auth/token/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: username, password: password + }) + })*/ + const data = await dispatch('apiLocalPost', { + target: '/token/', data: { + username: username, password: password + } + }) + if (data.token) { + commit('setToken', data.token); + commit('setUser', username + '@example.com'); + const j = await dispatch('apiLocalGet', {target: '/keys/'}) + const k = j.key + this.commit('setKey', k) + await router.push({path: '/'}); + return true; + } else { + return false; + } + + }, + async getFriends(state) { + return ['jedi@j3d1.de', 'foobar@example.com', 'foobaz@example.eleon']; + }, + async getFriendServer({state}, {username}) { + const domain = username.split('@')[1] + if (domain === 'example.eleon') + return ['10.23.42.186:8000']; + if (domain === 'localhost') + return ['127.0.0.1:8000']; + if (domain === 'example.com') + return ['10.23.42.128:8000']; + const request = '_toolshed-server._tcp.' + domain + '.' + return await state.resolver.query(request, 'SRV').then( + (result) => result.map( + (answer) => answer.target + ':' + answer.port)) + }, + async apiFederatedGet({state}, url) { + if (state.unreachable_neighbors.queryUnreachable(url)) { + throw new Error('unreachable neighbor') + } + const signature = nacl.crypto_sign_detached(nacl.encode_utf8(url), state.keypair.signSk) + const auth = 'Signature ' + state.user + ':' + nacl.to_hex(signature) + return await fetch(url, { + method: 'GET', + headers: { + 'Authorization': auth + } + }).catch( err => state.unreachable_neighbors.unreachable(url) + ).then(response => response.json()) + }, + async apiFederatedPost({state}, {url, data}) { + if (state.unreachable_neighbors.queryUnreachable(url)) { + throw new Error('unreachable neighbor') + } + const json = JSON.stringify(data) + const signature = nacl.crypto_sign_detached(nacl.encode_utf8(url + json), state.keypair.signSk) + const auth = 'Signature ' + state.user + ':' + nacl.to_hex(signature) + return await fetch(url, { + method: 'POST', + headers: { + 'Authorization': auth, + 'Content-Type': 'application/json' + }, + body: json + }).catch( err => state.unreachable_neighbors.unreachable(url) + ).then(response => response.json()) + }, + async apiLocalGet({state}, {target}) { + const auth = state.token ? {'Authorization': 'Token ' + state.token} : {} + return await fetch('http://10.23.42.128:8000/auth' + target, { + method: 'GET', + headers: auth + }).then(response => response.json()) + }, + async apiLocalPost({state}, {target, data}) { + const auth = state.token ? {'Authorization': 'Token ' + state.token} : {} + return await fetch('http://10.23.42.128:8000/auth' + target, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...auth + }, + body: JSON.stringify(data) + }).then(response => response.json()) + } + }, + getters: { + isLoggedIn(state) { + return state.user !== null && state.token !== null; + }, + inventory_items(state) { + return Object.entries(state.item_map).reduce((acc, [url, items]) => { + return acc.concat(items) + }, []) + } + } +}) \ No newline at end of file diff --git a/frontend/src/views/Friends.vue b/frontend/src/views/Friends.vue new file mode 100644 index 0000000..3a21812 --- /dev/null +++ b/frontend/src/views/Friends.vue @@ -0,0 +1,88 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Index.vue b/frontend/src/views/Index.vue new file mode 100644 index 0000000..4a5d040 --- /dev/null +++ b/frontend/src/views/Index.vue @@ -0,0 +1,41 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue new file mode 100644 index 0000000..bb94db5 --- /dev/null +++ b/frontend/src/views/Inventory.vue @@ -0,0 +1,93 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue new file mode 100644 index 0000000..d3bd892 --- /dev/null +++ b/frontend/src/views/InventoryDetail.vue @@ -0,0 +1,65 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/InventoryEdit.vue b/frontend/src/views/InventoryEdit.vue new file mode 100644 index 0000000..f054c83 --- /dev/null +++ b/frontend/src/views/InventoryEdit.vue @@ -0,0 +1,65 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/InventoryNew.vue b/frontend/src/views/InventoryNew.vue new file mode 100644 index 0000000..758761b --- /dev/null +++ b/frontend/src/views/InventoryNew.vue @@ -0,0 +1,65 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..3686f6b --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,97 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue new file mode 100644 index 0000000..50ecade --- /dev/null +++ b/frontend/src/views/Profile.vue @@ -0,0 +1,257 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue new file mode 100644 index 0000000..3396600 --- /dev/null +++ b/frontend/src/views/Register.vue @@ -0,0 +1,88 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..d143c7e --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,209 @@ + + + + + \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..40c16c4 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,28 @@ +import {fileURLToPath, URL} from 'node:url' + +import {defineConfig} from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + host: true, + cors: true, + headers: { + //allow all origins + //'Access-Control-Allow-Origin': 'http://10.23.42.128:8000, http://10.23.42.168:8000', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Origin, Content-Type, X-Auth-Token, Authorization, Accept,charset,boundary,Content-Length', + 'Access-Control-Allow-Credentials': 'true' + + + + } + } +})