diff --git a/api/v1/migrations/0004_remove_server_id_alter_server_url.py b/api/v1/migrations/0004_remove_server_id_alter_server_url.py new file mode 100644 index 0000000..ad38a86 --- /dev/null +++ b/api/v1/migrations/0004_remove_server_id_alter_server_url.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.4 on 2024-05-02 22:25 + +import v1.models.server +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('v1', '0003_remove_server_inactivity_counter_server_up_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='server', + name='id', + ), + migrations.AlterField( + model_name='server', + name='url', + field=models.CharField(max_length=255, primary_key=True, serialize=False, validators=[v1.models.server.OptionalSchemeURLValidator()]), + ), + ] diff --git a/api/v1/migrations/0005_alter_server_url.py b/api/v1/migrations/0005_alter_server_url.py new file mode 100644 index 0000000..d8a0d1a --- /dev/null +++ b/api/v1/migrations/0005_alter_server_url.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.4 on 2024-05-02 23:51 + +import v1.models.server +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('v1', '0004_remove_server_id_alter_server_url'), + ] + + operations = [ + migrations.AlterField( + model_name='server', + name='url', + field=models.CharField(error_messages={'unique': 'A server with this URL already exists.'}, max_length=255, primary_key=True, serialize=False, validators=[v1.models.server.OptionalSchemeURLValidator()]), + ), + ] diff --git a/api/v1/models/server.py b/api/v1/models/server.py index 0366836..54f5462 100644 --- a/api/v1/models/server.py +++ b/api/v1/models/server.py @@ -1,14 +1,13 @@ import datetime import json -import os -import socket import requests from django.db import models from django.core.validators import URLValidator -from django.core.exceptions import ValidationError from urllib.parse import urlparse from v1.common.logging import logger from django.contrib.gis.geoip2 import GeoIP2 +from django.core.exceptions import ValidationError + # Required and recommended settings used to build the initial client server card required_settings = [ @@ -39,9 +38,11 @@ class OptionalSchemeURLValidator(URLValidator): class Server(models.Model): name = models.CharField(max_length=255, null=True, editable=False) url = models.CharField( + primary_key=True, max_length=255, null=False, validators=[OptionalSchemeURLValidator()], + error_messages={"unique": "A server with this URL already exists."}, ) location = models.CharField(max_length=255, null=True, editable=False) max_level = models.IntegerField(null=True, editable=False) @@ -55,8 +56,11 @@ class Server(models.Model): @property def expires(self): - time_since_creation = self.updated - self.created - return self.updated + min(time_since_creation, datetime.timedelta(hours=24)) + try: + time_since_creation = self.updated - self.created + return self.updated + min(time_since_creation, datetime.timedelta(hours=24)) + except: + return datetime.datetime(0, 1, 1, 0, 0, 0) def _str_(self): return self.url @@ -70,13 +74,10 @@ class Server(models.Model): parsed_url = urlparse(self.url) self.url = parsed_url.netloc.lower() - # Check if server URL is unique - existing_server = Server.objects.filter(url=self.url).first() - if existing_server and existing_server.pk != self.pk: - raise ValidationError("A server with this URL already exists.") - # Validate the server API if not self.parse_server_api(): + if not self.created: + raise ValidationError("Server verification failed.") if self.expires <= datetime.datetime.now(datetime.timezone.utc): return False self.up = False diff --git a/api/v1/serializers/server.py b/api/v1/serializers/server.py index 213f420..38385f0 100644 --- a/api/v1/serializers/server.py +++ b/api/v1/serializers/server.py @@ -7,7 +7,6 @@ class ServerSerializer(serializers.ModelSerializer): class Meta: model = Server fields = [ - "id", "name", "url", "location", diff --git a/api/v1/views/server.py b/api/v1/views/server.py index eaf303e..35ad485 100644 --- a/api/v1/views/server.py +++ b/api/v1/views/server.py @@ -1,7 +1,11 @@ +from django.http import Http404 from rest_framework import viewsets, mixins +from rest_framework.response import Response from django.db.models.fields.json import KT from v1.serializers.server import ServerDetailsSerializer, ServerSerializer from v1.models.server import Server +from rest_framework import status +from django.core.exceptions import ValidationError class ServerViewSet( @@ -43,8 +47,53 @@ class ServerViewSet( class ServerDetailsViewSet( + mixins.CreateModelMixin, mixins.RetrieveModelMixin, + mixins.ListModelMixin, viewsets.GenericViewSet, ): queryset = Server.objects.all() serializer_class = ServerDetailsSerializer + + def get_object(self): + queryset = self.filter_queryset(self.get_queryset()) + url_param = self.request.query_params.get("url") + if url_param is not None: + try: + return queryset.get(url=url_param) + except Server.DoesNotExist: + raise Http404("Server not found with the provided URL.") + return super().get_object() + + def create_server(self, url_param): + try: + serializer = self.get_serializer(data={"url": url_param}) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return serializer.instance + except ValidationError as e: + raise Http404(e.message) + + def create(self, request, *args, **kwargs): + url_param = request.data.get("url") + if url_param: + try: + server = Server.objects.get(url=url_param) + serializer = self.get_serializer(server) + return Response(serializer.data, status=status.HTTP_200_OK) + except Server.DoesNotExist: + instance = self.create_server(url_param) + serializer = self.get_serializer(instance) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + url_param = self.request.query_params.get("url") + if url_param is not None: + queryset = queryset.filter(url=url_param) + if not queryset.exists(): + instance = self.create_server(url_param) + serializer = self.get_serializer(instance) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return self.retrieve(request, *args, **kwargs) + raise Http404("No URL provided.") diff --git a/client/src/App.tsx b/client/src/App.tsx index 8e80877..167ea1a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -39,7 +39,7 @@ export function App() { element={} /> } /> } /> diff --git a/client/src/apiUtil.tsx b/client/src/apiUtil.tsx index 3ec9046..077c407 100644 --- a/client/src/apiUtil.tsx +++ b/client/src/apiUtil.tsx @@ -26,23 +26,34 @@ export const fetchDataFromBackend = async ( queryParams = {} ) => { const queryString = new URLSearchParams(queryParams).toString(); - const url = `${import.meta.env.PROD ? 'https://api.ixion.dev' : 'http://localhost:8000'}/${dir}/${queryString ? `?${queryString}` : ''}`; + const url = `${import.meta.env.PROD ? 'https://api.ixion.dev' : 'http://localhost:8000'}/${dir}${queryString ? `?${queryString}` : ''}`; try { const response = await fetch(url, { signal: AbortSignal.timeout(5000), }); + if (!response.ok) { - throw new Error( - `(${response.status.toString()} - ${response.statusText || 'unknown'})` - ); + let errorMessage = + 'An unknown error occurred while fetching data from the backend.'; + + // Check if the response has JSON content + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const errorData = await response.json(); + errorMessage = errorData.url || errorData.detail; + } + + throw new Error(errorMessage); } return await response.json(); } catch (err) { if (err instanceof Error) { - throw new Error(`${err.message} - ${url}`); + throw new Error(err.message); } else { - throw new Error('Something went wrong.'); + throw new Error( + 'An unhandled error occurred while fetching data from the backend.' + ); } } }; @@ -59,7 +70,7 @@ export const postData = async (inputText: string) => { body: JSON.stringify({ url: inputText }), } ); - let message = `Server verification failed!`; + let message = `An unknown error occurred while sending data to the backend.`; let success = false; if (!response.ok) { // bad url or duplicate @@ -73,13 +84,13 @@ export const postData = async (inputText: string) => { }; } const responseData = await response.json(); - if (responseData.id !== null) { + if (response.status === 201) { message = 'Added new server!'; success = true; } return { message, success, data: responseData }; } catch (err) { - let message = 'An unknown error occurred.'; + let message = 'An unhandled error while sending data to the backend.'; if (err instanceof Error) { message = err.message; } diff --git a/client/src/components/AddServer.tsx b/client/src/components/AddServer.tsx index 42127aa..86b14f0 100644 --- a/client/src/components/AddServer.tsx +++ b/client/src/components/AddServer.tsx @@ -73,7 +73,7 @@ export default function AddServer({ severity: 'success', }); if (response.data && typeof response.data === 'object') { - navigate(`/server/${response.data.id}`); + navigate(`/server/${encodeURIComponent(response.data.url)}`); setServers([response.data, ...servers]); } if (inputRef.current) { @@ -119,7 +119,11 @@ export default function AddServer({ - + Max Level @@ -194,15 +194,16 @@ export default function SearchServers({ onChange={handleSearchMaxLevelMin} // onBlur={handleBlur} inputProps={{ + name: 'max-level-min', step: 1, min: 1, max: 99, type: 'number', - 'aria-labelledby': 'max-level-min', + 'aria-labelledby': 'max-level', }} /> diff --git a/client/src/components/ServerCard.tsx b/client/src/components/ServerCard.tsx index 4610ff1..e22569f 100644 --- a/client/src/components/ServerCard.tsx +++ b/client/src/components/ServerCard.tsx @@ -330,7 +330,7 @@ export default function ServerCard({ server }: { server: ServerData }) { ) : ( filteredServers.map((server: ServerData) => ( - + )) )} diff --git a/client/src/pages/ServerDetails.tsx b/client/src/pages/ServerDetails.tsx index 1839b24..4397764 100644 --- a/client/src/pages/ServerDetails.tsx +++ b/client/src/pages/ServerDetails.tsx @@ -30,7 +30,7 @@ export default function ServerDetails({ }: { setAlertInfo: React.Dispatch>; }) { - const { id } = useParams(); + const { url } = useParams(); const [server, setServer] = useState(); const [error, setError] = useState(''); @@ -38,10 +38,10 @@ export default function ServerDetails({ let data: ServerData; const fetchServerData = async () => { try { - if (id === 'demo') { + if (url === 'demo') { data = await fetchDemo(); } else { - data = await fetchDataFromBackend(`server/${id}`); + data = await fetchDataFromBackend(`server/?url=${url}`); } } catch (err) { if (err instanceof Error) { @@ -58,7 +58,7 @@ export default function ServerDetails({ }; fetchServerData(); - }, [id, setServer, setAlertInfo, setError]); + }, [url, setServer, setAlertInfo, setError]); const [clipboardTooltip, setClipboardTooltip] = useState('Copy server URL.'); const [clipboardTooltipOpen, setClipboardTooltipOpen] = useState(false); @@ -93,12 +93,12 @@ export default function ServerDetails({ } }; - function formatExternalUrl(url: string): string { + function formatExternalUrl(serverUrl: string): string { // prepend 'https://' to the URL if it's not already there - if (!/^https?:\/\//i.test(url)) { - return `https://${url}`; + if (!/^https?:\/\//i.test(serverUrl)) { + return `https://${serverUrl}`; } - return url; + return serverUrl; } return (