Compare commits

...

11 commits

Author SHA1 Message Date
9acf5a97e2 add explicit non-interactive option to configure.py 2024-10-12 16:02:41 +02:00
8d64a3c528 add vuex store and federation layer for api calls 2024-04-13 01:03:23 +02:00
0fd49bc023 add javascript dns resolver using DoH
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-08 20:54:29 +02:00
6b5ccc2be6 restructure docs
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 19:55:47 +02:00
bbc2faeb7b update README.md 2024-04-06 19:55:47 +02:00
48b9e595ff frontend: add collapsable sidebar
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 18:54:54 +02:00
f8dacef309 development setup using docker 2024-04-06 18:48:53 +02:00
c4c49931a4 frontend: add /login and /register forms 2024-04-06 18:47:41 +02:00
bea56f101a add frontend skeleton 2024-04-06 18:42:39 +02:00
8cf0897ec5 make dataset parsing more robust
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-16 19:58:22 +01:00
f2db5d9dad fix inconsistencies between policy api ant combined info api
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-07 00:44:58 +01:00
60 changed files with 5553 additions and 111 deletions

3
.gitignore vendored
View file

@ -130,4 +130,5 @@ dmypy.json
staticfiles/
userfiles/
testdata.py
testdata.py
*.sqlite3

View file

@ -1,16 +1,21 @@
# toolshed
## Installation / Development
## Development
``` bash
git clone https://github.com/gr4yj3d1/toolshed.git
```
or
``` bash
git clone https://git.neulandlabor.de/j3d1/toolshed.git
```
### Backend
all following development mode commands support auto-reloading and hot-reloading where applicable, they do not need to bw
restarted after changes.
### Backend only
``` bash
cd toolshed/backend
@ -20,9 +25,11 @@ pip install -r requirements.txt
python configure.py
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.
### Frontend
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.
### Frontend only
``` bash
cd toolshed/frontend
@ -30,14 +37,44 @@ npm install
npm run dev
```
### Docs
### Docs only
``` bash
cd toolshed/docs
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

View file

@ -6,3 +6,5 @@
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# GitHub Copilot persisted chat sessions
/copilot/chatSessions

View file

@ -8,32 +8,41 @@ import dotenv
from django.db import transaction, IntegrityError
def yesno(prompt, default=False):
if not sys.stdin.isatty():
return default
yes = {'yes', 'y', 'ye'}
no = {'no', 'n'}
class CmdCtx:
if default:
yes.add('')
else:
no.add('')
def __init__(self, args):
self.args = args
hint = ' [Y/n] ' if default else ' [y/N] '
while True:
choice = input(prompt + hint).lower()
if choice in yes:
def yesno(self, prompt, default=False):
if not sys.stdin.isatty() or self.args.noninteractive:
return default
elif self.args.yes:
return True
elif choice in no:
elif self.args.no:
return False
yes = {'yes', 'y', 'ye'}
no = {'no', 'n'}
if default:
yes.add('')
else:
print('Please respond with "yes" or "no"')
no.add('')
hint = ' [Y/n] ' if default else ' [y/N] '
while True:
choice = input(prompt + hint).lower()
if choice in yes:
return True
elif choice in no:
return False
else:
print('Please respond with "yes" or "no"')
def configure():
def configure(ctx):
if not os.path.exists('.env'):
if not yesno("the .env file does not exist, do you want to create it?", default=True):
if not ctx.yesno("the .env file does not exist, do you want to create it?", default=True):
print('Aborting')
exit(0)
if not os.path.exists('.env.dist'):
@ -56,7 +65,7 @@ def configure():
current_hosts = os.getenv('ALLOWED_HOSTS')
print('Current ALLOWED_HOSTS: {}'.format(current_hosts))
if yesno("Do you want to add ALLOWED_HOSTS?"):
if ctx.yesno("Do you want to add ALLOWED_HOSTS?"):
hosts = input("Enter a comma-separated list of allowed hosts: ")
joined_hosts = current_hosts + ',' + hosts if current_hosts else hosts
dotenv.set_key('.env', 'ALLOWED_HOSTS', joined_hosts)
@ -67,26 +76,29 @@ def configure():
django.setup()
if not os.path.exists('db.sqlite3'):
if not yesno("No database found, do you want to create one?", default=True):
if not ctx.yesno("No database found, do you want to create one?", default=True):
print('Aborting')
exit(0)
from django.core.management import call_command
call_command('migrate')
if yesno("Do you want to create a superuser?"):
if ctx.yesno("Do you want to create a superuser?"):
from django.core.management import call_command
call_command('createsuperuser')
call_command('collectstatic', '--no-input')
if yesno("Do you want to import all categories, properties and tags contained in this repository?", default=True):
if ctx.yesno("Do you want to import all categories, properties and tags contained in this repository?",
default=True):
from hostadmin.serializers import CategorySerializer, PropertySerializer, TagSerializer
from hostadmin.models import ImportedIdentifierSets
from hashlib import sha256
if not os.path.exists('shared_data'):
os.mkdir('shared_data')
files = os.listdir('shared_data')
idsets = {}
hashes = {}
for file in files:
if file.endswith('.json'):
name = "git:" + file[:-5]
@ -94,6 +106,8 @@ def configure():
try:
idset = json.load(f)
idsets[name] = idset
f.seek(0)
hashes[name] = sha256(f.read().encode()).hexdigest()
except json.decoder.JSONDecodeError:
print('Error: invalid JSON in file {}'.format(file))
imported_sets = ImportedIdentifierSets.objects.all()
@ -108,9 +122,13 @@ def configure():
unmet_deps = [dep for dep in idset['depends'] if not imported_sets.filter(name=dep).exists()]
if unmet_deps:
if all([dep in idsets.keys() for dep in unmet_deps]):
print('Not all dependencies for {} are imported, postponing'.format(name))
queue.append(name)
continue
if all([dep in queue for dep in unmet_deps]):
print('Not all dependencies for {} are imported, postponing'.format(name))
queue.append(name)
continue
else:
print('Error: unresolvable dependencies for {}: {}'.format(name, unmet_deps))
continue
else:
print('unknown dependencies for {}: {}'.format(name, unmet_deps))
continue
@ -131,10 +149,15 @@ def configure():
serializer = TagSerializer(data=tag)
if serializer.is_valid():
serializer.save(origin=name)
imported_sets.create(name=name)
imported_sets.create(name=name, hash=hashes[name])
except IntegrityError:
print('Error: integrity error while importing {}\n\tmight be cause by name conflicts with existing'
' categories, properties or tags'.format(name))
transaction.set_rollback(True)
continue
except Exception as e:
print('Error: {}'.format(e))
transaction.set_rollback(True)
continue
@ -183,6 +206,7 @@ def main():
parser = ArgumentParser(description='Toolshed Server Configuration')
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('--noninteractive', '-x', help="Run in noninteractive mode", action='store_true')
parser.add_argument('cmd', help='Command', default='configure', nargs='?')
args = parser.parse_args()
@ -190,8 +214,10 @@ def main():
print('Error: --yes and --no are mutually exclusive')
exit(1)
ctx = CmdCtx(args)
if args.cmd == 'configure':
configure()
configure(ctx)
elif args.cmd == 'reset':
reset()
elif args.cmd == 'testdata':

View file

@ -1,6 +1,6 @@
from django.contrib import admin
from .models import Domain
from .models import Domain, ImportedIdentifierSets
class DomainAdmin(admin.ModelAdmin):
@ -9,3 +9,11 @@ class DomainAdmin(admin.ModelAdmin):
admin.site.register(Domain, DomainAdmin)
class ImportedIdentifierSetsAdmin(admin.ModelAdmin):
list_display = ('name', 'hash', 'created_at')
list_filter = ('name', 'hash', 'created_at')
admin.site.register(ImportedIdentifierSets, ImportedIdentifierSetsAdmin)

View file

@ -0,0 +1,39 @@
# Generated by Django 4.2.2 on 2024-03-11 15:19
import os
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hostadmin', '0002_importedidentifiersets'),
]
def calculate_hash(apps, schema_editor):
from hostadmin.models import ImportedIdentifierSets
for identifier_set in ImportedIdentifierSets.objects.all():
if not identifier_set.hash:
print("update", identifier_set.name)
filename = "shared_data/" + identifier_set.name.strip('git:') + ".json"
if not os.path.exists(filename):
continue
from hashlib import sha256
with open(filename, 'r') as file:
data = file.read()
identifier_set.hash = sha256(data.encode()).hexdigest()
identifier_set.save()
operations = [
migrations.AddField(
model_name='importedidentifiersets',
name='hash',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.RunPython(calculate_hash),
migrations.AlterField(
model_name='importedidentifiersets',
name='hash',
field=models.CharField(max_length=255, unique=True),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 4.2.2 on 2024-03-14 16:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('hostadmin', '0003_importedidentifiersets_hash'),
]
operations = [
migrations.AlterModelOptions(
name='importedidentifiersets',
options={'verbose_name_plural': 'imported identifier sets'},
),
]

View file

@ -12,4 +12,8 @@ class Domain(models.Model):
class ImportedIdentifierSets(models.Model):
name = models.CharField(max_length=255, unique=True)
hash = models.CharField(max_length=255, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name_plural = 'imported identifier sets'

View file

@ -5,6 +5,32 @@ from hostadmin.models import Domain
from toolshed.models import Category, Property, Tag
class SlugPathField(serializers.SlugRelatedField):
def to_internal_value(self, data):
path = data.split('/') if '/' in data else [data]
candidates = self.get_queryset().filter(name=path[-1])
if len(candidates) == 1:
return candidates.first()
if len(candidates) == 0:
raise serializers.ValidationError(
"No {} with name '{}' found".format(self.queryset.model.__name__, path[-1]))
if len(candidates) > 1 and len(path) == 1:
raise serializers.ValidationError("Multiple {}s with name '{}' found, please specify the parent".format(
self.queryset.model.__name__, path[-1]))
parent = self.to_internal_value('/'.join(path[:-1]))
candidates = self.get_queryset().filter(name=path[-1], parent=parent)
if len(candidates) == 1:
return candidates.first()
if len(candidates) == 0:
raise serializers.ValidationError(
"No {} with name '{}' found".format(self.queryset.model.__name__, path[-1]))
def to_representation(self, value):
source = getattr(value, self.field_name, None) # should this use self.source?
prefix = self.to_representation(source) + '/' if source else ''
return prefix + getattr(value, self.slug_field)
class DomainSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True)
@ -12,12 +38,21 @@ class DomainSerializer(serializers.ModelSerializer):
model = Domain
fields = ['name', 'owner', 'open_registration']
def create(self, validated_data):
return super().create(validated_data)
class CategorySerializer(serializers.ModelSerializer):
parent = serializers.SlugRelatedField(slug_field='name', queryset=Category.objects.all(), required=False)
parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
def validate(self, attrs):
if 'name' in attrs:
if '/' in attrs['name']:
raise serializers.ValidationError("Category name cannot contain '/'")
return attrs
def create(self, validated_data):
try:
return Category.objects.create(**validated_data)
except Exception as e:
raise serializers.ValidationError(e)
class Meta:
model = Category
@ -27,7 +62,19 @@ class CategorySerializer(serializers.ModelSerializer):
class PropertySerializer(serializers.ModelSerializer):
category = serializers.SlugRelatedField(slug_field='name', queryset=Category.objects.all(), required=False)
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
def validate(self, attrs):
if 'name' in attrs:
if '/' in attrs['name']:
raise serializers.ValidationError("Property name cannot contain '/'")
return attrs
def create(self, validated_data):
try:
return Property.objects.create(**validated_data)
except Exception as e:
raise serializers.ValidationError(e)
class Meta:
model = Property
@ -38,7 +85,19 @@ class PropertySerializer(serializers.ModelSerializer):
class TagSerializer(serializers.ModelSerializer):
category = serializers.SlugRelatedField(slug_field='name', queryset=Category.objects.all(), required=False)
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
def validate(self, attrs):
if 'name' in attrs:
if '/' in attrs['name']:
raise serializers.ValidationError("Tag name cannot contain '/'")
return attrs
def create(self, validated_data):
try:
return Tag.objects.create(**validated_data)
except Exception as e:
raise serializers.ValidationError(e)
class Meta:
model = Tag

View file

@ -100,7 +100,8 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
response = client.get('/api/categories/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(),
["cat1", "cat2", "cat3", "cat1/subcat1", "cat1/subcat2", "cat1/subcat1/subcat3"])
["cat1", "cat2", "cat3", "cat1/subcat1",
"cat1/subcat2", "cat1/subcat1/subcat1", "cat1/subcat1/subcat2"])
def test_admin_get_categories_fail(self):
response = client.get('/admin/categories/', self.f['local_user1'])
@ -109,7 +110,7 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
def test_admin_get_categories(self):
response = client.get('/admin/categories/', self.f['admin'])
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 6)
self.assertEqual(len(response.json()), 7)
self.assertEqual(response.json()[0]['name'], 'cat1')
self.assertEqual(response.json()[1]['name'], 'cat2')
self.assertEqual(response.json()[2]['name'], 'cat3')
@ -117,10 +118,12 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
self.assertEqual(response.json()[3]['parent'], 'cat1')
self.assertEqual(response.json()[4]['name'], 'subcat2')
self.assertEqual(response.json()[4]['parent'], 'cat1')
self.assertEqual(response.json()[5]['name'], 'subcat3')
self.assertEqual(response.json()[5]['parent'], 'subcat1')
self.assertEqual(response.json()[5]['name'], 'subcat1')
self.assertEqual(response.json()[5]['parent'], 'cat1/subcat1')
self.assertEqual(response.json()[6]['name'], 'subcat2')
self.assertEqual(response.json()[6]['parent'], 'cat1/subcat1')
def test_admin_create_category(self):
def test_admin_post_category(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'cat4'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['name'], 'cat4')
@ -128,6 +131,40 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
self.assertEqual(response.json()['parent'], None)
self.assertEqual(response.json()['origin'], 'api')
def test_admin_post_category_duplicate(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'cat3'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_invalid(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'cat/4'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_parent_not_found(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat4'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_parent_ambiguous(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'subcat1'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_parent_subcategory(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat1/subcat1'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['name'], 'subcat4')
self.assertEqual(response.json()['description'], None)
self.assertEqual(response.json()['parent'], 'cat1/subcat1')
self.assertEqual(response.json()['origin'], 'api')
def test_admin_post_category_parent_subcategory_not_found(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat2/subcat1'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_parent_subcategory_ambiguous(self):
from toolshed.models import Category
self.f['subcat111'] = Category.objects.create(name='subcat1', parent=self.f['subcat11'], origin='test')
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'subcat1/subcat1'})
self.assertEqual(response.status_code, 400)
def test_admin_post_subcategory(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat1'})
self.assertEqual(response.status_code, 201)
@ -136,6 +173,18 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
self.assertEqual(response.json()['parent'], 'cat1')
self.assertEqual(response.json()['origin'], 'api')
def test_admin_post_subcategory_duplicate(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat2', 'parent': 'cat1'})
self.assertEqual(response.status_code, 400)
def test_admin_post_subcategory_distinct_duplicate(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat2', 'parent': 'cat2'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['name'], 'subcat2')
self.assertEqual(response.json()['description'], None)
self.assertEqual(response.json()['parent'], 'cat2')
self.assertEqual(response.json()['origin'], 'api')
def test_admin_put_category(self):
response = client.put('/admin/categories/1/', self.f['admin'], {'name': 'cat5'})
self.assertEqual(response.status_code, 200)
@ -188,6 +237,14 @@ class TagApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, ToolshedTes
self.assertEqual(response.json()['origin'], 'api')
self.assertEqual(response.json()['category'], None)
def test_admin_create_tag_duplicate(self):
response = client.post('/admin/tags/', self.f['admin'], {'name': 'tag3'})
self.assertEqual(response.status_code, 400)
def test_admin_create_tag_invalid(self):
response = client.post('/admin/tags/', self.f['admin'], {'name': 'tag/4'})
self.assertEqual(response.status_code, 400)
def test_admin_put_tag(self):
response = client.put('/admin/tags/1/', self.f['admin'], {'name': 'tag5'})
self.assertEqual(response.status_code, 200)
@ -250,7 +307,13 @@ class PropertyApiTestCase(UserTestMixin, CategoryTestMixin, PropertyTestMixin, T
self.assertEqual(response.json()['base2_prefix'], False)
self.assertEqual(response.json()['dimensions'], 1)
# self.assertEqual(response.json()['sort_lexicographically'], False)
def test_admin_create_property_duplicate(self):
response = client.post('/admin/properties/', self.f['admin'], {'name': 'prop3', 'category': 'cat1'})
self.assertEqual(response.status_code, 400)
def test_admin_create_property_invalid(self):
response = client.post('/admin/properties/', self.f['admin'], {'name': 'prop/4'})
self.assertEqual(response.status_code, 400)
def test_admin_put_property(self):
response = client.put('/admin/properties/1/', self.f['admin'], {'name': 'prop5'})
@ -265,8 +328,6 @@ class PropertyApiTestCase(UserTestMixin, CategoryTestMixin, PropertyTestMixin, T
self.assertEqual(response.json()['base2_prefix'], False)
self.assertEqual(response.json()['dimensions'], 1)
# self.assertEqual(response.json()['sort_lexicographically'], False)
def test_admin_patch_property(self):
response = client.patch('/admin/properties/1/', self.f['admin'], {'name': 'prop5'})
self.assertEqual(response.status_code, 200)

View file

@ -1,6 +1,6 @@
from django.contrib import admin
from toolshed.models import InventoryItem, Property, Tag, ItemProperty, ItemTag
from toolshed.models import InventoryItem, Property, Tag, Category
class InventoryItemAdmin(admin.ModelAdmin):
@ -12,16 +12,24 @@ admin.site.register(InventoryItem, InventoryItemAdmin)
class PropertyAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
list_display = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
search_fields = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
admin.site.register(Property, PropertyAdmin)
class TagAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
list_display = ('name', 'description', 'category', 'origin')
search_fields = ('name', 'description', 'category', 'origin')
admin.site.register(Tag, TagAdmin)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'parent', 'origin')
search_fields = ('name', 'description', 'parent', 'origin')
admin.site.register(Category, CategoryAdmin)

View file

@ -61,9 +61,11 @@ def combined_info(request, format=None): # /info/
tags = [tag.name for tag in Tag.objects.all()]
properties = PropertySerializer(Property.objects.all(), many=True).data
categories = [str(category) for category in Category.objects.all()]
policies = ['private', 'friends', 'internal', 'public']
policies = InventoryItem.AVAILABILITY_POLICY_CHOICES
domains = [domain.name for domain in Domain.objects.filter(open_registration=True)]
return Response({'tags': tags, 'properties': properties, 'availability_policies': policies, 'categories': categories, 'domains': domains})
return Response(
{'tags': tags, 'properties': properties, 'availability_policies': policies, 'categories': categories,
'domains': domains})
urlpatterns = [

View file

@ -0,0 +1,67 @@
# Generated by Django 4.2.2 on 2024-03-14 16:54
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0005_alter_inventoryitem_availability_policy'),
]
operations = [
migrations.AlterModelOptions(
name='tag',
options={'verbose_name_plural': 'tags'},
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='category',
name='parent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='toolshed.category'),
),
migrations.AlterField(
model_name='inventoryitem',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='toolshed.category'),
),
migrations.AlterField(
model_name='property',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='properties', to='toolshed.category'),
),
migrations.AlterField(
model_name='tag',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='toolshed.category'),
),
migrations.AddConstraint(
model_name='category',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', False)), fields=('name', 'parent'), name='category_unique_name_parent'),
),
migrations.AddConstraint(
model_name='category',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='category_unique_name_no_parent'),
),
migrations.AddConstraint(
model_name='property',
constraint=models.UniqueConstraint(condition=models.Q(('category__isnull', False)), fields=('name', 'category'), name='property_unique_name_category'),
),
migrations.AddConstraint(
model_name='property',
constraint=models.UniqueConstraint(condition=models.Q(('category__isnull', True)), fields=('name',), name='property_unique_name_no_category'),
),
migrations.AddConstraint(
model_name='tag',
constraint=models.UniqueConstraint(condition=models.Q(('category__isnull', False)), fields=('name', 'category'), name='tag_unique_name_category'),
),
migrations.AddConstraint(
model_name='tag',
constraint=models.UniqueConstraint(condition=models.Q(('category__isnull', True)), fields=('name',), name='tag_unique_name_no_category'),
),
]

View file

@ -8,13 +8,19 @@ from files.models import File
class Category(SoftDeleteModel):
name = models.CharField(max_length=255, unique=True)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, related_name='children')
origin = models.CharField(max_length=255, null=False, blank=False)
class Meta:
verbose_name_plural = 'categories'
constraints = [
models.UniqueConstraint(fields=['name', 'parent'], condition=models.Q(parent__isnull=False),
name='category_unique_name_parent'),
models.UniqueConstraint(fields=['name'], condition=models.Q(parent__isnull=True),
name='category_unique_name_no_parent')
]
def __str__(self):
parent = str(self.parent) + "/" if self.parent else ""
@ -24,7 +30,7 @@ class Category(SoftDeleteModel):
class Property(models.Model):
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, related_name='properties')
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='properties')
unit_symbol = models.CharField(max_length=16, null=True, blank=True)
unit_name = models.CharField(max_length=255, null=True, blank=True)
unit_name_plural = models.CharField(max_length=255, null=True, blank=True)
@ -34,6 +40,12 @@ class Property(models.Model):
class Meta:
verbose_name_plural = 'properties'
constraints = [
models.UniqueConstraint(fields=['name', 'category'], condition=models.Q(category__isnull=False),
name='property_unique_name_category'),
models.UniqueConstraint(fields=['name'], condition=models.Q(category__isnull=True),
name='property_unique_name_no_category')
]
def __str__(self):
return self.name
@ -42,9 +54,18 @@ class Property(models.Model):
class Tag(models.Model):
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, related_name='tags')
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='tags')
origin = models.CharField(max_length=255, null=False, blank=False)
class Meta:
verbose_name_plural = 'tags'
constraints = [
models.UniqueConstraint(fields=['name', 'category'], condition=models.Q(category__isnull=False),
name='tag_unique_name_category'),
models.UniqueConstraint(fields=['name'], condition=models.Q(category__isnull=True),
name='tag_unique_name_no_category')
]
def __str__(self):
return self.name
@ -61,8 +82,7 @@ class InventoryItem(SoftDeleteModel):
published = models.BooleanField(default=False)
name = models.CharField(max_length=255, null=True, blank=True)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
related_name='inventory_items')
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items')
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)])
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='inventory_items')

View file

@ -8,7 +8,8 @@ class CategoryTestMixin:
self.f['cat3'] = Category.objects.create(name='cat3', origin='test')
self.f['subcat1'] = Category.objects.create(name='subcat1', parent=self.f['cat1'], origin='test')
self.f['subcat2'] = Category.objects.create(name='subcat2', parent=self.f['cat1'], origin='test')
self.f['subcat3'] = Category.objects.create(name='subcat3', parent=self.f['subcat1'], origin='test')
self.f['subcat11'] = Category.objects.create(name='subcat1', parent=self.f['subcat1'], origin='test')
self.f['subcat12'] = Category.objects.create(name='subcat2', parent=self.f['subcat1'], origin='test')
class TagTestMixin:

View file

@ -53,9 +53,11 @@ class CombinedApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, Proper
def test_combined_api(self):
response = client.get('/api/info/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['availability_policies'], ['private', 'friends', 'internal', 'public'])
self.assertEqual(response.json()['availability_policies'], [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'],
['share', 'Share'], ['private', 'Private']])
self.assertEqual(response.json()['categories'],
['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat3'])
['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat1',
'cat1/subcat1/subcat2'])
self.assertEqual(response.json()['tags'], ['tag1', 'tag2', 'tag3'])
self.assertEqual([p['name'] for p in response.json()['properties']], ['prop1', 'prop2', 'prop3'])
self.assertEqual(response.json()['domains'], ['example.com'])

View file

@ -17,10 +17,11 @@ class CategoryTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
self.assertEqual(self.f['cat1'].children.last(), self.f['subcat2'])
self.assertEqual(self.f['subcat1'].parent, self.f['cat1'])
self.assertEqual(self.f['subcat2'].parent, self.f['cat1'])
self.assertEqual(self.f['subcat1'].children.count(), 1)
self.assertEqual(self.f['subcat1'].children.count(), 2)
self.assertEqual(str(self.f['subcat1']), 'cat1/subcat1')
self.assertEqual(str(self.f['subcat2']), 'cat1/subcat2')
self.assertEqual(str(self.f['subcat3']), 'cat1/subcat1/subcat3')
self.assertEqual(str(self.f['subcat11']), 'cat1/subcat1/subcat1')
self.assertEqual(str(self.f['subcat12']), 'cat1/subcat1/subcat2')
class CategoryApiTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
@ -33,10 +34,12 @@ class CategoryApiTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
def test_get_categories(self):
reply = client.get('/api/categories/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 6)
self.assertEqual(len(reply.json()), 7)
self.assertEqual(reply.json()[0], 'cat1')
self.assertEqual(reply.json()[1], 'cat2')
self.assertEqual(reply.json()[2], 'cat3')
self.assertEqual(reply.json()[3], 'cat1/subcat1')
self.assertEqual(reply.json()[4], 'cat1/subcat2')
self.assertEqual(reply.json()[5], 'cat1/subcat1/subcat3')
self.assertEqual(reply.json()[5], 'cat1/subcat1/subcat1')
self.assertEqual(reply.json()[6], 'cat1/subcat1/subcat2')

View file

@ -375,7 +375,6 @@ class FriendRequestCombinedTestCase(UserTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
print(self.f)
def test_friend_request_combined(self):
befriender = self.f['local_user1']

View file

@ -0,0 +1,16 @@
# Use an official Python runtime as instance_a parent image
FROM python:3.9
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /code
# Install dependencies
COPY requirements.txt /code/
RUN pip install --no-cache-dir -r requirements.txt
# Run the application
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--insecure"]

16
deploy/dev/Dockerfile.dns Normal file
View file

@ -0,0 +1,16 @@
# Use an official Python runtime as instance_a parent image
FROM python:3.9
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /dns
COPY dns_server.py /dns/
RUN pip install dnslib
# Run the application
CMD ["python", "dns_server.py"]

View file

@ -0,0 +1,13 @@
# 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
CMD [ "npm", "run", "dev", "--", "--host"]

View file

@ -0,0 +1,14 @@
FROM nginx:bookworm
# snakeoil for localhost
RUN apt-get update && \
apt-get install -y openssl && \
openssl genrsa -des3 -passout pass:x -out server.pass.key 2048 && \
openssl rsa -passin pass:x -in server.pass.key -out server.key && \
rm server.pass.key && \
openssl req -new -key server.key -out server.csr \
-subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=localhost" && \
openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt &&\
mv server.crt /etc/nginx/nginx.crt && \
mv server.key /etc/nginx/nginx.key \

View file

@ -0,0 +1,15 @@
# Use an official Python runtime as instance_a parent image
FROM python:3.9
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /wiki
# Install dependencies
RUN pip install --no-cache-dir mkdocs
# Run the application
CMD ["mkdocs", "serve", "--dev-addr=0.0.0.0:8001"]

72
deploy/dev/dns_server.py Normal file
View file

@ -0,0 +1,72 @@
import http.server
import socketserver
import urllib.parse
import dnslib
import base64
try:
def resolve(zone, qname, qtype):
for record in zone:
if record["name"] == qname and record["type"] == qtype and "value" in record:
return record["value"]
class DnsHttpRequestHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
try:
with open("/dns/zone.json", "r") as f:
import json
zone = json.load(f)
url = urllib.parse.urlparse(self.path)
if url.path != "/dns-query":
self.send_response(404)
return
query = urllib.parse.parse_qs(url.query)
if "dns" not in query:
self.send_response(400)
return
query_base64 = query["dns"][0]
padded = query_base64 + "=" * (4 - len(query_base64) % 4)
raw = base64.b64decode(padded)
dns = dnslib.DNSRecord.parse(raw)
response = dnslib.DNSRecord(dnslib.DNSHeader(id=dns.header.id, qr=1, aa=1, ra=1), q=dns.q)
record = resolve(zone, dns.q.qname, dnslib.QTYPE[dns.q.qtype])
if record:
if dns.q.qtype == dnslib.QTYPE.SRV:
print("SRV record")
reply = dnslib.SRV(record["priority"], record["weight"], record["port"], record["target"])
response.add_answer(dnslib.RR(dns.q.qname, dns.q.qtype, rdata=reply))
else:
response.header.rcode = dnslib.RCODE.NXDOMAIN
print(response)
self.send_response(200)
self.send_header("Content-type", "application/dns-message")
self.end_headers()
pack = response.pack()
self.wfile.write(pack)
return
except Exception as e:
print(f"Error: {e}")
self.send_response(500)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"Internal Server Error")
handler_object = DnsHttpRequestHandler
PORT = 8053
my_server = socketserver.TCPServer(("", PORT), handler_object)
# Start the server
print(f"Starting server on port {PORT}")
my_server.serve_forever()
except Exception as e:
print(f"Error: {e}")

View file

@ -0,0 +1,8 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG=True
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY='e*lm&*!j0_stqaiod$1zob(vs@aq6+n-i$1%!rek)_v9n^ue$3'
ALLOWED_HOSTS="*"

View file

@ -0,0 +1,3 @@
[
"127.0.0.3:5353"
]

View file

@ -0,0 +1,3 @@
[
"a.localhost"
]

View file

@ -0,0 +1,96 @@
events {}
http {
upstream backend {
server backend-a:8000;
}
upstream frontend {
server frontend:5173;
}
upstream wiki {
server wiki:8001;
}
upstream dns {
server dns:8053;
}
server {
listen 8080 ssl;
server_name localhost;
ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key;
location /api {
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /auth {
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /docs {
proxy_pass http://backend/docs;
}
location /static {
proxy_pass http://backend/static;
}
location /wiki {
proxy_pass http://wiki/wiki;
}
location /livereload {
proxy_pass http://wiki/livereload;
}
location /local/ {
alias /var/www/;
try_files $uri.json =404;
add_header Content-Type application/json;
}
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_pass http://frontend;
}
}
# DoH server
server {
listen 5353 ssl;
server_name localhost;
ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key;
location /dns-query {
proxy_pass http://dns;
# allow any origin
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
}
}
}

View file

@ -0,0 +1,7 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG=True
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY='7ccxjje%q@@0*z+r&-$fy3(rj9n)%$!sk-k++-&rb=_u(wpjbe'
ALLOWED_HOSTS="*"

View file

@ -0,0 +1,46 @@
events {}
http {
upstream backend {
server backend-b:8000;
}
server {
listen 8080 ssl;
server_name localhost;
ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key;
location /api {
#proxy_set_header X-Forwarded-For "$http_x_forwarded_for, $realip_remote_addr";
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /auth {
#proxy_set_header X-Forwarded-For "$http_x_forwarded_for, $realip_remote_addr";
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /docs {
proxy_pass http://backend/docs;
}
location /static {
proxy_pass http://backend/static;
}
}
}

24
deploy/dev/zone.json Normal file
View file

@ -0,0 +1,24 @@
[
{
"name": "_toolshed-server._tcp.a.localhost.",
"type": "SRV",
"ttl": 60,
"value": {
"priority": 0,
"weight": 5,
"port": 8080,
"target": "127.0.0.1."
}
},
{
"name": "_toolshed-server._tcp.b.localhost.",
"type": "SRV",
"ttl": 60,
"value": {
"priority": 0,
"weight": 5,
"port": 8080,
"target": "127.0.0.2."
}
}
]

View file

@ -0,0 +1,78 @@
version: '3.8'
services:
backend-a:
build:
context: ../backend/
dockerfile: ../deploy/dev/Dockerfile.backend
volumes:
- ../backend:/code
- ../deploy/dev/instance_a/a.env:/code/.env
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
expose:
- 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
backend-b:
build:
context: ../backend/
dockerfile: ../deploy/dev/Dockerfile.backend
volumes:
- ../backend:/code
- ../deploy/dev/instance_b/b.env:/code/.env
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
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:ro
- /app/node_modules
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
proxy-a:
build:
context: ./
dockerfile: dev/Dockerfile.proxy
volumes:
- ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
- ./dev/instance_a/dns.json:/var/www/dns.json:ro
- ./dev/instance_a/domains.json:/var/www/domains.json:ro
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.3:5353:5353"
proxy-b:
build:
context: ./
dockerfile: dev/Dockerfile.proxy
volumes:
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
ports:
- "127.0.0.2:8080:8080"
dns:
build:
context: ./dev/
dockerfile: Dockerfile.dns
volumes:
- ./dev/zone.json:/dns/zone.json
expose:
- 8053

102
docs/deployment.md Normal file
View file

@ -0,0 +1,102 @@
# 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
```

105
docs/development.md Normal file
View file

@ -0,0 +1,105 @@
# 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/`.

23
docs/federation.md Normal file
View file

@ -0,0 +1,23 @@
# 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,47 +6,8 @@ This is the documentation for the Toolshed project. It is a work in progress.
`#social` `#network` `#federation` `#decentralized` `#federated` `#socialnetwork` `#fediverse` `#community` `#hashtags`
## 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
```

28
frontend/.gitignore vendored Normal file
View file

@ -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?

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="shortcut icon" href="/src/assets/icons/toolshed-48x48.png" type="image/png">
<title>Toolshed</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

0
frontend/node_modules/.forgit generated vendored Normal file
View file

2807
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
frontend/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"bootstrap": "^4.6.2",
"bootstrap-icons-vue": "^1.10.3",
"dns-query": "^0.11.2",
"js-nacl": "^1.4.0",
"moment": "^2.29.4",
"vue": "^3.2.47",
"vue-multiselect": "^2.1.7",
"vue-router": "^4.1.6",
"vuex": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"@vue/test-utils": "^2.3.2",
"jsdom": "^22.0.0",
"sass": "^1.72.0",
"vite": "^4.1.4",
"vitest": "^0.31.1"
}
}

18
frontend/src/App.vue Normal file
View file

@ -0,0 +1,18 @@
<script setup>
</script>
<template>
<router-view></router-view>
</template>
<script>
export default {
name: 'App'
}
</script>
<style scoped>
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,115 @@
<template>
<div class="wrapper">
<Sidebar/>
<div class="main">
<nav class="navbar navbar-expand navbar-light navbar-bg">
<a class="sidebar-toggle d-flex" @click="toggleSidebar">
<i class="hamburger align-self-center"></i>
</a>
</nav>
<slot></slot>
<Footer/>
</div>
</div>
</template>
<script>
import Footer from "@/components/Footer.vue";
import Sidebar from "@/components/Sidebar.vue";
export default {
name: 'BaseLayout',
components: {
Footer,
Sidebar
},
props: {
hideSearch: {
type: Boolean,
required: false,
default: false
}
},
methods: {
toggleSidebar() {
closeAllDropdowns();
document.getElementById("sidebar").classList.toggle("collapsed");
},
},
}
</script>
<style scoped>
.wrapper {
align-items: stretch;
display: flex;
width: 100%;
}
.main {
display: flex;
width: 100%;
min-width: 0;
min-height: 100vh;
transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out;
flex-direction: column;
overflow: hidden;
}
.navbar-expand {
flex-wrap: nowrap;
justify-content: flex-start;
}
.navbar {
border-bottom: 0;
padding: .875rem 1.375rem;
box-shadow: 0 0 2rem var(--bs-shadow)
}
.navbar-bg {
background: var(--bs-white);
}
.sidebar-toggle {
cursor: pointer;
width: 26px;
height: 26px;
margin-right: 1rem;
}
.hamburger {
position: relative;
&, &:after, &:before {
cursor: pointer;
border-radius: 1px;
height: 3px;
width: 24px;
background: var(--bs-gray-700);
display: block;
content: "";
transition: background .1s ease-in-out, color .1s ease-in-out
}
&:before {
top: -7.5px;
width: 24px;
position: absolute
}
&:after {
bottom: -7.5px;
width: 16px;
position: absolute
}
}
.sidebar-toggle:hover {
.hamburger, .hamburger:after, .hamburger:before {
background: var(--bs-primary);
}
}
</style>

View file

@ -0,0 +1,53 @@
<template>
<footer class="footer">
<div class="container-fluid">
<div class="row text-muted">
<div class="col-6 text-left">
<p class="mb-0">
<a target="_blank" href="https://www.gnu.org/licenses/gpl-3.0.de.html"
class="text-muted">
License: <strong>GPL-3.0</strong>
</a>
</p>
</div>
<div class="col-6 text-right">
<ul class="list-inline">
<li class="list-inline-item">
<a class="text-muted"
target="_blank" href="/docs/">API Docs</a>
</li>
<li class="list-inline-item">
<a class="text-muted"
target="_blank" href="/wiki/">Wiki</a>
</li>
<li class="list-inline-item">
<a class="text-muted"
target="_blank" href="https://github.com/gr4yj3d1/toolshed">Sources</a>
</li>
</ul>
</div>
</div>
</div>
</footer>
</template>
<script>
export default {
name: "Footer"
}
</script>
<style scoped>
.footer {
padding: 1rem .875rem;
direction: ltr;
background: var(--bs-background-1)
}
.footer ul {
margin-bottom: 0;
}
</style>

View file

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

50
frontend/src/dns.js Normal file
View file

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

324
frontend/src/federation.js Normal file
View file

@ -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};

47
frontend/src/main.js Normal file
View file

@ -0,0 +1,47 @@
import {createApp} from 'vue'
import {BootstrapIconsPlugin} from 'bootstrap-icons-vue';
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(store).use(BootstrapIconsPlugin);
_nacl.instantiate((nacl) => {
window.nacl = nacl
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');
}
}
}

48
frontend/src/neigbors.js Normal file
View file

@ -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;

35
frontend/src/router.js Normal file
View file

@ -0,0 +1,35 @@
import {createRouter, createWebHistory} from 'vue-router'
import Index from '@/views/Index.vue';
import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue';
const routes = [
{path: '/', component: Index, 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 && false) {
// this route requires auth, check if logged in
// if not, redirect to login page.
console.log("Not logged in, redirecting to login page")
return {
path: '/login',
// save the location we were at to come back later
query: {redirect: to.fullPath},
}
}
})
export default router

View file

@ -0,0 +1,58 @@
.card {
margin-bottom: 24px;
box-shadow: 0 0 .875rem map-get($theme-colors, shadow);
background-clip: initial;
border: 0 solid transparent;
}
.card-header {
background-color: map-get($theme-colors, background-1);
border-bottom: 0 solid transparent;
}
.card-title {
color: map-get($theme-colors, text-3);
margin-bottom: .5rem;
}
.card-subtitle {
margin-top: -.25rem;
}
.card-subtitle, .card-text:last-child {
margin-bottom: 0;
}
.card {
& > .dataTables_wrapper .table.dataTable,
& > .table,
& > .table-responsive-lg .table,
& > .table-responsive-md .table,
& > .table-responsive-sm .table,
& > .table-responsive-xl .table,
& > .table-responsive .table {
border-right: 0;
border-bottom: 0;
border-left: 0;
margin-bottom: 0;
& tr:first-child td,
& tr:first-child th {
border-top: 0;
}
& td:last-child,
& th:last-child {
border-right: 0;
padding-right: 1.25rem;
}
& td:first-child,
& th:first-child {
border-left: 0;
padding-left: 1.25rem;
}
}
}

View file

@ -0,0 +1,71 @@
.form-control {
width: 100%;
height: initial;
min-height: calc(1.8125rem + 2px);
padding: .25rem .7rem;
appearance: none;
background-color: initial;
border-radius: .2rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.form-control-lg {
height: initial;
min-height: calc(2.0875rem + 2px);
padding: .35rem 1rem;
font-size: .925rem;
border-radius: .3rem
}
.btn {
display: inline-block;
font-weight: 400;
line-height: 1.5;
text-align: center;
vertical-align: middle;
cursor: pointer;
user-select: none;
padding: .25rem .7rem;
font-size: .875rem;
border-radius: .2rem;
transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.form-select {
width: 100%;
padding: .25rem 1.7rem .25rem .7rem;
color: map-get($theme-colors, text-3);
background-color: map-get($theme-colors, background-1);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right .7rem center;
background-size: 16px 12px;
border: 1px solid #ced4da;
border-radius: .2rem;
appearance: none;
}
.btn-group-sm > .btn, .btn-sm {
padding: .15rem .5rem;
font-size: .75rem;
border-radius: .1rem;
}
.input-group > :not(:first-child):not(.dropdown-menu) {
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.input-group > .dropdown-toggle:nth-last-child(n+3), .input-group > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-group-text {
display: flex;
align-items: center;
padding: .25rem .7rem;
background-color: map-get($theme-colors, background-2);
border-right-width: 0;
}

View file

@ -0,0 +1,138 @@
$variable-prefix: bs-;
$white: #ffffff;
$theme-colors: (
"light": #d7e1dc,
"dark": #1f2327,
"primary": #3a7ddd,
"secondary": #45393a,
"info": #027980,
"success": #019a56,
"warning": #ffc107,
"danger": #ee1200,
"background-1": $white,
"background-2": #e9ecef,
"text-1": #000,
"text-3": #495057,
"shadow": #2125291a,
);
$font-size-base: 0.875rem;
$h1-font-size: $font-size-base * 2;
$h2-font-size: $font-size-base * 1.75;
$h3-font-size: $font-size-base * 1.5;
$h4-font-size: $font-size-base * 1.25;
$h5-font-size: $font-size-base;
$h6-font-size: $font-size-base;
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
$body-color: $gray-700;
@import "bootstrap/scss/mixins";
:root {
@each $color, $value in $colors {
--#{$variable-prefix}#{$color}: #{$value};
}
@each $color, $value in $theme-colors {
--#{$variable-prefix}#{$color}: #{$value};
}
@each $color, $value in $grays {
--#{$variable-prefix}gray-#{$color}: #{$value};
}
@each $bp, $value in $grid-breakpoints {
--#{$variable-prefix}breakpoint-#{$bp}: #{$value};
}
--#{$variable-prefix}font-family-sans-serif: #{inspect($font-family-sans-serif)};
--#{$variable-prefix}font-family-monospace: #{inspect($font-family-monospace)};
}
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
@import "bootstrap/scss/images";
@import "bootstrap/scss/code";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/tables";
@import "bootstrap/scss/forms";
@import "bootstrap/scss/buttons";
@import "bootstrap/scss/transitions";
@import "bootstrap/scss/dropdown";
@import "bootstrap/scss/button-group";
@import "bootstrap/scss/input-group";
@import "bootstrap/scss/custom-forms";
@import "bootstrap/scss/nav";
@import "bootstrap/scss/navbar";
@import "bootstrap/scss/card";
@import "bootstrap/scss/breadcrumb";
@import "bootstrap/scss/pagination";
@import "bootstrap/scss/badge";
@import "bootstrap/scss/jumbotron";
@import "bootstrap/scss/alert";
@import "bootstrap/scss/progress";
@import "bootstrap/scss/media";
@import "bootstrap/scss/list-group";
@import "bootstrap/scss/close";
@import "bootstrap/scss/toasts";
@import "bootstrap/scss/modal";
@import "bootstrap/scss/tooltip";
@import "bootstrap/scss/popover";
@import "bootstrap/scss/carousel";
@import "bootstrap/scss/spinners";
@import "bootstrap/scss/utilities";
@import "bootstrap/scss/print";
@import "card";
@import "forms";
#root, body, html {
height: 100%;
}
body {
overflow-y: scroll;
opacity: 1 !important;
}
.main {
background-color: var(--bs-gray-300);
}
.content {
padding: 1.5rem 1.5rem .75rem;
flex: 1;
width: 100vw;
max-width: 100vw;
direction: ltr
}
@media (min-width: map-get($grid-breakpoints, md)) {
.content {
width: auto;
max-width: auto
}
}
@media (min-width: map-get($grid-breakpoints, lg)) {
.content {
padding: 2.5rem 2.5rem 1rem
}
}
.h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 {
font-weight: 400;
color: map-get($theme-colors, text-1);
}
.table > :not(caption) > * > * {
padding: .75rem;
background-color: var(--bs-table-bg);
background-image: linear-gradient(var(--bs-table-accent-bg), var(--bs-table-accent-bg));
border-bottom-width: 1px !important;
}

132
frontend/src/store.js Normal file
View file

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

View file

@ -0,0 +1,31 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Dashboard</h1>
<div class="row">
<div class="col-12">
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
export default {
name: 'Index',
components: {
...BIcons,
BaseLayout
},
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,106 @@
<template>
<div class="main d-flex w-100">
<div class="container d-flex flex-column">
<div class="row vh-100">
<div class="col-sm-10 col-md-8 col-lg-6 mx-auto d-table h-100">
<div class="d-table-cell align-middle">
<div class="text-center mt-4">
<h1 class="h2">
Toolshed
</h1>
<p class="lead" v-if="msg">
{{ msg }}
</p>
<p class="lead" v-else>
Sign in to your account to continue
</p>
</div>
<div class="card">
<div class="card-body">
<div class="m-sm-4">
<form role="form" @submit.prevent="do_login">
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control form-control-lg" type="text"
name="username" placeholder="Enter your username"
v-model="username"/>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input class="form-control form-control-lg" type="password"
name="password" placeholder="Enter your password"
v-model="password"/>
</div>
<div>
<label class="form-check">
<input class="form-check-input" type="checkbox" value="remember-me"
name="remember-me" checked v-model="remember"
@change="setRemember(remember)">
<span class="form-check-label">
Remember me next time
</span>
</label>
</div>
<div class="text-center mt-3">
<button type="submit" name="login" class="btn btn-lg btn-primary">Login
</button>
</div>
</form>
<br/>
<div class="text-center">
<p class="mb-0 text-muted">
Dont have an account?
<router-link to="/register">Sign up</router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import router from "@/router";
export default {
name: 'Login',
data() {
return {
msg: 'Welcome to ' + location.hostname,
username: '',
password: '',
remember: false
}
},
methods: {
setRemember(remember) {
},
login(data) {
return true;
},
async do_login(e) {
e.preventDefault();
if (await this.login({username: this.username, password: this.password, remember: this.remember})) {
if (this.$route.query.redirect) {
await router.push({path: this.$route.query.redirect});
} else {
await router.push({path: '/'});
}
} else {
this.msg = 'Invalid username or password';
}
},
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,163 @@
<template>
<div class="main d-flex w-100">
<div class="container d-flex flex-column">
<div class="row vh-100">
<div class="col-sm-10 col-md-8 col-lg-6 mx-auto d-table h-100">
<div class="d-table-cell align-middle">
<div class="text-center mt-4">
<h1 class="h2">
Toolshed
</h1>
<p class="lead" v-if="msg">
{{ msg }}
</p>
<p class="lead" v-else>
Create an account to get started
</p>
</div>
<div class="card">
<div class="card-body">
<div class="m-sm-4">
<form role="form" method="post" @submit.prevent="do_register">
<div :class="errors.username||errors.domain?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Username</label>
<div class="input-group">
<input class="form-control form-control-lg"
type="text" v-model="form.username" id="validationCustomUsername"
placeholder="Enter your username" required/>
<div class="input-group-prepend">
<span class="input-group-text form-control form-control-lg">@</span>
</div>
<select class="form-control form-control-lg"
id="exampleFormControlSelect1"
placeholder="Domain" v-model="form.domain" required>
<option v-for="domain in domains">{{ domain }}</option>
</select>
</div>
<div class="invalid-feedback">
{{ errors.username }}{{ errors.domain }}
</div>
</div>
<div :class="errors.email?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Email</label>
<input class="form-control form-control-lg" type="email"
v-model="form.email" placeholder="Enter your email"/>
<div class="invalid-feedback">{{ errors.email }}</div>
</div>
<div :class="errors.password?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Password</label>
<input class="form-control form-control-lg" type="password"
v-model="form.password" placeholder="Enter your password"/>
<div class="invalid-feedback">{{ errors.password }}</div>
</div>
<div :class="errors.password2?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Password Check</label>
<input class="form-control form-control-lg" type="password"
v-model="password2" placeholder="Enter your password again"/>
<div class="invalid-feedback">{{ errors.password2 }}</div>
</div>
<div class="text-center mt-3">
<button type="submit" class="btn btn-lg btn-primary">
Register
</button>
</div>
</form>
<br/>
<div class="text-center">
<p class="mb-0 text-muted">
Already have an account?
<router-link to="/login">Login</router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Register',
data() {
return {
msg: 'Register new account',
password2: '',
form: {
username: '',
domain: '',
email: '',
password: '',
},
errors: {
username: null,
domain: null,
email: null,
password: null,
password2: null,
},
domains: []
}
},
methods: {
do_register() {
console.log('do_register');
console.log(this.form);
if (this.form.password !== this.password2) {
this.errors.password2 = 'Passwords do not match';
return;
} else {
this.errors.password2 = null;
}
fetch('/auth/register/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.form)
})
.then(response => response.json())
.then(data => {
if (data.errors) {
console.error('Error:', data.errors);
this.errors = data.errors;
return;
}
console.log('Success:', data);
this.msg = 'Success';
this.$router.push('/login');
})
.catch((error) => {
console.error('Error:', error);
this.msg = 'Error';
});
}
},
mounted() {
fetch('/api/domains/')
.then(response => response.json())
.then(data => {
this.domains = data;
});
}
}
</script>
<style scoped>
.is-invalid input, .is-invalid select {
border: 1px solid var(--bs-danger);
}
.is-invalid .invalid-feedback {
display: block;
}
</style>

35
frontend/vite.config.js Normal file
View file

@ -0,0 +1,35 @@
import {fileURLToPath, URL} from 'node:url'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import * as fs from "fs";
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-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',
'Access-Control-Max-Age': '86400',
'Content-Security-Policy': 'default-src \'self\';'
+ ' script-src \'self\' \'wasm-unsafe-eval\';'
+ ' style-src \'self\' \'unsafe-inline\';'
+ ' img-src \'self\' data:; '
+ ' connect-src * data:', // TODO: change * to https://* for production (probably in nginx config not here)
},
},
test: {
include: ['src/tests/**/*.js'],
globals: true,
environment: "jsdom"
}
})

View file

@ -24,3 +24,4 @@ extra_javascript:
- https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.js
extra_css:
- toolshed.css
site_url: https://localhost:8080/wiki/