Compare commits
1 commit
stable
...
jedi/proto
Author | SHA1 | Date | |
---|---|---|---|
bd59c40ac6 |
71 changed files with 521 additions and 5557 deletions
|
@ -13,5 +13,4 @@ steps:
|
||||||
- apk add --no-cache gcc musl-dev python3-dev
|
- apk add --no-cache gcc musl-dev python3-dev
|
||||||
- pip install --upgrade pip && pip install -r requirements.txt
|
- pip install --upgrade pip && pip install -r requirements.txt
|
||||||
- python3 configure.py
|
- python3 configure.py
|
||||||
- coverage run manage.py test
|
- coverage run manage.py test && coverage report
|
||||||
- coverage report
|
|
||||||
|
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -130,5 +130,5 @@ dmypy.json
|
||||||
|
|
||||||
staticfiles/
|
staticfiles/
|
||||||
userfiles/
|
userfiles/
|
||||||
testdata.py
|
backend/templates/
|
||||||
*.sqlite3
|
backend/testdata.py
|
43
README.md
43
README.md
|
@ -1,6 +1,6 @@
|
||||||
# toolshed
|
# toolshed
|
||||||
|
|
||||||
## Development
|
## Installation / Development
|
||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
git clone https://github.com/gr4yj3d1/toolshed.git
|
git clone https://github.com/gr4yj3d1/toolshed.git
|
||||||
|
@ -12,10 +12,7 @@ or
|
||||||
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
|
||||||
|
@ -29,7 +26,7 @@ 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
|
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.
|
requests to the backend, then run the backend with just `python manage.py runserver` without the `--insecure` flag.
|
||||||
|
|
||||||
### Frontend only
|
### Frontend
|
||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
cd toolshed/frontend
|
cd toolshed/frontend
|
||||||
|
@ -37,45 +34,13 @@ 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
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
2
backend/.idea/.gitignore
vendored
2
backend/.idea/.gitignore
vendored
|
@ -6,5 +6,3 @@
|
||||||
# Datasource local storage ignored files
|
# Datasource local storage ignored files
|
||||||
/dataSources/
|
/dataSources/
|
||||||
/dataSources.local.xml
|
/dataSources.local.xml
|
||||||
# GitHub Copilot persisted chat sessions
|
|
||||||
/copilot/chatSessions
|
|
||||||
|
|
|
@ -22,7 +22,8 @@ class KnownIdentity(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.username}@{self.domain}"
|
return f"{self.username}@{self.domain}"
|
||||||
|
|
||||||
def is_authenticated(self):
|
@staticmethod
|
||||||
|
def is_authenticated():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def friends_or_self(self):
|
def friends_or_self(self):
|
||||||
|
@ -30,9 +31,9 @@ class KnownIdentity(models.Model):
|
||||||
public_identity=self)
|
public_identity=self)
|
||||||
|
|
||||||
def verify(self, message, signature):
|
def verify(self, message, signature):
|
||||||
if len(signature) != 128 or type(signature) != str:
|
if len(signature) != 128 or not isinstance(signature, str):
|
||||||
raise TypeError('Signature must be 128 characters long and a string')
|
raise TypeError('Signature must be 128 characters long and a string')
|
||||||
if type(message) != str:
|
if not isinstance(message, str):
|
||||||
raise TypeError('Message must be a string')
|
raise TypeError('Message must be a string')
|
||||||
try:
|
try:
|
||||||
VerifyKey(bytes.fromhex(self.public_key)).verify(message.encode('utf-8'), bytes.fromhex(signature))
|
VerifyKey(bytes.fromhex(self.public_key)).verify(message.encode('utf-8'), bytes.fromhex(signature))
|
||||||
|
@ -44,14 +45,17 @@ class KnownIdentity(models.Model):
|
||||||
class ToolshedUserManager(auth.models.BaseUserManager):
|
class ToolshedUserManager(auth.models.BaseUserManager):
|
||||||
def create_user(self, username, email, password, **extra_fields):
|
def create_user(self, username, email, password, **extra_fields):
|
||||||
domain = extra_fields.pop('domain', 'localhost')
|
domain = extra_fields.pop('domain', 'localhost')
|
||||||
private_key_hex = extra_fields.pop('private_key', None)
|
private_key_hex: str | None = extra_fields.pop('private_key', None)
|
||||||
if private_key_hex and type(private_key_hex) != str:
|
if private_key_hex is not None:
|
||||||
|
if not isinstance(private_key_hex, str):
|
||||||
raise TypeError('Private key must be a string or no private key must be provided')
|
raise TypeError('Private key must be a string or no private key must be provided')
|
||||||
if private_key_hex and len(private_key_hex) != 64:
|
if len(private_key_hex) != 64:
|
||||||
raise ValueError('Private key must be 64 characters long or no private key must be provided')
|
raise ValueError('Private key must be 64 characters long or no private key must be provided')
|
||||||
if private_key_hex and not all(c in '0123456789abcdef' for c in private_key_hex):
|
if not all(c in '0123456789abcdef' for c in private_key_hex):
|
||||||
raise ValueError('Private key must be a hexadecimal string or no private key must be provided')
|
raise ValueError('Private key must be a hexadecimal string or no private key must be provided')
|
||||||
private_key = SigningKey(bytes.fromhex(private_key_hex)) if private_key_hex else SigningKey.generate()
|
private_key = SigningKey(bytes.fromhex(private_key_hex))
|
||||||
|
else:
|
||||||
|
private_key = SigningKey.generate()
|
||||||
public_key = SigningKey(private_key.encode()).verify_key
|
public_key = SigningKey(private_key.encode()).verify_key
|
||||||
extra_fields['private_key'] = private_key.encode(encoder=HexEncoder).decode('utf-8')
|
extra_fields['private_key'] = private_key.encode(encoder=HexEncoder).decode('utf-8')
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import Client, RequestFactory
|
from django.test import Client, RequestFactory
|
||||||
from nacl.encoding import HexEncoder
|
from nacl.encoding import HexEncoder
|
||||||
from nacl.signing import SigningKey
|
from nacl.signing import SigningKey
|
||||||
|
|
|
@ -13,11 +13,14 @@ Including another URLconf
|
||||||
1. Import the include() function: from django.urls import include, path
|
1. Import the include() function: from django.urls import include, path
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from drf_yasg import openapi
|
from drf_yasg import openapi
|
||||||
from drf_yasg.views import get_schema_view
|
from drf_yasg.views import get_schema_view
|
||||||
|
|
||||||
|
from backend import settings
|
||||||
|
|
||||||
openapi_info = openapi.Info(
|
openapi_info = openapi.Info(
|
||||||
title="Toolshed API",
|
title="Toolshed API",
|
||||||
default_version='v1',
|
default_version='v1',
|
||||||
|
@ -35,9 +38,10 @@ urlpatterns = [
|
||||||
path('auth/', include('authentication.api')),
|
path('auth/', include('authentication.api')),
|
||||||
path('admin/', include('hostadmin.api')),
|
path('admin/', include('hostadmin.api')),
|
||||||
path('api/', include('toolshed.api.friend')),
|
path('api/', include('toolshed.api.friend')),
|
||||||
|
path('api/', include('toolshed.api.social')),
|
||||||
path('api/', include('toolshed.api.inventory')),
|
path('api/', include('toolshed.api.inventory')),
|
||||||
path('api/', include('toolshed.api.info')),
|
path('api/', include('toolshed.api.info')),
|
||||||
path('api/', include('toolshed.api.files')),
|
path('api/', include('toolshed.api.files')),
|
||||||
path('media/', include('files.media_urls')),
|
path('media/', include('files.media_urls')),
|
||||||
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
|
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
|
||||||
]
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
|
@ -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,29 +67,34 @@ 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 initial domains?"):
|
||||||
|
# domains = input("Enter a comma-separated list of allowed hosts: ")
|
||||||
|
# from hostadmin import Domain
|
||||||
|
#
|
||||||
|
# Domain.objects.
|
||||||
|
|
||||||
|
# TODO check if superuser exists
|
||||||
|
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')
|
||||||
|
|
||||||
|
# TODO ask for which static directory to use and save it in .env
|
||||||
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
|
|
||||||
if not os.path.exists('shared_data'):
|
if not os.path.exists('shared_data'):
|
||||||
os.mkdir('shared_data')
|
os.mkdir('shared_data')
|
||||||
files = os.listdir('shared_data')
|
files = os.listdir('shared_data')
|
||||||
idsets = {}
|
idsets = {}
|
||||||
hashes = {}
|
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.endswith('.json'):
|
if file.endswith('.json'):
|
||||||
name = "git:" + file[:-5]
|
name = "git:" + file[:-5]
|
||||||
|
@ -106,8 +102,6 @@ def configure(ctx):
|
||||||
try:
|
try:
|
||||||
idset = json.load(f)
|
idset = json.load(f)
|
||||||
idsets[name] = idset
|
idsets[name] = idset
|
||||||
f.seek(0)
|
|
||||||
hashes[name] = sha256(f.read().encode()).hexdigest()
|
|
||||||
except json.decoder.JSONDecodeError:
|
except json.decoder.JSONDecodeError:
|
||||||
print('Error: invalid JSON in file {}'.format(file))
|
print('Error: invalid JSON in file {}'.format(file))
|
||||||
imported_sets = ImportedIdentifierSets.objects.all()
|
imported_sets = ImportedIdentifierSets.objects.all()
|
||||||
|
@ -122,13 +116,9 @@ def configure(ctx):
|
||||||
unmet_deps = [dep for dep in idset['depends'] if not imported_sets.filter(name=dep).exists()]
|
unmet_deps = [dep for dep in idset['depends'] if not imported_sets.filter(name=dep).exists()]
|
||||||
if unmet_deps:
|
if unmet_deps:
|
||||||
if all([dep in idsets.keys() for dep in unmet_deps]):
|
if all([dep in idsets.keys() for dep in unmet_deps]):
|
||||||
if all([dep in queue for dep in unmet_deps]):
|
|
||||||
print('Not all dependencies for {} are imported, postponing'.format(name))
|
print('Not all dependencies for {} are imported, postponing'.format(name))
|
||||||
queue.append(name)
|
queue.append(name)
|
||||||
continue
|
continue
|
||||||
else:
|
|
||||||
print('Error: unresolvable dependencies for {}: {}'.format(name, unmet_deps))
|
|
||||||
continue
|
|
||||||
else:
|
else:
|
||||||
print('unknown dependencies for {}: {}'.format(name, unmet_deps))
|
print('unknown dependencies for {}: {}'.format(name, unmet_deps))
|
||||||
continue
|
continue
|
||||||
|
@ -149,15 +139,10 @@ def configure(ctx):
|
||||||
serializer = TagSerializer(data=tag)
|
serializer = TagSerializer(data=tag)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save(origin=name)
|
serializer.save(origin=name)
|
||||||
imported_sets.create(name=name, hash=hashes[name])
|
imported_sets.create(name=name)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
print('Error: integrity error while importing {}\n\tmight be cause by name conflicts with existing'
|
print('Error: integrity error while importing {}\n\tmight be cause by name conflicts with existing'
|
||||||
' categories, properties or tags'.format(name))
|
' categories, properties or tags'.format(name))
|
||||||
transaction.set_rollback(True)
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
print('Error: {}'.format(e))
|
|
||||||
transaction.set_rollback(True)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
@ -206,7 +191,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 +198,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':
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Domain, ImportedIdentifierSets
|
from .models import Domain
|
||||||
|
|
||||||
|
|
||||||
class DomainAdmin(admin.ModelAdmin):
|
class DomainAdmin(admin.ModelAdmin):
|
||||||
|
@ -9,11 +9,3 @@ class DomainAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Domain, DomainAdmin)
|
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)
|
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,17 +0,0 @@
|
||||||
# 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'},
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -12,8 +12,4 @@ class Domain(models.Model):
|
||||||
|
|
||||||
class ImportedIdentifierSets(models.Model):
|
class ImportedIdentifierSets(models.Model):
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
hash = models.CharField(max_length=255, unique=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name_plural = 'imported identifier sets'
|
|
||||||
|
|
|
@ -5,32 +5,6 @@ from hostadmin.models import Domain
|
||||||
from toolshed.models import Category, Property, Tag
|
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):
|
class DomainSerializer(serializers.ModelSerializer):
|
||||||
owner = OwnerSerializer(read_only=True)
|
owner = OwnerSerializer(read_only=True)
|
||||||
|
|
||||||
|
@ -38,21 +12,12 @@ class DomainSerializer(serializers.ModelSerializer):
|
||||||
model = Domain
|
model = Domain
|
||||||
fields = ['name', 'owner', 'open_registration']
|
fields = ['name', 'owner', 'open_registration']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
class CategorySerializer(serializers.ModelSerializer):
|
class CategorySerializer(serializers.ModelSerializer):
|
||||||
parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
|
parent = serializers.SlugRelatedField(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:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
|
@ -62,19 +27,7 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
|
||||||
class PropertySerializer(serializers.ModelSerializer):
|
class PropertySerializer(serializers.ModelSerializer):
|
||||||
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
|
category = serializers.SlugRelatedField(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:
|
class Meta:
|
||||||
model = Property
|
model = Property
|
||||||
|
@ -85,19 +38,7 @@ class PropertySerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
|
||||||
class TagSerializer(serializers.ModelSerializer):
|
class TagSerializer(serializers.ModelSerializer):
|
||||||
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
|
category = serializers.SlugRelatedField(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:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
|
|
|
@ -100,8 +100,7 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
|
||||||
response = client.get('/api/categories/', self.f['local_user1'])
|
response = client.get('/api/categories/', self.f['local_user1'])
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json(),
|
self.assertEqual(response.json(),
|
||||||
["cat1", "cat2", "cat3", "cat1/subcat1",
|
["cat1", "cat2", "cat3", "cat1/subcat1", "cat1/subcat2", "cat1/subcat1/subcat3"])
|
||||||
"cat1/subcat2", "cat1/subcat1/subcat1", "cat1/subcat1/subcat2"])
|
|
||||||
|
|
||||||
def test_admin_get_categories_fail(self):
|
def test_admin_get_categories_fail(self):
|
||||||
response = client.get('/admin/categories/', self.f['local_user1'])
|
response = client.get('/admin/categories/', self.f['local_user1'])
|
||||||
|
@ -110,7 +109,7 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
|
||||||
def test_admin_get_categories(self):
|
def test_admin_get_categories(self):
|
||||||
response = client.get('/admin/categories/', self.f['admin'])
|
response = client.get('/admin/categories/', self.f['admin'])
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(len(response.json()), 7)
|
self.assertEqual(len(response.json()), 6)
|
||||||
self.assertEqual(response.json()[0]['name'], 'cat1')
|
self.assertEqual(response.json()[0]['name'], 'cat1')
|
||||||
self.assertEqual(response.json()[1]['name'], 'cat2')
|
self.assertEqual(response.json()[1]['name'], 'cat2')
|
||||||
self.assertEqual(response.json()[2]['name'], 'cat3')
|
self.assertEqual(response.json()[2]['name'], 'cat3')
|
||||||
|
@ -118,12 +117,10 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(response.json()[3]['parent'], 'cat1')
|
self.assertEqual(response.json()[3]['parent'], 'cat1')
|
||||||
self.assertEqual(response.json()[4]['name'], 'subcat2')
|
self.assertEqual(response.json()[4]['name'], 'subcat2')
|
||||||
self.assertEqual(response.json()[4]['parent'], 'cat1')
|
self.assertEqual(response.json()[4]['parent'], 'cat1')
|
||||||
self.assertEqual(response.json()[5]['name'], 'subcat1')
|
self.assertEqual(response.json()[5]['name'], 'subcat3')
|
||||||
self.assertEqual(response.json()[5]['parent'], 'cat1/subcat1')
|
self.assertEqual(response.json()[5]['parent'], 'subcat1')
|
||||||
self.assertEqual(response.json()[6]['name'], 'subcat2')
|
|
||||||
self.assertEqual(response.json()[6]['parent'], 'cat1/subcat1')
|
|
||||||
|
|
||||||
def test_admin_post_category(self):
|
def test_admin_create_category(self):
|
||||||
response = client.post('/admin/categories/', self.f['admin'], {'name': 'cat4'})
|
response = client.post('/admin/categories/', self.f['admin'], {'name': 'cat4'})
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 201)
|
||||||
self.assertEqual(response.json()['name'], 'cat4')
|
self.assertEqual(response.json()['name'], 'cat4')
|
||||||
|
@ -131,40 +128,6 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(response.json()['parent'], None)
|
self.assertEqual(response.json()['parent'], None)
|
||||||
self.assertEqual(response.json()['origin'], 'api')
|
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):
|
def test_admin_post_subcategory(self):
|
||||||
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat1'})
|
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat1'})
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 201)
|
||||||
|
@ -173,18 +136,6 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(response.json()['parent'], 'cat1')
|
self.assertEqual(response.json()['parent'], 'cat1')
|
||||||
self.assertEqual(response.json()['origin'], 'api')
|
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):
|
def test_admin_put_category(self):
|
||||||
response = client.put('/admin/categories/1/', self.f['admin'], {'name': 'cat5'})
|
response = client.put('/admin/categories/1/', self.f['admin'], {'name': 'cat5'})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -237,14 +188,6 @@ class TagApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, ToolshedTes
|
||||||
self.assertEqual(response.json()['origin'], 'api')
|
self.assertEqual(response.json()['origin'], 'api')
|
||||||
self.assertEqual(response.json()['category'], None)
|
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):
|
def test_admin_put_tag(self):
|
||||||
response = client.put('/admin/tags/1/', self.f['admin'], {'name': 'tag5'})
|
response = client.put('/admin/tags/1/', self.f['admin'], {'name': 'tag5'})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -307,13 +250,7 @@ class PropertyApiTestCase(UserTestMixin, CategoryTestMixin, PropertyTestMixin, T
|
||||||
self.assertEqual(response.json()['base2_prefix'], False)
|
self.assertEqual(response.json()['base2_prefix'], False)
|
||||||
self.assertEqual(response.json()['dimensions'], 1)
|
self.assertEqual(response.json()['dimensions'], 1)
|
||||||
|
|
||||||
def test_admin_create_property_duplicate(self):
|
# self.assertEqual(response.json()['sort_lexicographically'], False)
|
||||||
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):
|
def test_admin_put_property(self):
|
||||||
response = client.put('/admin/properties/1/', self.f['admin'], {'name': 'prop5'})
|
response = client.put('/admin/properties/1/', self.f['admin'], {'name': 'prop5'})
|
||||||
|
@ -328,6 +265,8 @@ class PropertyApiTestCase(UserTestMixin, CategoryTestMixin, PropertyTestMixin, T
|
||||||
self.assertEqual(response.json()['base2_prefix'], False)
|
self.assertEqual(response.json()['base2_prefix'], False)
|
||||||
self.assertEqual(response.json()['dimensions'], 1)
|
self.assertEqual(response.json()['dimensions'], 1)
|
||||||
|
|
||||||
|
# self.assertEqual(response.json()['sort_lexicographically'], False)
|
||||||
|
|
||||||
def test_admin_patch_property(self):
|
def test_admin_patch_property(self):
|
||||||
response = client.patch('/admin/properties/1/', self.f['admin'], {'name': 'prop5'})
|
response = client.patch('/admin/properties/1/', self.f['admin'], {'name': 'prop5'})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
59
backend/shared_data/base.json
Normal file
59
backend/shared_data/base.json
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{ "name": "hardware"},
|
||||||
|
{ "name": "material"},
|
||||||
|
{ "name": "tools"}
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{ "name": "angle", "unit_symbol": "°", "unit_name": "degree", "unit_name_plural": "degrees" },
|
||||||
|
{ "name": "area", "unit_symbol": "m²", "unit_name": "square meter", "unit_name_plural": "square meters" },
|
||||||
|
{ "name": "current", "unit_symbol": "A", "unit_name": "ampere", "unit_name_plural": "amperes" },
|
||||||
|
{ "name": "diameter", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
|
||||||
|
{ "name": "energy", "unit_symbol": "J", "unit_name": "joule", "unit_name_plural": "joules" },
|
||||||
|
{ "name": "frequency", "unit_symbol": "Hz", "unit_name": "hertz", "unit_name_plural": "hertz" },
|
||||||
|
{ "name": "height", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
|
||||||
|
{ "name": "length", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
|
||||||
|
{ "name": "memory", "unit_symbol": "B", "unit_name": "byte", "unit_name_plural": "bytes", "base2_prefix": true },
|
||||||
|
{ "name": "power", "unit_symbol": "W", "unit_name": "watt", "unit_name_plural": "watts" },
|
||||||
|
{ "name": "price", "unit_symbol": "€", "unit_name": "euro", "unit_name_plural": "euros" },
|
||||||
|
{ "name": "speed", "unit_symbol": "m/s", "unit_name": "meter per second", "unit_name_plural": "meters per second" },
|
||||||
|
{ "name": "temperature", "unit_symbol": "°C", "unit_name": "degree Celsius", "unit_name_plural": "degrees Celsius" },
|
||||||
|
{ "name": "time", "unit_symbol": "s", "unit_name": "second", "unit_name_plural": "seconds" },
|
||||||
|
{ "name": "voltage", "unit_symbol": "V", "unit_name": "volt", "unit_name_plural": "volts" },
|
||||||
|
{ "name": "volume", "unit_symbol": "l", "unit_name": "liter", "unit_name_plural": "liters" },
|
||||||
|
{ "name": "weight", "unit_symbol": "g", "unit_name": "gram", "unit_name_plural": "grams" },
|
||||||
|
{ "name": "width", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" }
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{"name": "bolt", "category": "hardware"},
|
||||||
|
{"name": "chisel", "category": "tools"},
|
||||||
|
{"name": "clamp", "category": "tools"},
|
||||||
|
{"name": "drill", "category": "tools"},
|
||||||
|
{"name": "ear plugs", "category": "tools"},
|
||||||
|
{"name": "extension cord", "category": "tools"},
|
||||||
|
{"name": "flashlight", "category": "tools"},
|
||||||
|
{"name": "gloves", "category": "tools"},
|
||||||
|
{"name": "goggles", "category": "tools"},
|
||||||
|
{"name": "hammer", "category": "tools"},
|
||||||
|
{"name": "level", "category": "tools"},
|
||||||
|
{"name": "mask", "category": "tools"},
|
||||||
|
{"name": "nail", "category": "hardware"},
|
||||||
|
{"name": "nut", "category": "hardware"},
|
||||||
|
{"name": "paint brush", "category": "tools"},
|
||||||
|
{"name": "paint roller", "category": "tools"},
|
||||||
|
{"name": "paint tray", "category": "tools"},
|
||||||
|
{"name": "pliers", "category": "tools"},
|
||||||
|
{"name": "power strip", "category": "tools"},
|
||||||
|
{"name": "sander", "category": "tools"},
|
||||||
|
{"name": "saw", "category": "tools"},
|
||||||
|
{"name": "screw", "category": "hardware"},
|
||||||
|
{"name": "screwdriver", "category": "tools"},
|
||||||
|
{"name": "soldering iron", "category": "tools"},
|
||||||
|
{"name": "stapler", "category": "tools"},
|
||||||
|
{"name": "tape measure", "category": "tools"},
|
||||||
|
{"name": "tool"},
|
||||||
|
{"name": "vise", "category": "tools"},
|
||||||
|
{"name": "washer", "category": "hardware"},
|
||||||
|
{"name": "wrench", "category": "tools"}
|
||||||
|
]
|
||||||
|
}
|
30
backend/shared_data/screws.json
Normal file
30
backend/shared_data/screws.json
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"depends": [ "git:base" ],
|
||||||
|
"categories": [
|
||||||
|
{ "name": "screws", "parent": "hardware"}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{"name": "m1", "category": "screws"},
|
||||||
|
{"name": "m2", "category": "screws"},
|
||||||
|
{"name": "m2.5", "category": "screws"},
|
||||||
|
{"name": "m3", "category": "screws"},
|
||||||
|
{"name": "m4", "category": "screws"},
|
||||||
|
{"name": "m5", "category": "screws"},
|
||||||
|
{"name": "m6", "category": "screws"},
|
||||||
|
{"name": "m8", "category": "screws"},
|
||||||
|
{"name": "m10", "category": "screws"},
|
||||||
|
{"name": "m12", "category": "screws"},
|
||||||
|
{"name": "m16", "category": "screws"},
|
||||||
|
{"name": "torx", "category": "screws"},
|
||||||
|
{"name": "hex", "category": "screws"},
|
||||||
|
{"name": "phillips", "category": "screws"},
|
||||||
|
{"name": "pozidriv", "category": "screws"},
|
||||||
|
{"name": "slotted", "category": "screws"},
|
||||||
|
{"name": "socket", "category": "screws"},
|
||||||
|
{"name": "flat", "category": "screws"},
|
||||||
|
{"name": "pan", "category": "screws"},
|
||||||
|
{"name": "button", "category": "screws"},
|
||||||
|
{"name": "countersunk", "category": "screws"},
|
||||||
|
{"name": "round", "category": "screws"}
|
||||||
|
]
|
||||||
|
}
|
|
@ -1,6 +1,14 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from toolshed.models import InventoryItem, Property, Tag, Category
|
from toolshed.models import Profile, InventoryItem, Property, Tag, ItemProperty, ItemTag
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'bio', 'location')
|
||||||
|
search_fields = ('user', 'bio', 'location')
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Profile, ProfileAdmin)
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemAdmin(admin.ModelAdmin):
|
class InventoryItemAdmin(admin.ModelAdmin):
|
||||||
|
@ -12,24 +20,39 @@ admin.site.register(InventoryItem, InventoryItemAdmin)
|
||||||
|
|
||||||
|
|
||||||
class PropertyAdmin(admin.ModelAdmin):
|
class PropertyAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
|
list_display = ('name',)
|
||||||
search_fields = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
|
search_fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Property, PropertyAdmin)
|
admin.site.register(Property, PropertyAdmin)
|
||||||
|
|
||||||
|
|
||||||
class TagAdmin(admin.ModelAdmin):
|
class TagAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'description', 'category', 'origin')
|
list_display = ('name',)
|
||||||
search_fields = ('name', 'description', 'category', 'origin')
|
search_fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Tag, TagAdmin)
|
admin.site.register(Tag, TagAdmin)
|
||||||
|
|
||||||
|
# class ItemPropertyAdmin(admin.ModelAdmin):
|
||||||
class CategoryAdmin(admin.ModelAdmin):
|
# list_display = ('item', 'property', 'value')
|
||||||
list_display = ('name', 'description', 'parent', 'origin')
|
# search_fields = ('item', 'property', 'value')
|
||||||
search_fields = ('name', 'description', 'parent', 'origin')
|
#
|
||||||
|
#
|
||||||
|
# admin.site.register(ItemProperty, ItemPropertyAdmin)
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# class ItemTagAdmin(admin.ModelAdmin):
|
||||||
|
# list_display = ('item', 'tag')
|
||||||
|
# search_fields = ('item', 'tag')
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# admin.site.register(ItemTag, ItemTagAdmin)
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Category, CategoryAdmin)
|
# class LendingPeriodAdmin(admin.ModelAdmin):
|
||||||
|
# list_display = ('item', 'start_date', 'end_date')
|
||||||
|
# search_fields = ('item', 'start_date', 'end_date')
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# admin.site.register(LendingPeriod, LendingPeriodAdmin)
|
||||||
|
|
16
backend/toolshed/aggregators.py
Normal file
16
backend/toolshed/aggregators.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from toolshed.models import Event, Message
|
||||||
|
|
||||||
|
|
||||||
|
def timeline_notifications(user):
|
||||||
|
"""Return a list of notifications that the user is interested in."""
|
||||||
|
for evt in Event.objects.all():
|
||||||
|
if evt.user == user:
|
||||||
|
yield evt
|
||||||
|
for tool in user.inventory.all():
|
||||||
|
if evt.tool == tool:
|
||||||
|
yield evt
|
||||||
|
|
||||||
|
|
||||||
|
def unread_messages(user):
|
||||||
|
"""Return a list of unread messages."""
|
||||||
|
return Message.objects.filter(recipient=user, read=False)
|
|
@ -1,12 +1,13 @@
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework import routers, viewsets
|
from rest_framework import routers, viewsets, serializers
|
||||||
from rest_framework.decorators import authentication_classes, api_view, permission_classes
|
from rest_framework.decorators import authentication_classes, api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity
|
from authentication.models import ToolshedUser, KnownIdentity
|
||||||
from authentication.signature_auth import SignatureAuthentication
|
from authentication.signature_auth import SignatureAuthentication
|
||||||
|
from files.models import File
|
||||||
from toolshed.models import InventoryItem, StorageLocation
|
from toolshed.models import InventoryItem, StorageLocation
|
||||||
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer
|
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer
|
||||||
|
|
||||||
|
|
40
backend/toolshed/api/social.py
Normal file
40
backend/toolshed/api/social.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from django.urls import path
|
||||||
|
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from authentication.signature_auth import SignatureAuthenticationLocal
|
||||||
|
from toolshed.models import Message, Profile
|
||||||
|
from toolshed.serializers import MessageSerializer, ProfileSerializer
|
||||||
|
from toolshed.aggregators import unread_messages, timeline_notifications
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_messages(request):
|
||||||
|
messages = Message.objects.filter(recipient=request.user)
|
||||||
|
return Response(MessageSerializer(messages, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_profile(request):
|
||||||
|
profile = Profile.objects.get(user=request.user)
|
||||||
|
return Response(ProfileSerializer(profile).data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_notifications(request):
|
||||||
|
notifications = timeline_notifications(request.user)
|
||||||
|
return Response(notifications)
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('messages/', get_messages),
|
||||||
|
path('profile/', get_profile),
|
||||||
|
path('notifications/', get_notifications),
|
||||||
|
]
|
|
@ -1,67 +0,0 @@
|
||||||
# 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'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Generated by Django 4.2.2 on 2024-02-23 15:30
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('files', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('toolshed', '0005_alter_inventoryitem_availability_policy'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Event',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField()),
|
||||||
|
('location', models.CharField(max_length=255)),
|
||||||
|
('date', models.DateField()),
|
||||||
|
('time', models.TimeField()),
|
||||||
|
('host_username', models.CharField(max_length=255)),
|
||||||
|
('host_domain', models.CharField(max_length=255)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Transaction',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], default='pending', max_length=20)),
|
||||||
|
('message', models.TextField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('item_offered', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='offered_transactions', to='toolshed.inventoryitem')),
|
||||||
|
('item_requested', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requested_transactions', to='toolshed.inventoryitem')),
|
||||||
|
('offerer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='offered_transactions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requested_transactions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Profile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('bio', models.TextField(blank=True)),
|
||||||
|
('location', models.CharField(blank=True, max_length=255)),
|
||||||
|
('profile_picture', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='files.file')),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Message',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('subject', models.CharField(max_length=255)),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('read', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,4 +1,6 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save, pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django_softdelete.models import SoftDeleteModel
|
from django_softdelete.models import SoftDeleteModel
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
@ -7,20 +9,60 @@ from authentication.models import ToolshedUser, KnownIdentity
|
||||||
from files.models import File
|
from files.models import File
|
||||||
|
|
||||||
|
|
||||||
class Category(SoftDeleteModel):
|
# @receiver(pre_save)
|
||||||
|
# def pre_save_handler(sender, instance, *args, **kwargs):
|
||||||
|
# instance.full_clean()
|
||||||
|
|
||||||
|
|
||||||
|
class Event(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField()
|
||||||
|
location = models.CharField(max_length=255)
|
||||||
|
date = models.DateField()
|
||||||
|
time = models.TimeField()
|
||||||
|
# host = models.ForeignKey(User, on_delete=models.CASCADE, related_name='events')
|
||||||
|
host_username = models.CharField(max_length=255)
|
||||||
|
host_domain = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
# def __str__(self):
|
||||||
|
# return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Message(models.Model):
|
||||||
|
sender = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='sent_messages')
|
||||||
|
recipient = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='received_messages')
|
||||||
|
subject = models.CharField(max_length=255)
|
||||||
|
body = models.TextField()
|
||||||
|
read = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
user = models.OneToOneField(ToolshedUser, on_delete=models.CASCADE)
|
||||||
|
profile_picture = models.ForeignKey(File, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
bio = models.TextField(blank=True)
|
||||||
|
location = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
@receiver(post_save, sender=ToolshedUser)
|
||||||
|
def create_user_profile(sender, instance, created, **kwargs):
|
||||||
|
if created:
|
||||||
|
Profile.objects.create(user=instance)
|
||||||
|
|
||||||
|
@receiver(post_save, sender=ToolshedUser)
|
||||||
|
def save_user_profile(sender, instance, **kwargs):
|
||||||
|
instance.profile.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Category(SoftDeleteModel):
|
||||||
|
name = models.CharField(max_length=255, unique=True)
|
||||||
description = models.TextField(null=True, blank=True)
|
description = models.TextField(null=True, blank=True)
|
||||||
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, related_name='children')
|
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
|
||||||
origin = models.CharField(max_length=255, null=False, blank=False)
|
origin = models.CharField(max_length=255, null=False, blank=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = 'categories'
|
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):
|
def __str__(self):
|
||||||
parent = str(self.parent) + "/" if self.parent else ""
|
parent = str(self.parent) + "/" if self.parent else ""
|
||||||
|
@ -30,22 +72,17 @@ class Category(SoftDeleteModel):
|
||||||
class Property(models.Model):
|
class Property(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(null=True, blank=True)
|
description = models.TextField(null=True, blank=True)
|
||||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='properties')
|
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, related_name='properties')
|
||||||
unit_symbol = models.CharField(max_length=16, null=True, blank=True)
|
unit_symbol = models.CharField(max_length=16, null=True, blank=True)
|
||||||
unit_name = models.CharField(max_length=255, 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)
|
unit_name_plural = models.CharField(max_length=255, null=True, blank=True)
|
||||||
base2_prefix = models.BooleanField(default=False)
|
base2_prefix = models.BooleanField(default=False)
|
||||||
|
# sort_lexicographically = models.BooleanField(default=False)
|
||||||
dimensions = models.IntegerField(null=False, blank=False, default=1, validators=[MinValueValidator(1)])
|
dimensions = models.IntegerField(null=False, blank=False, default=1, validators=[MinValueValidator(1)])
|
||||||
origin = models.CharField(max_length=255, null=False, blank=False)
|
origin = models.CharField(max_length=255, null=False, blank=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = 'properties'
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
@ -54,18 +91,9 @@ class Property(models.Model):
|
||||||
class Tag(models.Model):
|
class Tag(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(null=True, blank=True)
|
description = models.TextField(null=True, blank=True)
|
||||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='tags')
|
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, related_name='tags')
|
||||||
origin = models.CharField(max_length=255, null=False, blank=False)
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@ -82,7 +110,8 @@ class InventoryItem(SoftDeleteModel):
|
||||||
published = models.BooleanField(default=False)
|
published = models.BooleanField(default=False)
|
||||||
name = models.CharField(max_length=255, null=True, blank=True)
|
name = models.CharField(max_length=255, null=True, blank=True)
|
||||||
description = models.TextField(null=True, blank=True)
|
description = models.TextField(null=True, blank=True)
|
||||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items')
|
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
|
||||||
|
related_name='inventory_items')
|
||||||
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
|
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
|
||||||
owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)])
|
owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)])
|
||||||
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='inventory_items')
|
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='inventory_items')
|
||||||
|
@ -109,6 +138,23 @@ class ItemTag(models.Model):
|
||||||
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
|
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('accepted', 'Accepted'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
)
|
||||||
|
|
||||||
|
item_requested = models.ForeignKey(InventoryItem, on_delete=models.CASCADE, related_name='requested_transactions')
|
||||||
|
item_offered = models.ForeignKey(InventoryItem, on_delete=models.CASCADE, related_name='offered_transactions')
|
||||||
|
requester = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='requested_transactions')
|
||||||
|
offerer = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='offered_transactions')
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
|
message = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|
||||||
class StorageLocation(models.Model):
|
class StorageLocation(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(null=True, blank=True)
|
description = models.TextField(null=True, blank=True)
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming
|
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming
|
||||||
from authentication.serializers import OwnerSerializer
|
from authentication.serializers import OwnerSerializer
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from files.serializers import FileSerializer
|
from files.serializers import FileSerializer
|
||||||
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation
|
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, Profile, Message, StorageLocation
|
||||||
|
|
||||||
|
|
||||||
class FriendSerializer(serializers.ModelSerializer):
|
class FriendSerializer(serializers.ModelSerializer):
|
||||||
|
@ -28,6 +29,18 @@ class FriendRequestSerializer(serializers.ModelSerializer):
|
||||||
return obj.befriender_username + '@' + obj.befriender_domain
|
return obj.befriender_username + '@' + obj.befriender_domain
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileSerializer(serializers.Serializer):
|
||||||
|
class Meta:
|
||||||
|
model = Profile
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSerializer(serializers.Serializer):
|
||||||
|
class Meta:
|
||||||
|
model = Message
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
class PropertySerializer(serializers.ModelSerializer):
|
class PropertySerializer(serializers.ModelSerializer):
|
||||||
category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name')
|
category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name')
|
||||||
|
|
||||||
|
@ -103,6 +116,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
tags = validated_data.pop('tags', [])
|
tags = validated_data.pop('tags', [])
|
||||||
props = validated_data.pop('itemproperty_set', [])
|
props = validated_data.pop('itemproperty_set', [])
|
||||||
files = validated_data.pop('files', [])
|
files = validated_data.pop('files', [])
|
||||||
|
# if 'category' in validated_data and validated_data['category'] == '':
|
||||||
|
# validated_data.pop('category')
|
||||||
item = InventoryItem.objects.create(**validated_data)
|
item = InventoryItem.objects.create(**validated_data)
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
item.tags.add(tag, through_defaults={})
|
item.tags.add(tag, through_defaults={})
|
||||||
|
@ -127,6 +142,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
tags = validated_data.pop('tags', [])
|
tags = validated_data.pop('tags', [])
|
||||||
props = validated_data.pop('itemproperty_set', [])
|
props = validated_data.pop('itemproperty_set', [])
|
||||||
|
# if 'category' in validated_data and validated_data['category'] == '':
|
||||||
|
# validated_data.pop('category')
|
||||||
item = super().update(instance, validated_data)
|
item = super().update(instance, validated_data)
|
||||||
item.tags.clear()
|
item.tags.clear()
|
||||||
item.properties.clear()
|
item.properties.clear()
|
||||||
|
|
|
@ -8,8 +8,7 @@ class CategoryTestMixin:
|
||||||
self.f['cat3'] = Category.objects.create(name='cat3', origin='test')
|
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['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['subcat2'] = Category.objects.create(name='subcat2', parent=self.f['cat1'], origin='test')
|
||||||
self.f['subcat11'] = Category.objects.create(name='subcat1', parent=self.f['subcat1'], origin='test')
|
self.f['subcat3'] = Category.objects.create(name='subcat3', parent=self.f['subcat1'], origin='test')
|
||||||
self.f['subcat12'] = Category.objects.create(name='subcat2', parent=self.f['subcat1'], origin='test')
|
|
||||||
|
|
||||||
|
|
||||||
class TagTestMixin:
|
class TagTestMixin:
|
||||||
|
|
|
@ -56,8 +56,7 @@ class CombinedApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, Proper
|
||||||
self.assertEqual(response.json()['availability_policies'], [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'],
|
self.assertEqual(response.json()['availability_policies'], [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'],
|
||||||
['share', 'Share'], ['private', 'Private']])
|
['share', 'Share'], ['private', 'Private']])
|
||||||
self.assertEqual(response.json()['categories'],
|
self.assertEqual(response.json()['categories'],
|
||||||
['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat1',
|
['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat3'])
|
||||||
'cat1/subcat1/subcat2'])
|
|
||||||
self.assertEqual(response.json()['tags'], ['tag1', 'tag2', 'tag3'])
|
self.assertEqual(response.json()['tags'], ['tag1', 'tag2', 'tag3'])
|
||||||
self.assertEqual([p['name'] for p in response.json()['properties']], ['prop1', 'prop2', 'prop3'])
|
self.assertEqual([p['name'] for p in response.json()['properties']], ['prop1', 'prop2', 'prop3'])
|
||||||
self.assertEqual(response.json()['domains'], ['example.com'])
|
self.assertEqual(response.json()['domains'], ['example.com'])
|
||||||
|
|
|
@ -17,11 +17,10 @@ class CategoryTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(self.f['cat1'].children.last(), self.f['subcat2'])
|
self.assertEqual(self.f['cat1'].children.last(), self.f['subcat2'])
|
||||||
self.assertEqual(self.f['subcat1'].parent, self.f['cat1'])
|
self.assertEqual(self.f['subcat1'].parent, self.f['cat1'])
|
||||||
self.assertEqual(self.f['subcat2'].parent, self.f['cat1'])
|
self.assertEqual(self.f['subcat2'].parent, self.f['cat1'])
|
||||||
self.assertEqual(self.f['subcat1'].children.count(), 2)
|
self.assertEqual(self.f['subcat1'].children.count(), 1)
|
||||||
self.assertEqual(str(self.f['subcat1']), 'cat1/subcat1')
|
self.assertEqual(str(self.f['subcat1']), 'cat1/subcat1')
|
||||||
self.assertEqual(str(self.f['subcat2']), 'cat1/subcat2')
|
self.assertEqual(str(self.f['subcat2']), 'cat1/subcat2')
|
||||||
self.assertEqual(str(self.f['subcat11']), 'cat1/subcat1/subcat1')
|
self.assertEqual(str(self.f['subcat3']), 'cat1/subcat1/subcat3')
|
||||||
self.assertEqual(str(self.f['subcat12']), 'cat1/subcat1/subcat2')
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryApiTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
|
class CategoryApiTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
|
||||||
|
@ -34,12 +33,10 @@ class CategoryApiTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
|
||||||
def test_get_categories(self):
|
def test_get_categories(self):
|
||||||
reply = client.get('/api/categories/', self.f['local_user1'])
|
reply = client.get('/api/categories/', self.f['local_user1'])
|
||||||
self.assertEqual(reply.status_code, 200)
|
self.assertEqual(reply.status_code, 200)
|
||||||
self.assertEqual(len(reply.json()), 7)
|
self.assertEqual(len(reply.json()), 6)
|
||||||
self.assertEqual(reply.json()[0], 'cat1')
|
self.assertEqual(reply.json()[0], 'cat1')
|
||||||
self.assertEqual(reply.json()[1], 'cat2')
|
self.assertEqual(reply.json()[1], 'cat2')
|
||||||
self.assertEqual(reply.json()[2], 'cat3')
|
self.assertEqual(reply.json()[2], 'cat3')
|
||||||
self.assertEqual(reply.json()[3], 'cat1/subcat1')
|
self.assertEqual(reply.json()[3], 'cat1/subcat1')
|
||||||
self.assertEqual(reply.json()[4], 'cat1/subcat2')
|
self.assertEqual(reply.json()[4], 'cat1/subcat2')
|
||||||
self.assertEqual(reply.json()[5], 'cat1/subcat1/subcat1')
|
self.assertEqual(reply.json()[5], 'cat1/subcat1/subcat3')
|
||||||
self.assertEqual(reply.json()[6], 'cat1/subcat1/subcat2')
|
|
||||||
|
|
||||||
|
|
|
@ -369,6 +369,23 @@ class FriendRequestOutgoingTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(befriendee.friends.first().username, befriender.username)
|
self.assertEqual(befriendee.friends.first().username, befriender.username)
|
||||||
self.assertEqual(befriendee.friends.first().domain, befriender.domain)
|
self.assertEqual(befriendee.friends.first().domain, befriender.domain)
|
||||||
|
|
||||||
|
# TODO:
|
||||||
|
# - test that the friend request is deleted after a certain amount of time
|
||||||
|
# - decline friend request Endpoint ('reject'?)
|
||||||
|
# - cancel friend request Endpoint ('retract'?)
|
||||||
|
# - drop friend Endpoint
|
||||||
|
# Szenarios: (all also with broken signature, wrong key, wrong secret, wrong author and without authorisation header)
|
||||||
|
# (plus: friend request exists already, already friends)
|
||||||
|
# - local1 requests local2, local2 accepts
|
||||||
|
# - local1 requests local2, local2 declines
|
||||||
|
# - local1 requests local2, local1 cancels
|
||||||
|
# - ext requests local, local accepts
|
||||||
|
# - ext requests local, local declines
|
||||||
|
# - ext requests local, ext cancels
|
||||||
|
# - local requests ext, ext accepts
|
||||||
|
# - local requests ext, ext declines
|
||||||
|
# - local requests ext, local cancels
|
||||||
|
|
||||||
|
|
||||||
class FriendRequestCombinedTestCase(UserTestMixin, ToolshedTestCase):
|
class FriendRequestCombinedTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
|
||||||
|
|
41
backend/toolshed/tests/test_social.py
Normal file
41
backend/toolshed/tests/test_social.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
|
||||||
|
|
||||||
|
anonymous_client = Client()
|
||||||
|
client = SignatureAuthClient()
|
||||||
|
|
||||||
|
|
||||||
|
class MessageApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
|
||||||
|
def test_get_messages(self):
|
||||||
|
response = client.get('/api/messages/', self.f['local_user1'])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
|
||||||
|
def test_get_profile(self):
|
||||||
|
response = client.get('/api/profile/', self.f['local_user1'])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
|
||||||
|
def test_get_notifications(self):
|
||||||
|
response = client.get('/api/notifications/', self.f['local_user1'])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), [])
|
|
@ -1,16 +0,0 @@
|
||||||
# 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"]
|
|
|
@ -1,16 +0,0 @@
|
||||||
# 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"]
|
|
|
@ -1,13 +0,0 @@
|
||||||
# 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"]
|
|
|
@ -1,14 +0,0 @@
|
||||||
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 \
|
|
|
@ -1,15 +0,0 @@
|
||||||
# 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"]
|
|
|
@ -1,72 +0,0 @@
|
||||||
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}")
|
|
|
@ -1,8 +0,0 @@
|
||||||
|
|
||||||
# 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="*"
|
|
|
@ -1,3 +0,0 @@
|
||||||
[
|
|
||||||
"127.0.0.3:5353"
|
|
||||||
]
|
|
|
@ -1,3 +0,0 @@
|
||||||
[
|
|
||||||
"a.localhost"
|
|
||||||
]
|
|
|
@ -1,96 +0,0 @@
|
||||||
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';
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
# 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="*"
|
|
|
@ -1,46 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,78 +0,0 @@
|
||||||
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
|
|
|
@ -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
|
|
||||||
```
|
|
|
@ -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/`.
|
|
|
@ -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).
|
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
28
frontend/.gitignore
vendored
28
frontend/.gitignore
vendored
|
@ -1,28 +0,0 @@
|
||||||
# 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?
|
|
|
@ -1,14 +0,0 @@
|
||||||
<!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
0
frontend/node_modules/.forgit
generated
vendored
2807
frontend/package-lock.json
generated
2807
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
<script setup>
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<router-view></router-view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'App'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
Binary file not shown.
Before Width: | Height: | Size: 11 KiB |
|
@ -1,115 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,53 +0,0 @@
|
||||||
<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>
|
|
|
@ -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>
|
|
|
@ -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;
|
|
|
@ -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};
|
|
||||||
|
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
|
@ -1,35 +0,0 @@
|
||||||
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
|
|
|
@ -1,58 +0,0 @@
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,71 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
|
@ -1,138 +0,0 @@
|
||||||
$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;
|
|
||||||
}
|
|
|
@ -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({})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
|
@ -1,31 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,106 +0,0 @@
|
||||||
<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">
|
|
||||||
Don’t 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>
|
|
|
@ -1,163 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,35 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
})
|
|
|
@ -24,4 +24,3 @@ extra_javascript:
|
||||||
- https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.js
|
- https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.js
|
||||||
extra_css:
|
extra_css:
|
||||||
- toolshed.css
|
- toolshed.css
|
||||||
site_url: https://localhost:8080/wiki/
|
|
||||||
|
|
Loading…
Reference in a new issue