diff --git a/GUIDE.md b/GUIDE.md index 6763720..dbd247d 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -212,6 +212,22 @@ In a single-page application, the page never reloads. (That's the entire point o Fortunately, Shynet offers a simple method you can call from anywhere within your JavaScript to indicate that a new page has been loaded: `Shynet.newPageLoad()`. Add this method call to the code that handles routing in your app, and you'll be ready to go. + +### API + +All the information displayed on the dashboard can be obtained via API on url ```//shynet.example.com/api/v1/dashboard/```. By default this endpoint will return the full data from all services over the last last 30 days. The `Authentication` header should be set to use user's parsonal API token (```'Authorization: Token '```). + +There are 3 optional query parameters: + * `uuid` - to get data only from one service + * `startDate` - to set start date in format YYYY-MM-DD + * `endDate` - to set end date in format YYYY-MM-DD + +Example in HTTPie: +```http get '//shynet.example.com/api/v1/dashboard/?uuid={{service_uuid}}&startDate=2021-01-01&endDate=2050-01-01' 'Authorization:Token {{user_api_token}}'``` + +Example in cURL: +```curl -H 'Authorization:Token {{user_api_token}}' '//shynet.example.com/api/v1/dashboard/?uuid={{service_uuid}}&startDate=2021-01-01&endDate=2050-01-01'``` + --- ## Troubleshooting diff --git a/poetry.lock b/poetry.lock index 6b75755..dc0cf00 100644 --- a/poetry.lock +++ b/poetry.lock @@ -301,6 +301,17 @@ python3-openid = ">=3.0.8" requests = "*" requests-oauthlib = ">=0.3.0" +[[package]] +name = "django-cors-headers" +version = "3.13.0" +description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +Django = ">=3.2" + [[package]] name = "django-coverage-plugin" version = "2.0.3" @@ -963,7 +974,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "ab6edbffdf2275a6e323a34d85f1f354264ad3a46d3c179922a7ee72f88ce1a2" +content-hash = "af3d0422c23c7381c78eaab410bdd1a0fa1ca2f19d615a4d365808b705e73188" [metadata.files] aiohttp = [ @@ -1084,6 +1095,7 @@ django = [] django-allauth = [ {file = "django-allauth-0.45.0.tar.gz", hash = "sha256:6d46be0e1480316ccd45476db3aefb39db70e038d2a543112d314b76bb999a4e"}, ] +django-cors-headers = [] django-coverage-plugin = [] django-debug-toolbar = [] django-health-check = [] diff --git a/pyproject.toml b/pyproject.toml index 58f64fc..42f762c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ django-health-check = "^3.16.4" django-npm = "^1.0.0" python-dotenv = "^0.18.0" django-debug-toolbar = "^3.2.1" +django-cors-headers = "^3.11.0" [tool.poetry.dev-dependencies] pytest-sugar = "^0.9.4" diff --git a/shynet/api/__init__.py b/shynet/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shynet/api/admin.py b/shynet/api/admin.py new file mode 100644 index 0000000..d3c1eff --- /dev/null +++ b/shynet/api/admin.py @@ -0,0 +1 @@ +# from django.contrib import admin diff --git a/shynet/api/apps.py b/shynet/api/apps.py new file mode 100644 index 0000000..66656fd --- /dev/null +++ b/shynet/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' diff --git a/shynet/api/migrations/__init__.py b/shynet/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shynet/api/mixins.py b/shynet/api/mixins.py new file mode 100644 index 0000000..47dc9a8 --- /dev/null +++ b/shynet/api/mixins.py @@ -0,0 +1,23 @@ +from django.http import JsonResponse +from django.contrib.auth.models import AnonymousUser + +from core.models import User + + +class ApiTokenRequiredMixin: + def _get_user_by_token(self, request): + token = request.headers.get('Authorization') + if not token or not token.startswith('Token '): + return AnonymousUser() + + token = token.split(' ')[1] + user = User.objects.filter(api_token=token).first() + + return user if user else AnonymousUser() + + def dispatch(self, request, *args, **kwargs): + request.user = self._get_user_by_token(request) + if not request.user.is_authenticated: + return JsonResponse(data={}, status=403) + + return super().dispatch(request, *args, **kwargs) diff --git a/shynet/api/models.py b/shynet/api/models.py new file mode 100644 index 0000000..24e1689 --- /dev/null +++ b/shynet/api/models.py @@ -0,0 +1 @@ +# from django.db import models diff --git a/shynet/api/tests.py b/shynet/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/shynet/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/shynet/api/urls.py b/shynet/api/urls.py new file mode 100644 index 0000000..11d877f --- /dev/null +++ b/shynet/api/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("dashboard/", views.DashboardApiView.as_view(), name="services"), +] diff --git a/shynet/api/views.py b/shynet/api/views.py new file mode 100644 index 0000000..a5dc895 --- /dev/null +++ b/shynet/api/views.py @@ -0,0 +1,60 @@ +import uuid +from django.http import JsonResponse +from django.db.models import Q +from django.db.models.query import QuerySet +from django.views.generic import View + +from dashboard.mixins import DateRangeMixin +from core.models import Service + +from .mixins import ApiTokenRequiredMixin + + +def is_valid_uuid(value): + try: + uuid.UUID(value) + return True + except ValueError: + return False + + +class DashboardApiView(ApiTokenRequiredMixin, DateRangeMixin, View): + def get(self, request, *args, **kwargs): + services = Service.objects.filter( + Q(owner=request.user) | Q(collaborators__in=[request.user]) + ).distinct() + + uuid = request.GET.get('uuid') + if uuid and is_valid_uuid(uuid): + services = services.filter(uuid=uuid) + + try: + start = self.get_start_date() + end = self.get_end_date() + except ValueError: + return JsonResponse(status=400, data={'error': 'Invalid date format'}) + + services_data = [ + { + 'name': s.name, + 'uuid': s.uuid, + 'link': s.link, + 'stats': s.get_core_stats(start, end), + } + for s in services + ] + + services_data = self._convert_querysets_to_lists(services_data) + + return JsonResponse(data={'services': services_data}) + + def _convert_querysets_to_lists(self, services_data): + for service_data in services_data: + for key, value in service_data['stats'].items(): + if isinstance(value, QuerySet): + service_data['stats'][key] = list(value) + for key, value in service_data['stats']['compare'].items(): + if isinstance(value, QuerySet): + service_data['stats']['compare'][key] = list(value) + + return services_data diff --git a/shynet/core/migrations/0009_auto_20211117_0217.py b/shynet/core/migrations/0009_auto_20211117_0217.py new file mode 100644 index 0000000..461cd1e --- /dev/null +++ b/shynet/core/migrations/0009_auto_20211117_0217.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.5 on 2021-11-17 07:17 + +import core.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_auto_20200628_1403'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='api_token', + field=models.TextField(default=core.models._default_api_token, unique=True), + ), + migrations.AlterField( + model_name='user', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/shynet/core/models.py b/shynet/core/models.py index 87a2cce..6fc01d3 100644 --- a/shynet/core/models.py +++ b/shynet/core/models.py @@ -1,8 +1,9 @@ import ipaddress -import json import re import uuid +from secrets import token_urlsafe + from django.apps import apps from django.conf import settings from django.contrib.auth.models import AbstractUser @@ -44,9 +45,14 @@ def _parse_network_list(networks: str): return [ipaddress.ip_network(network.strip()) for network in networks.split(",")] +def _default_api_token(): + return token_urlsafe(32) + + class User(AbstractUser): username = models.TextField(default=_default_uuid, unique=True) email = models.EmailField(unique=True) + api_token = models.TextField(default=_default_api_token, unique=True) def __str__(self): return self.email @@ -211,6 +217,7 @@ class Service(models.Model): chart_data, chart_tooltip_format, chart_granularity = self._get_chart_data( sessions, hits, start_time, end_time, tz_now ) + return { "currently_online": currently_online, "session_count": session_count, diff --git a/shynet/dashboard/templates/account/password_change.html b/shynet/dashboard/templates/account/password_change.html index ca8f276..3fe2bbf 100644 --- a/shynet/dashboard/templates/account/password_change.html +++ b/shynet/dashboard/templates/account/password_change.html @@ -2,8 +2,8 @@ {% load i18n a17t_tags %} -{% block head_title %}{% trans "Change Password" %}{% endblock %} -{% block page_title %}{% trans "Change Password" %}{% endblock %} +{% block head_title %}{% trans "Change authentication info" %}{% endblock %} +{% block page_title %}{% trans "Change authentication info" %}{% endblock %} {% block card %}
@@ -11,4 +11,17 @@ {{ form|a17t }}
+
+
+

Personal API token

+
+ {{request.user.api_token}} +
+ {% csrf_token %} + +
+
+

To learn more about the API, see our API guide.

+
+ {% endblock %} diff --git a/shynet/dashboard/templates/dashboard/pages/service_update.html b/shynet/dashboard/templates/dashboard/pages/service_update.html index f0ccdf6..a8913e3 100644 --- a/shynet/dashboard/templates/dashboard/pages/service_update.html +++ b/shynet/dashboard/templates/dashboard/pages/service_update.html @@ -34,5 +34,20 @@ +
+
API
+
+

Shynet provides a simple API that you can use to pull data programmatically. You can access this data via this URL:

+ {{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}} +

+ There are 2 optional query parameters: +

+

+

Example using cURL:

+ curl -H 'Authorization: Token {{request.user.api_token}}' '{{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}}&startDate=2021-01-01&endDate=2050-01-01' +
{% endblock %} diff --git a/shynet/dashboard/urls.py b/shynet/dashboard/urls.py index 328ce58..7aa917b 100644 --- a/shynet/dashboard/urls.py +++ b/shynet/dashboard/urls.py @@ -1,6 +1,4 @@ -from django.contrib import admin -from django.urls import include, path -from django.views.generic import RedirectView +from django.urls import path from . import views @@ -28,4 +26,9 @@ urlpatterns = [ views.ServiceSessionView.as_view(), name="service_session", ), + path( + "api-token-refresh/", + views.RefreshApiTokenView.as_view(), + name="api_token_refresh", + ), ] diff --git a/shynet/dashboard/views.py b/shynet/dashboard/views.py index 9ce02ce..97360d0 100644 --- a/shynet/dashboard/views.py +++ b/shynet/dashboard/views.py @@ -3,8 +3,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.messages.views import SuccessMessageMixin from django.core.cache import cache from django.db.models import Q -from django.shortcuts import get_object_or_404, reverse -from django.utils import timezone +from django.shortcuts import get_object_or_404, reverse, redirect from django.views.generic import ( CreateView, DeleteView, @@ -12,11 +11,12 @@ from django.views.generic import ( ListView, TemplateView, UpdateView, + View, ) from rules.contrib.views import PermissionRequiredMixin from analytics.models import Session -from core.models import Service +from core.models import Service, _default_api_token from .forms import ServiceForm from .mixins import DateRangeMixin @@ -155,3 +155,10 @@ class ServiceSessionView(LoginRequiredMixin, PermissionRequiredMixin, DetailView data = super().get_context_data(**kwargs) data["object"] = get_object_or_404(Service, pk=self.kwargs.get("pk")) return data + + +class RefreshApiTokenView(LoginRequiredMixin, View): + def post(self, request): + request.user.api_token = _default_api_token() + request.user.save() + return redirect('account_change_password') diff --git a/shynet/shynet/settings.py b/shynet/shynet/settings.py index 3a94e73..ce71c1e 100644 --- a/shynet/shynet/settings.py +++ b/shynet/shynet/settings.py @@ -60,16 +60,19 @@ INSTALLED_APPS = [ "core", "dashboard.apps.DashboardConfig", "analytics", + "api", "allauth", "allauth.account", "allauth.socialaccount", "debug_toolbar", + "corsheaders", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -371,3 +374,6 @@ DASHBOARD_PAGE_SIZE = int(os.getenv("DASHBOARD_PAGE_SIZE", "5")) USE_RELATIVE_MAX_IN_BAR_VISUALIZATION = ( os.getenv("USE_RELATIVE_MAX_IN_BAR_VISUALIZATION", "True") == "True" ) + +CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOW_METHODS = ["GET", "OPTIONS"] diff --git a/shynet/shynet/urls.py b/shynet/shynet/urls.py index 7264261..cbec472 100644 --- a/shynet/shynet/urls.py +++ b/shynet/shynet/urls.py @@ -25,4 +25,5 @@ urlpatterns = [ path("dashboard/", include(("dashboard.urls", "dashboard"), namespace="dashboard")), path("healthz/", include("health_check.urls")), path("", include(("core.urls", "core"), namespace="core")), + path("api/v1/", include(("api.urls", "api"), namespace="api")), ]