Compare commits

..

1 commit

Author SHA1 Message Date
37350f59c3 stash
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 18:51:44 +02:00
16 changed files with 156 additions and 1085 deletions

View file

@ -1,21 +1,16 @@
# toolshed # toolshed
## Development ## Installation / Development
``` bash ``` bash
git clone https://github.com/gr4yj3d1/toolshed.git git clone https://github.com/gr4yj3d1/toolshed.git
``` ```
or or
``` bash ``` bash
git clone https://git.neulandlabor.de/j3d1/toolshed.git git clone https://git.neulandlabor.de/j3d1/toolshed.git
``` ```
all following development mode commands support auto-reloading and hot-reloading where applicable, they do not need to bw ### Backend
restarted after changes.
### Backend only
``` bash ``` bash
cd toolshed/backend cd toolshed/backend
@ -25,11 +20,9 @@ pip install -r requirements.txt
python configure.py python configure.py
python manage.py runserver 0.0.0.0:8000 --insecure python manage.py runserver 0.0.0.0:8000 --insecure
``` ```
to run this in properly in production, you need to configure a webserver to serve the static files and proxy the requests to the backend, then run the backend with just `python manage.py runserver` without the `--insecure` flag.
to run this in properly in production, you need to configure a webserver to serve the static files and proxy the ### Frontend
requests to the backend, then run the backend with just `python manage.py runserver` without the `--insecure` flag.
### Frontend only
``` bash ``` bash
cd toolshed/frontend cd toolshed/frontend
@ -37,44 +30,14 @@ npm install
npm run dev npm run dev
``` ```
### Docs only ### Docs
``` bash ``` bash
cd toolshed/docs cd toolshed/docs
mkdocs serve mkdocs serve
``` ```
### Full stack
``` bash
cd toolshed
docker-compose -f deploy/docker-compose.override.yml up --build
```
## Deployment
### Requirements
- python3
- python3-pip
- python3-venv
- wget
- unzip
- nginx
- uwsgi
### Installation
* Get the latest release from
`https://git.neulandlabor.de/j3d1/toolshed/releases/download/<version>/toolshed.zip` or
`https://github.com/gr4yj3d1/toolshed/archive/refs/tags/<version>.zip`.
* Unpack it to `/var/www` or wherever you want to install toolshed.
* Create a virtual environment and install the requirements.
* Then run the configuration script.
* Configure your webserver to serve the static files and proxy the requests to the backend.
* Configure your webserver to run the backend with uwsgi.
for detailed instructions see [docs](/docs/deployment.md).
## CLI Client ## CLI Client

View file

@ -8,18 +8,9 @@ import dotenv
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
class CmdCtx: def yesno(prompt, default=False):
if not sys.stdin.isatty():
def __init__(self, args):
self.args = args
def yesno(self, prompt, default=False):
if not sys.stdin.isatty() or self.args.noninteractive:
return default return default
elif self.args.yes:
return True
elif self.args.no:
return False
yes = {'yes', 'y', 'ye'} yes = {'yes', 'y', 'ye'}
no = {'no', 'n'} no = {'no', 'n'}
@ -40,9 +31,9 @@ class CmdCtx:
print('Please respond with "yes" or "no"') print('Please respond with "yes" or "no"')
def configure(ctx): def configure():
if not os.path.exists('.env'): if not os.path.exists('.env'):
if not ctx.yesno("the .env file does not exist, do you want to create it?", default=True): if not yesno("the .env file does not exist, do you want to create it?", default=True):
print('Aborting') print('Aborting')
exit(0) exit(0)
if not os.path.exists('.env.dist'): if not os.path.exists('.env.dist'):
@ -65,7 +56,7 @@ def configure(ctx):
current_hosts = os.getenv('ALLOWED_HOSTS') current_hosts = os.getenv('ALLOWED_HOSTS')
print('Current ALLOWED_HOSTS: {}'.format(current_hosts)) print('Current ALLOWED_HOSTS: {}'.format(current_hosts))
if ctx.yesno("Do you want to add ALLOWED_HOSTS?"): if yesno("Do you want to add ALLOWED_HOSTS?"):
hosts = input("Enter a comma-separated list of allowed hosts: ") hosts = input("Enter a comma-separated list of allowed hosts: ")
joined_hosts = current_hosts + ',' + hosts if current_hosts else hosts joined_hosts = current_hosts + ',' + hosts if current_hosts else hosts
dotenv.set_key('.env', 'ALLOWED_HOSTS', joined_hosts) dotenv.set_key('.env', 'ALLOWED_HOSTS', joined_hosts)
@ -76,21 +67,20 @@ def configure(ctx):
django.setup() django.setup()
if not os.path.exists('db.sqlite3'): if not os.path.exists('db.sqlite3'):
if not ctx.yesno("No database found, do you want to create one?", default=True): if not yesno("No database found, do you want to create one?", default=True):
print('Aborting') print('Aborting')
exit(0) exit(0)
from django.core.management import call_command from django.core.management import call_command
call_command('migrate') call_command('migrate')
if ctx.yesno("Do you want to create a superuser?"): if yesno("Do you want to create a superuser?"):
from django.core.management import call_command from django.core.management import call_command
call_command('createsuperuser') call_command('createsuperuser')
call_command('collectstatic', '--no-input') call_command('collectstatic', '--no-input')
if ctx.yesno("Do you want to import all categories, properties and tags contained in this repository?", if yesno("Do you want to import all categories, properties and tags contained in this repository?", default=True):
default=True):
from hostadmin.serializers import CategorySerializer, PropertySerializer, TagSerializer from hostadmin.serializers import CategorySerializer, PropertySerializer, TagSerializer
from hostadmin.models import ImportedIdentifierSets from hostadmin.models import ImportedIdentifierSets
from hashlib import sha256 from hashlib import sha256
@ -206,7 +196,6 @@ def main():
parser = ArgumentParser(description='Toolshed Server Configuration') parser = ArgumentParser(description='Toolshed Server Configuration')
parser.add_argument('--yes', '-y', help='Answer yes to all questions', action='store_true') parser.add_argument('--yes', '-y', help='Answer yes to all questions', action='store_true')
parser.add_argument('--no', '-n', help='Answer no to all questions', action='store_true') parser.add_argument('--no', '-n', help='Answer no to all questions', action='store_true')
parser.add_argument('--noninteractive', '-x', help="Run in noninteractive mode", action='store_true')
parser.add_argument('cmd', help='Command', default='configure', nargs='?') parser.add_argument('cmd', help='Command', default='configure', nargs='?')
args = parser.parse_args() args = parser.parse_args()
@ -214,10 +203,8 @@ def main():
print('Error: --yes and --no are mutually exclusive') print('Error: --yes and --no are mutually exclusive')
exit(1) exit(1)
ctx = CmdCtx(args)
if args.cmd == 'configure': if args.cmd == 'configure':
configure(ctx) configure()
elif args.cmd == 'reset': elif args.cmd == 'reset':
reset() reset()
elif args.cmd == 'testdata': elif args.cmd == 'testdata':

View file

@ -0,0 +1,54 @@
version: '3.8'
services:
backend:
build:
context: ../backend/
dockerfile: ../deploy/dev/Dockerfile.backend
volumes:
- ../backend:/code
expose:
- 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
frontend:
build:
context: ../frontend/
dockerfile: ../deploy/dev/Dockerfile.frontend
volumes:
- ../frontend:/app
expose:
- 5173
command: npm run dev -- --host
wiki:
build:
context: ../
dockerfile: deploy/dev/Dockerfile.wiki
volumes:
- ../mkdocs.yml:/wiki/mkdocs.yml
- ../docs:/wiki/docs
expose:
- 8001
command: mkdocs serve --dev-addr=0.0.0.0:8001
nginx:
image: nginx:latest
volumes:
- ./dev/fullchain.pem2:/etc/nginx/nginx.crt
- ./dev/privkey.pem2:/etc/nginx/nginx.key
- ./dev/nginx-instance_a.dev.conf:/etc/nginx/nginx.conf
- ./dev/dns.json:/var/www/dns.json
- ./dev/domains.json:/var/www/domains.json
ports:
- 8080:8080
- 5353:5353
dns:
build:
context: ./dev/
dockerfile: Dockerfile.dns
volumes:
- ./dev/zone.json:/dns/zone.json
expose:
- 8053

View file

@ -0,0 +1,15 @@
# Use an official Node.js runtime as instance_a parent image
FROM node:14
# Set work directory
WORKDIR /app
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package.json ./
RUN npm install
# If you are building your code for production
# RUN npm ci --only=production
CMD [ "npm", "run", "dev", "--", "--host"]

View file

@ -0,0 +1,14 @@
ROM node:alpine as builder
WORKDIR /app
COPY ./package.json /app/package.json
COPY . /app
RUN npm ci --only=production
RUN npm run build
FROM nginx:alpine as runner
#RUN apk add --update npm
WORKDIR /app
COPY --from=builder /app/dist /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/nginx.conf
EXPOSE 80

View file

@ -1,102 +0,0 @@
# Deployment
## Native
### Requirements
- python3
- python3-pip
- python3-venv
- wget
- unzip
- nginx
- uwsgi
- certbot
### Installation
Get the latest release:
``` bash
cd /var/www # or wherever you want to install toolshed
wget https://git.neulandlabor.de/j3d1/toolshed/releases/download/<version>/toolshed.zip
```
or from github:
``` bash
cd /var/www # or wherever you want to install toolshed
wget https://github.com/gr4yj3d1/toolshed/archive/refs/tags/<version>.zip -O toolshed.zip
```
Extract and configure the backend:
``` bash
unzip toolshed.zip
cd toolshed/backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python configure.py
```
Configure uWSGI to serve the backend locally:
``` bash
cd /var/www/toolshed/backend
cp toolshed.ini /etc/uwsgi/apps-available/
ln -s /etc/uwsgi/apps-available/toolshed.ini /etc/uwsgi/apps-enabled/
systemctl restart uwsgi
```
Configure nginx to serve the static files and proxy the requests to the backend:
``` bash
cd /var/www/toolshed/backend
cp toolshed.nginx /etc/nginx/sites-available/toolshed
ln -s /etc/nginx/sites-available/toolsheed /etc/nginx/sites-enabled/
systemctl restart nginx
```
Configure certbot to get a certificate for the domain:
``` bash
certbot --nginx -d <domain>
```
### Update
``` bash
cd /var/www
wget https://git.neulandlabor.de/j3d1/toolshed/releases/download/<version>/toolshed.zip
unzip toolshed.zip
cd toolshed/backend
source venv/bin/activate
pip install -r requirements.txt
python configure.py
systemctl restart uwsgi
```
## Docker
### Requirements
- docker
- docker-compose
- git
### Installation
``` bash
git clone https://git.neulandlabor.de/j3d1/toolshed.git
# or
git clone https://github.com/gr4yj3d1/toolshed.git
cd toolshed
docker-compose -f deploy/docker-compose.prod.yml up -d --build
```
### Update
``` bash
toolshed
git pull
docker-compose -f deploy/docker-compose.prod.yml up -d --build
```

View file

@ -1,105 +0,0 @@
# Development
``` bash
git clone https://github.com/gr4yj3d1/toolshed.git
```
or
``` bash
git clone https://git.neulandlabor.de/j3d1/toolshed.git
```
## Native
To a certain extent, the frontend and backend can be developed independently. The frontend is a Vue.js project and the
backend is a DRF (Django-Rest-Framework) project. If you want to develop the frontend, you can do so without the backend
and vice
versa. However, especially for the frontend, it is recommended to use the backend as well, as the frontend does not have
a lot of 'offline' functionality.
If you want to run the fullstack application, it is recommended to use the [docker-compose](#docker) method.
### Frontend
install `node.js` and `npm`
on Debian* for example: `sudo apt install npm`
``` bash
cd toolshed/frontend
npm install
npm run dev
```
### Backend
Install `python3`, `pip` and `virtualenv`
on Debian* for example: `sudo apt install python3 python3-pip python3-venv`
Prepare backend environment
``` bash
cd toolshed/backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
Run the test suite:
``` bash
python manage.py test
```
optionally with coverage:
``` bash
coverage run manage.py test
coverage report
```
Start the backend in development mode:
``` bash
python manage.py migrate
cp .env.dist .env
echo "DEBUG = True" >> .env
python manage.py runserver 0.0.0.0:8000
```
provides the api docs at `http://localhost:8000/docs/`
### Docs (Wiki)
Install `mkdocs`
on Debian* for example: `sudo apt install mkdocs`
Start the docs server:
``` bash
cd toolshed/docs
mkdocs serve -a 0.0.0.0:8080
```
## Docker
### Fullstack
Install `docker` and `docker-compose`
on Debian* for example: `sudo apt install docker.io docker-compose`
Start the fullstack application:
``` bash
docker-compose -f deploy/docker-compose.override.yml up --build
```
This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend.
The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH
server is used to direct the frontend to the correct backend instance.
The frontend is configured to act as if it was served from the domain `a.localhost`.
Access the frontend at `http://localhost:8080/`, backend at `http://localhost:8080/api/`, api docs
at `http://localhost:8080/docs/` and the wiki at `http://localhost:8080/wiki/`.

View file

@ -1,23 +0,0 @@
# Federation
This section will cover how federation works in Toolshed.
## What is Federation?
Since user of Toolshed you can search and interact the inventory of all their 'friends' that are potentially on
different servers there is a need for a way to communicate between servers. We don't want to rely on a central server that
stores all the data and we don't want to have a central server that handles all the communication between servers. This
is where federation comes in. Toolshed uses a protocol that can not only exchange data with the server where the user
is registered but also with the servers where their friends are registered.
## How does it work?
Any user can register on any server and creates a personal key pair. The public key is stored on the server and the private
key is stored on the client. The private key is used to sign all requests to the server and the public key is used to
verify the signature. Once a user has registered on a server they can send friend requests to other users containing
their public key. If the other user accepts the friend request, the server stores the public key of the friend and
uses it to verify access to the friend's inventory. While accepting a friend request the user also automatically sends
their own public key to the friend's server. This way both users can access each other's inventory.
The protocol is based on a simple HTTPS API exchanging JSON data that is signed with the user's private key. By default
Toolshed servers provide a documentation of the API at [/docs/api](/docs/api).

View file

@ -6,8 +6,47 @@ This is the documentation for the Toolshed project. It is a work in progress.
`#social` `#network` `#federation` `#decentralized` `#federated` `#socialnetwork` `#fediverse` `#community` `#hashtags` `#social` `#network` `#federation` `#decentralized` `#federated` `#socialnetwork` `#fediverse` `#community` `#hashtags`
## Getting Started ## Getting Started
- [Deploying Toolshed](deployment.md)
- [Development Setup](development.md)
- [About Federation](federation.md)
## Installation
``` bash
# TODO add installation instructions
# similar to development instructions just with more docker
# TODO add docker-compose.yml
```
## Development
``` bash
git clone https://github.com/gr4yj3d1/toolshed.git
```
or
``` bash
git clone https://git.neulandlabor.de/j3d1/toolshed.git
```
### Frontend
``` bash
cd toolshed/frontend
npm install
npm run dev
```
### Backend
``` bash
cd toolshed/backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver 0.0.0.0:8000
```
### Docs
``` bash
cd toolshed/docs
mkdocs serve -a 0.0.0.0:8080
```

View file

@ -1,6 +1,5 @@
<template> <template>
<div class="wrapper"> <div class="wrapper">
<Sidebar/>
<div class="main"> <div class="main">
<nav class="navbar navbar-expand navbar-light navbar-bg"> <nav class="navbar navbar-expand navbar-light navbar-bg">
<a class="sidebar-toggle d-flex" @click="toggleSidebar"> <a class="sidebar-toggle d-flex" @click="toggleSidebar">
@ -15,13 +14,11 @@
<script> <script>
import Footer from "@/components/Footer.vue"; import Footer from "@/components/Footer.vue";
import Sidebar from "@/components/Sidebar.vue";
export default { export default {
name: 'BaseLayout', name: 'BaseLayout',
components: { components: {
Footer, Footer
Sidebar
}, },
props: { props: {
hideSearch: { hideSearch: {
@ -32,8 +29,6 @@ export default {
}, },
methods: { methods: {
toggleSidebar() { toggleSidebar() {
closeAllDropdowns();
document.getElementById("sidebar").classList.toggle("collapsed");
}, },
}, },
} }

View file

@ -1,181 +0,0 @@
<template>
<nav id="sidebar" class="sidebar">
<div class="sidebar-content">
<router-link to="/" class="sidebar-brand">
<img src="/src/assets/icons/toolshed-48x48.png" alt="Toolshed Logo" class="align-middle logo mr-2 h-75">
<span class="align-middle">Toolshed</span>
</router-link>
<ul class="sidebar-nav">
<li class="sidebar-header">
Tools & Components
</li>
</ul>
</div>
</nav>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
export default {
name: "Sidebar",
components: {
...BIcons
},
}
</script>
<style scoped>
.sidebar {
min-width: 260px;
max-width: 260px;
direction: ltr;
}
.sidebar, .sidebar-content {
transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out;
background: #222e3c;
}
.sidebar-content {
display: flex;
height: 100vh;
flex-direction: column;
}
.sidebar {
min-width: 260px;
max-width: 260px;
direction: ltr
}
.sidebar, .sidebar-content {
transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out;
background: #222e3c
}
.sidebar-content {
display: flex;
height: 100vh;
flex-direction: column
}
.sidebar-nav {
padding-left: 0;
margin-bottom: 0;
list-style: none;
flex-grow: 1
}
.sidebar-link i, .sidebar-link svg, a.sidebar-link i, a.sidebar-link svg {
margin-right: .75rem;
color: rgba(233, 236, 239, .5)
}
.sidebar-item.active .sidebar-link:hover, .sidebar-item.active > .sidebar-link {
color: #e9ecef;
background: linear-gradient(90deg, rgba(59, 125, 221, .1), rgba(59, 125, 221, .0875) 50%, transparent);
border-left-color: #3b7ddd
}
.sidebar-item.active .sidebar-link:hover i, .sidebar-item.active .sidebar-link:hover svg, .sidebar-item.active > .sidebar-link i, .sidebar-item.active > .sidebar-link svg {
color: #e9ecef
}
.sidebar-dropdown .sidebar-link {
padding: .625rem 1.5rem .625rem 3.25rem;
font-weight: 400;
font-size: 90%;
border-left: 0;
color: #adb5bd;
background: transparent
}
.sidebar-dropdown .sidebar-link:before {
content: "→";
display: inline-block;
position: relative;
left: -14px;
transition: all .1s ease;
transform: translateX(0)
}
.sidebar-dropdown .sidebar-item .sidebar-link:hover {
font-weight: 400;
border-left: 0;
color: #e9ecef;
background: transparent
}
.sidebar-dropdown .sidebar-item .sidebar-link:hover:hover:before {
transform: translateX(4px)
}
.sidebar-dropdown .sidebar-item.active .sidebar-link {
font-weight: 400;
border-left: 0;
color: #518be1;
background: transparent
}
.sidebar [data-toggle=collapse] {
position: relative
}
.sidebar [data-toggle=collapse]:after {
content: " ";
border: solid;
border-width: 0 .075rem .075rem 0;
display: inline-block;
padding: 2px;
transform: rotate(45deg);
position: absolute;
top: 1.2rem;
right: 1.5rem;
transition: all .2s ease-out
}
.sidebar [aria-expanded=true]:after, .sidebar [data-toggle=collapse]:not(.collapsed):after {
transform: rotate(-135deg);
top: 1.4rem
}
.sidebar-brand {
font-weight: 600;
font-size: 1.15rem;
padding: 1.15rem 1.5rem;
display: block;
color: #f8f9fa
}
.sidebar-brand:hover {
text-decoration: none;
color: #f8f9fa
}
.sidebar-brand:focus {
outline: 0
}
.sidebar.collapsed {
margin-left: -260px
}
@media (min-width: 1px) and (max-width: 991.98px) {
.sidebar {
margin-left: -260px
}
.sidebar.collapsed {
margin-left: 0
}
}
.sidebar-header {
background: transparent;
padding: 1.5rem 1.5rem .375rem;
font-size: .75rem;
color: #ced4da
}
</style>

View file

@ -1,50 +0,0 @@
import {query} from 'dns-query';
function get_prefered_server() {
try {
const servers = JSON.parse(localStorage.getItem('dns-servers'));
if (servers && servers.length > 0) {
return servers;
}
} catch (e) {
console.error(e);
}
const request = new XMLHttpRequest();
request.open('GET', '/local/dns', false);
request.send(null);
if (request.status === 200) {
const servers = JSON.parse(request.responseText);
if (servers && servers.length > 0) {
return servers;
}
}
return ['1.1.1.1', '8.8.8.8'];
}
class FallBackResolver {
constructor() {
this._servers = get_prefered_server();
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);
return [this._cache[key].data];
}
const result = await query(
{question: {type: type, name: domain}},
{
endpoints: this._servers,
}
)
if (result.answers.length === 0) throw new Error('No answer');
const first = result.answers[0];
this._cache[key] = {time: Date.now(), ...first}; // TODO hadle multiple answers
localStorage.setItem('dns-cache', JSON.stringify(this._cache));
return [first.data];
}
}
export default FallBackResolver;

View file

@ -1,324 +0,0 @@
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};

View file

@ -5,43 +5,12 @@ import App from './App.vue'
import './scss/toolshed.scss' import './scss/toolshed.scss'
import router from './router' import router from './router'
import store from './store';
import _nacl from 'js-nacl'; import _nacl from 'js-nacl';
const app = createApp(App).use(store).use(BootstrapIconsPlugin); const app = createApp(App).use(BootstrapIconsPlugin);
_nacl.instantiate((nacl) => { _nacl.instantiate((nacl) => {
window.nacl = nacl window.nacl = nacl
app.use(router).mount('#app') app.use(router).mount('#app')
}); });
window.closeAllDropdowns = function () {
const dropdowns = document.getElementsByClassName("dropdown-menu");
let i;
for (i = 0; i < dropdowns.length; i++) {
const openDropdown = dropdowns[i];
if (openDropdown.classList.contains('show')) {
openDropdown.classList.remove('show');
}
}
}
window.onclick = function (event) {
if (!event.target.matches('.dropdown-toggle *')
&& !event.target.matches('.dropdown-toggle')
&& !event.target.matches('.dropdown-menu *')
&& !event.target.matches('.dropdown-menu')) {
closeAllDropdowns();
}
if (!event.target.matches('.sidebar-toggle *')
&& !event.target.matches('.sidebar-toggle')
&& !event.target.matches('.sidebar *')
&& !event.target.matches('.sidebar')) {
const sidebar = document.getElementById("sidebar");
const marginLeft = parseInt(getComputedStyle(sidebar).marginLeft);
if (sidebar.classList.contains('collapsed') && marginLeft === 0) {
sidebar.classList.remove('collapsed');
}
}
}

View file

@ -1,48 +0,0 @@
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;

View file

@ -1,132 +0,0 @@
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}))
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]
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({})
},
}
})