diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml deleted file mode 100644 index 05811c03..00000000 --- a/.github/workflows/build-and-publish.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: Build and Publish Docker Image - -on: - push: - paths-ignore: - - 'README.md' - branches: - - master - tags: - - v* - -jobs: - build-amd64: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: 'recursive' - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Docker meta - id: docker_meta - uses: crazy-max/ghaction-docker-meta@v1 - with: - images: webodm/webodm_webapp - tag-semver: | - {{version}} - - name: Build and push Docker image (AMD64) - id: docker_build_amd64 - uses: docker/build-push-action@v6 - with: - file: ./Dockerfile - platforms: linux/amd64 - push: true - no-cache: true - tags: | - webodm/webodm_webapp:latest-amd64 - provenance: false - build-arm64: - runs-on: ubuntu-24.04-arm - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: 'recursive' - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Docker meta - id: docker_meta - uses: crazy-max/ghaction-docker-meta@v1 - with: - images: webodm/webodm_webapp - tag-semver: | - {{version}} - - name: Build and push Docker image (ARM64) - id: docker_build_arm64 - uses: docker/build-push-action@v6 - with: - file: ./Dockerfile - platforms: linux/arm64 - push: true - no-cache: true - tags: | - webodm/webodm_webapp:latest-arm64 - provenance: false - create-manifest: - runs-on: ubuntu-latest - needs: [build-amd64, build-arm64] - steps: - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Create and push multi-platform manifest - run: | - docker pull --platform linux/amd64 webodm/webodm_webapp:latest-amd64 - docker pull --platform linux/arm64 webodm/webodm_webapp:latest-arm64 - docker manifest create webodm/webodm_webapp:latest \ - webodm/webodm_webapp:latest-amd64 \ - webodm/webodm_webapp:latest-arm64 - docker manifest push webodm/webodm_webapp:latest - - name: Create and push multi-platform manifest (version tag) - if: startsWith(github.ref, 'refs/tags/v') - run: | - VERSION=${GITHUB_REF#refs/tags/v} - - docker manifest create webodm/webodm_webapp:${VERSION} \ - webodm/webodm_webapp:latest-amd64 \ - webodm/webodm_webapp:latest-arm64 - docker manifest push webodm/webodm_webapp:${VERSION} \ No newline at end of file diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 00000000..97d50057 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,41 @@ +name: Docker Image + +on: + push: + branches: + - main + tags: + - v* + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: docker-${{ github.ref }} + +jobs: + image: + uses: docker/github-builder/.github/workflows/build.yml@v1 + with: + output: image + # Only refs that produce tags may push: a workflow_dispatch on any other + # branch would otherwise push untagged digests and then fail. + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} + file: ./Dockerfile + platforms: | + linux/amd64 + linux/arm64 + set-meta-labels: true + meta-images: opendronemap/webui + # :edge on main; : and :latest on a v* tag (latest=auto + # skips prereleases such as v3.7.0-rc1). + meta-tags: | + type=edge,branch=main + type=semver,pattern={{version}} + secrets: + registry-auths: | + - registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index 64f96f63..39d6a9ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -242,6 +242,7 @@ RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \ libpng \ libwebp \ tiff \ + exiftool \ py3-shapely # Install libexecinfo from Alpine 3.16 (removed in 3.17+) diff --git a/app/api/authentication.py b/app/api/authentication.py index e8ef7f56..0ab8ab4c 100644 --- a/app/api/authentication.py +++ b/app/api/authentication.py @@ -3,13 +3,13 @@ class JSONWebTokenAuthenticationQS(JWTAuthentication): """ - JWT authentication that accepts tokens from query string parameter 'jwt' - instead of Authorization header + JWT authentication that accepts the token from the 'jwt' query string + parameter. Header authentication is handled by JWTAuthentication. """ - def get_raw_token(self, request): - # Check query parameters first - token = request.query_params.get('jwt') - if token: - return token.encode('utf-8') if isinstance(token, str) else token - # Fall back to standard header-based authentication - return super().get_raw_token(request) \ No newline at end of file + def authenticate(self, request): + raw_token = request.query_params.get('jwt') + if not raw_token: + return None + + validated_token = self.get_validated_token(raw_token) + return self.get_user(validated_token), validated_token diff --git a/app/api/jwt.py b/app/api/jwt.py deleted file mode 100644 index b6b2860f..00000000 --- a/app/api/jwt.py +++ /dev/null @@ -1,74 +0,0 @@ -from django.contrib.auth import authenticate -from django.contrib.auth.models import User -from django.utils.translation import gettext as _ -from rest_framework_jwt.settings import api_settings -from rest_framework_jwt.views import JSONWebTokenAPIView, RefreshJSONWebToken -from rest_framework_jwt.compat import Serializer, PasswordField -from rest_framework import serializers - -jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER -jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER - -class JSONWebTokenSerializer(Serializer): - """ - Serializer class used to validate a username and password. - - Returns a JSON Web Token that can be used to authenticate later calls. - """ - def __init__(self, *args, **kwargs): - super(JSONWebTokenSerializer, self).__init__(*args, **kwargs) - - self.fields['username'] = serializers.CharField() - self.fields['password'] = PasswordField(write_only=True) - self.fields['impersonate'] = serializers.CharField(required=False) - - def validate(self, attrs): - credentials = { - 'username': attrs.get('username'), - 'password': attrs.get('password') - } - - if all(credentials.values()): - user = authenticate(**credentials) - if user: - if not user.is_active: - msg = _('User account is disabled.') - raise serializers.ValidationError(msg) - - if attrs.get('impersonate'): - if not user.is_superuser: - raise serializers.ValidationError(_('Cannot impersonate, user is not superuser.')) - try: - impersonated = User.objects.get(username=attrs.get('impersonate')) - if not impersonated.is_active: - msg = _('User account is disabled.') - raise serializers.ValidationError(msg) - - return { - 'token': jwt_encode_handler(jwt_payload_handler(impersonated)), - 'user': impersonated - } - except: - msg = _('Unable to log in with provided credentials.') - raise serializers.ValidationError({'non_field_errors': msg, 'impersonate': "Invalid"}) - else: - return { - 'token': jwt_encode_handler(jwt_payload_handler(user)), - 'user': user - } - else: - raise serializers.ValidationError(_('Unable to log in with provided credentials.')) - else: - raise serializers.ValidationError(_('Must include "username" and "password".')) - -class ObtainJSONWebToken(JSONWebTokenAPIView): - """ - API View that receives a POST with a user's username and password - and an optional impersonate parameter (admin only) - - Returns a JSON Web Token that can be used for authenticated requests. - """ - serializer_class = JSONWebTokenSerializer - -obtain_jwt_token = ObtainJSONWebToken.as_view() -refresh_jwt_token = RefreshJSONWebToken.as_view() diff --git a/app/api/processingnodes.py b/app/api/processingnodes.py index f9e9f7ff..eb673649 100644 --- a/app/api/processingnodes.py +++ b/app/api/processingnodes.py @@ -37,7 +37,7 @@ class Meta: fields = ['has_available_options', 'id', 'hostname', 'port', 'api_version', 'queue_count', 'max_images', 'label', 'engine', 'engine_version', ] class ProcessingNodeViewSet(viewsets.ModelViewSet): - filter_class = ProcessingNodeFilter + filterset_class = ProcessingNodeFilter pagination_class = None serializer_class = ProcessingNodeSerializer diff --git a/app/api/projects.py b/app/api/projects.py index ab40af04..5cd44abb 100644 --- a/app/api/projects.py +++ b/app/api/projects.py @@ -14,6 +14,7 @@ from django.db import transaction from django.contrib.auth.models import User, Group from django.contrib.postgres.search import SearchQuery, SearchVector +from django.db.models import Prefetch from django.contrib.postgres.aggregates import StringAgg from django.db.models import Q @@ -103,9 +104,10 @@ class Meta: class ProjectViewSet(viewsets.ModelViewSet): - filter_fields = ('id', 'name', 'description', 'created_at') serializer_class = ProjectSerializer - queryset = models.Project.objects.prefetch_related('task_set').filter(deleting=False).order_by('-created_at') + queryset = models.Project.objects.prefetch_related( + Prefetch('task_set', queryset=models.Task.objects.order_by('-created_at')) + ).filter(deleting=False).order_by('-created_at') filterset_class = ProjectFilter ordering_fields = '__all__' diff --git a/app/api/urls.py b/app/api/urls.py index e31b57c9..cf3d2dad 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -11,7 +11,7 @@ from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView from .admin import AdminUserViewSet, AdminGroupViewSet, AdminProfileViewSet from rest_framework_nested import routers -from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.views import TokenObtainSlidingView, TokenRefreshSlidingView from .tiler import TileJson, Bounds, Metadata, Tiles, Export from .potree import Scene, CameraView from .workers import CheckTask, GetTaskResult, CancelTask @@ -51,8 +51,21 @@ re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/textured_model/$', TaskSafeTexturedModel.as_view()), re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/assets/(?P.+)$', TaskAssets.as_view()), re_path(r'projects/(?P[^/.]+)/tasks/import$', TaskAssetsImport.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/import/external/init$', TaskExternalImportInit.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/import/external/upload$', TaskExternalImportUpload.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/import/external/commit$', TaskExternalImportCommit.as_view()), re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/thumbnail$', TaskThumbnail.as_view()), re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/backup$', TaskBackup.as_view()), + + re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/media\.geojson$', TaskMediaGeoJSON.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/media/$', TaskMediaList.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/media/upload$', TaskMediaUpload.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/media/download/(?P.+)$', TaskMediaDownload.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/media/thumbnail/(?P.+)$', TaskMediaThumbnail.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/media/video/(?P[^/]+)/flightpath\.geojson$', TaskVideoFlightPath.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/media/manage/(?P.+)$', TaskMediaManage.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/media/panorama/(?P[^/]+)/tiles/(?P\d+)/(?P\w)/(?P\d+)/(?P\d+)\.jpg$', TaskPanoramaTiles.as_view()), + re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/images/thumbnail/(?P.+)$', Thumbnail.as_view()), re_path(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/images/download/(?P.+)$', ImageDownload.as_view()), @@ -61,16 +74,18 @@ re_path(r'workers/check/(?P.+)', CheckTask.as_view()), re_path(r'workers/get/(?P.+)', GetTaskResult.as_view()), + re_path(r'workers/cancel/(?P.+)', CancelTask.as_view()), path('auth/', include('rest_framework.urls')), - path('token-auth/', TokenObtainPairView.as_view()), + re_path(r'^token-auth/refresh/?$', TokenRefreshSlidingView.as_view()), + re_path(r'^token-auth/?$', TokenObtainSlidingView.as_view()), re_path(r'^plugins/(?P[^/.]+)/(.*)$', api_view_handler), -] -if settings.ENABLE_USERS_API: - urlpatterns.append(re_path(r'users', UsersList.as_view())) + path('user/profile/', UsersProfile.as_view()), + re_path(r'^users/?$', UsersList.as_view()), + re_path(r'^groups/?$', GroupsList.as_view()), +] if settings.EXTERNAL_AUTH_ENDPOINT != '': urlpatterns.append(path('external-token-auth/', ExternalTokenAuth.as_view())) - diff --git a/app/models/task.py b/app/models/task.py index 23202427..0e8602ba 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -531,7 +531,7 @@ def read_backup_file(self): self.name = backup.get('name', self.name) self.processing_time = backup.get('processing_time', self.processing_time) self.options = backup.get('options', self.options) - self.created_at = datetime.fromtimestamp(backup.get('created_at', self.created_at.astimezone(timezone.utc).timestamp()), tz=timezone.utc) + self.created_at = datetime.fromtimestamp(backup.get('created_at', self.created_at.astimezone(tz.utc).timestamp()), tz=tz.utc) self.public = backup.get('public', self.public) self.resize_to = backup.get('resize_to', self.resize_to) self.potree_scene = backup.get('potree_scene', self.potree_scene) diff --git a/app/tests/test_api.py b/app/tests/test_api.py index c7f056f2..3f3acb5f 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -514,7 +514,7 @@ def test_token_auth(self): 'username': 'testuser', 'password': 'wrongpwd' }) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) # Can generate token with valid credentials res = client.post('/api/token-auth/', { @@ -523,7 +523,7 @@ def test_token_auth(self): }) self.assertEqual(res.status_code, status.HTTP_200_OK) - token = res.data['access'] + token = res.data['token'] self.assertTrue(len(token) > 0) # Can access resources by passing token via querystring @@ -534,3 +534,7 @@ def test_token_auth(self): client = APIClient(HTTP_AUTHORIZATION="Bearer {0}".format(token)) res = client.get('/api/processingnodes/') self.assertEqual(res.status_code, status.HTTP_200_OK) + + client = APIClient(HTTP_AUTHORIZATION="JWT {0}".format(token)) + res = client.get('/api/processingnodes/') + self.assertEqual(res.status_code, status.HTTP_200_OK) diff --git a/app/tests/test_api_admin.py b/app/tests/test_api_admin.py index a998be01..8e82b8cf 100644 --- a/app/tests/test_api_admin.py +++ b/app/tests/test_api_admin.py @@ -31,7 +31,7 @@ def test_user(self): 'password': super_user_pass, }) self.assertEqual(res.status_code, status.HTTP_200_OK) - super_user_token = res.data['access'] + super_user_token = res.data['token'] client = APIClient(HTTP_AUTHORIZATION="Bearer {0}".format(super_user_token)) # Can create (active) user @@ -89,7 +89,7 @@ def test_user(self): 'password': user_pass, }) self.assertEqual(res.status_code, status.HTTP_200_OK) - user_token = res.data['access'] + user_token = res.data['token'] client = APIClient(HTTP_AUTHORIZATION="Bearer {0}".format(user_token)) # Can't create user @@ -127,7 +127,7 @@ def test_group(self): 'password': super_user_pass, }) self.assertEqual(res.status_code, status.HTTP_200_OK) - super_user_token = res.data['access'] + super_user_token = res.data['token'] client = APIClient(HTTP_AUTHORIZATION="Bearer {0}".format(super_user_token)) # Can create group @@ -181,7 +181,7 @@ def test_group(self): 'password': user_pass, }) self.assertEqual(res.status_code, status.HTTP_200_OK) - user_token = res.data['access'] + user_token = res.data['token'] client = APIClient(HTTP_AUTHORIZATION="Bearer {0}".format(user_token)) # Can't create group @@ -286,63 +286,4 @@ def test_refresh_token(self): }) self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertNotEqual(token, res.data['token']) - self.assertEqual(jwt.decode(res.data['token'], None, False).get('username'), 'testuser') - - def test_impersonation(self): - client = APIClient() - - # Create a test user to impersonate - impersonated_user = User.objects.create_user( - username='impersonateduser', - password='test1234', - ) - - # Create a mock project for the impersonated user - Project.objects.create( - owner=impersonated_user, - name='Impersonated Project' - ) - - # Regular user can get token, but can't impersonate - user_name = 'testuser' - user_pass = 'test1234' - res = client.post('/api/token-auth/', { - 'username': user_name, - 'password': user_pass, - }) - self.assertEqual(res.status_code, status.HTTP_200_OK) - - res = client.post('/api/token-auth/', { - 'username': user_name, - 'password': user_pass, - 'impersonate': 'impersonateduser' - }) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - # Super user can impersonate - - # Create a mock project for admin user - Project.objects.create( - owner=User.objects.get(username='testsuperuser'), - name='Admin Project' - ) - - client = APIClient() - res = client.post('/api/token-auth/', { - 'username': 'testsuperuser', - 'password': 'test1234', - 'impersonate': 'impersonateduser' - }) - self.assertEqual(res.status_code, status.HTTP_200_OK) - impersonated_token = res.data['token'] - - # Use impersonated token to access projects - # Projects should be filtered by impersonated user - impersonated_client = APIClient(HTTP_AUTHORIZATION="{0} {1}".format(api_settings.JWT_AUTH_HEADER_PREFIX, impersonated_token)) - - res = impersonated_client.get('/api/projects/') - self.assertEqual(res.status_code, status.HTTP_200_OK) - - # Verify the response contains only projects owned by impersonated user - self.assertEqual(len(res.data), 1) - self.assertTrue(res.data[0]['name'] == 'Impersonated Project') \ No newline at end of file + self.assertEqual(str(jwt.decode(res.data['token'], options={'verify_signature': False}).get('user_id')), str(User.objects.get(username='testuser').id)) diff --git a/app/tests/test_api_export.py b/app/tests/test_api_export.py index 6f98a835..a02642d1 100644 --- a/app/tests/test_api_export.py +++ b/app/tests/test_api_export.py @@ -229,7 +229,7 @@ def testExport(crop = False): res = client.get("/api/workers/get/{}?filename={}".format(celery_task_id, reply["filename"])) self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertEqual(res._headers['content-disposition'][1], 'attachment; filename={}'.format(reply["filename"])) + self.assertEqual(res['Content-Disposition'], 'attachment; filename={}'.format(reply["filename"])) else: self.assertTrue(len(reply[0]) > 0) # Error message diff --git a/app/tests/test_api_task.py b/app/tests/test_api_task.py index 89751786..731536c1 100644 --- a/app/tests/test_api_task.py +++ b/app/tests/test_api_task.py @@ -594,7 +594,7 @@ def extract_xmp(file): # Can download exported orthophoto res = client.get("/api/workers/get/{}?filename=odm_orthophoto_NDVI.tif".format(celery_task_id)) self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertEquals(res.get('Content-Disposition'), "attachment; filename=odm_orthophoto_NDVI.tif") + self.assertEqual(res.get('Content-Disposition'), "attachment; filename=odm_orthophoto_NDVI.tif") with Image.open(io.BytesIO(res.content)) as i: self.assertEqual(i.width, 212) self.assertEqual(i.height, 212) diff --git a/app/tests/test_task_wkt.py b/app/tests/test_task_wkt.py index 7356aa07..c576caf0 100644 --- a/app/tests/test_task_wkt.py +++ b/app/tests/test_task_wkt.py @@ -29,13 +29,13 @@ def test_task_wkt(self): # Import with URL upload method res = client.post("/api/projects/{}/tasks/import".format(project.id), { - 'url': "https://github.com/OpenDroneMap/WebUI/releases/download/v3.0.1/brighton-proj-test.zip", + 'url': "https://github.com/WebODM/WebODM/releases/download/v3.0.1/brighton-proj-test.zip", 'name': "test" }) self.assertEqual(res.status_code, status.HTTP_201_CREATED) url_import_task = Task.objects.get(id=res.data['id']) - + # Wait for completion c = 0 while c < 10: @@ -46,7 +46,7 @@ def test_task_wkt(self): c += 1 time.sleep(1) - self.assertEqual(url_import_task.import_url, "https://github.com/OpenDroneMap/WebUI/releases/download/v3.0.1/brighton-proj-test.zip") + self.assertEqual(url_import_task.import_url, "https://github.com/WebODM/WebODM/releases/download/v3.0.1/brighton-proj-test.zip") self.assertEqual(url_import_task.name, "test") # EPSG should be none, but WKT should be populated @@ -61,4 +61,4 @@ def test_task_wkt(self): res = client.get("/api/projects/{}/tasks/{}/orthophoto/metadata".format(project.id, url_import_task.id)) self.assertEqual(res.status_code, status.HTTP_200_OK) metadata = json.loads(res.content.decode("utf-8")) - self.assertEqual(metadata['bounds']['crs'], url_import_task.wkt) + self.assertEqual(metadata['bounds']['crs'], 'EPSG:4326') diff --git a/app/tests/test_welcome.py b/app/tests/test_welcome.py index 28d99cd0..f1a3593f 100644 --- a/app/tests/test_welcome.py +++ b/app/tests/test_welcome.py @@ -32,7 +32,7 @@ def test_first_screen(self): res = c.post('/welcome/', data={ 'username': 'testadminuser', 'password': ''}, follow=True) - self.assertFormError(res, 'firstuserform', 'password', 'This field is required.') + self.assertFormError(res.context['firstuserform'], 'password', 'This field is required.') self.assertTrue(User.objects.count() == 0, 'No users were created') # User can create admin user diff --git a/app/tests/utils.py b/app/tests/utils.py index 2d01bc08..c1bc6dc7 100644 --- a/app/tests/utils.py +++ b/app/tests/utils.py @@ -21,9 +21,11 @@ def start_processing_node(args = []): node_odm = subprocess.Popen(['node', 'index.js', '--port', '11223', '--test'] + args, shell=False, cwd=os.path.join(current_dir, "..", "..", "nodeodm", "external", "NodeODM")) time.sleep(3) # Wait for the server to launch - yield node_odm - node_odm.terminate() - time.sleep(1) # Wait for the server to stop + try: + yield node_odm + finally: + node_odm.terminate() + node_odm.wait(timeout=10) @contextmanager def start_simple_auth_server(args = []): diff --git a/app/urls.py b/app/urls.py index 8727e995..65d56573 100644 --- a/app/urls.py +++ b/app/urls.py @@ -42,6 +42,9 @@ path('about/', app_views.about, name='about'), re_path(r'^dev-tools/(?P.*)$', dev_views.dev_tools, name='dev_tools'), + re_path(r'^oidc/login/(?P[0-9]+)/$', oidc_views.oidc_login, name='oidc_login'), + path('oidc/callback/', oidc_views.oidc_callback, name='oidc_callback'), + # TODO: add caching: https://docs.djangoproject.com/en/5.2/topics/i18n/translation/#note-on-performance path('jsi18n/', JavaScriptCatalog.as_view(packages=['app']), name='javascript-catalog'), path('i18n/', include('django.conf.urls.i18n')), diff --git a/coreplugins/lightning/api.py b/coreplugins/lightning/api.py index dc550be7..f2815719 100644 --- a/coreplugins/lightning/api.py +++ b/coreplugins/lightning/api.py @@ -111,7 +111,7 @@ def check_refresh_token(): nonlocal cloud_token try: - meta = jwt.decode(cloud_token, None, False) + meta = jwt.decode(cloud_token, options={"verify_signature": False}) exp = meta.get('exp', time.time()) # Refresh token if less than 1 hour remaining to expiry diff --git a/docker-compose.nodeodm.gpu.nvidia.yml b/docker-compose.nodeodm.gpu.nvidia.yml index 668f858f..c35ac7be 100644 --- a/docker-compose.nodeodm.gpu.nvidia.yml +++ b/docker-compose.nodeodm.gpu.nvidia.yml @@ -10,7 +10,7 @@ services: environment: - WO_DEFAULT_NODES node-odm-1: - image: opendronemap/nodeodm:stable + image: opendronemap/nodeodm:3.6.2-gpu container_name: node-odm-1 expose: - "3000" diff --git a/docker-compose.nodeodm.yml b/docker-compose.nodeodm.yml index 8dcc1e9f..79224466 100644 --- a/docker-compose.nodeodm.yml +++ b/docker-compose.nodeodm.yml @@ -10,7 +10,7 @@ services: environment: - WO_DEFAULT_NODES node-odm-1: - image: opendronemap/nodeodm:stable + image: opendronemap/nodeodm:3.6.2 container_name: node-odm-1 expose: - "3000" diff --git a/docker-compose.yml b/docker-compose.yml index b4d584e1..13b301d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: restart: unless-stopped oom_score_adj: -100 webapp: - build: . + image: opendronemap/webui container_name: webapp entrypoint: /bin/bash -c "crond && chmod +x /webui/*.sh && /bin/bash -c \"/webui/wait-for-postgres.sh db /webui/wait-for-it.sh -t 0 broker:6379 -- /webui/start.sh\"" volumes: @@ -58,7 +58,7 @@ services: restart: unless-stopped oom_score_adj: -500 worker: - build: . + image: opendronemap/webui container_name: worker entrypoint: /bin/bash -c "/webui/wait-for-postgres.sh db /webui/wait-for-it.sh -t 0 broker:6379 -- /webui/wait-for-it.sh -t 0 webapp:8000 -- /webui/worker.sh start" volumes: diff --git a/nodeodm/external/NodeODM b/nodeodm/external/NodeODM new file mode 160000 index 00000000..d45bc41b --- /dev/null +++ b/nodeodm/external/NodeODM @@ -0,0 +1 @@ +Subproject commit d45bc41b0c7a51e7f3796035f3bc238e9e01d0a2 diff --git a/nodeodm/models.py b/nodeodm/models.py index 16ddaecd..0508c21b 100644 --- a/nodeodm/models.py +++ b/nodeodm/models.py @@ -91,7 +91,7 @@ def update_node_info(self): self.last_refreshed = timezone.now() self.save() return True - except exceptions.GenericError: + except exceptions.OdmError: return False def api_client(self, timeout=30): @@ -211,7 +211,7 @@ def auto_update_node_info(sender, instance, created, **kwargs): if created: try: instance.update_node_info() - except exceptions.GenericError: + except exceptions.OdmError: pass except Exception as e: logger.warning("auto_update_node_info: " + str(e)) diff --git a/nodeodm/tests.py b/nodeodm/tests.py index 82a1fde0..43af655a 100644 --- a/nodeodm/tests.py +++ b/nodeodm/tests.py @@ -3,7 +3,6 @@ import requests from django.test import TestCase -from django.utils import six import time from django.utils import timezone from os import path @@ -44,7 +43,7 @@ def test_offline_api(self): def test_info(self): with start_processing_node(): info = self.api_client.info() - self.assertTrue(isinstance(info.version, six.string_types), "Found version string") + self.assertTrue(isinstance(info.version, str), "Found version string") self.assertTrue(isinstance(info.task_queue_count, int), "Found task queue count") self.assertTrue(info.max_images is None, "Found task max images") @@ -67,8 +66,8 @@ def test_online_processing_node(self): self.assertTrue(online_node.api_version != "", "API version is set") self.assertTrue(online_node.max_images is None, "No max images limit is set") - self.assertTrue(isinstance(online_node.get_available_options_json(), six.string_types), "Available options json works") - self.assertTrue(isinstance(online_node.get_available_options_json(pretty=True), six.string_types), "Available options json works with pretty") + self.assertTrue(isinstance(online_node.get_available_options_json(), str), "Available options json works") + self.assertTrue(isinstance(online_node.get_available_options_json(pretty=True), str), "Available options json works with pretty") def test_offline_processing_node(self): diff --git a/webui.sh b/webui.sh index 8af4ac9b..ea01700a 100755 --- a/webui.sh +++ b/webui.sh @@ -542,7 +542,7 @@ down(){ rebuild(){ run "$docker_compose down --remove-orphans" run "rm -fr node_modules/ || sudo rm -fr node_modules/" - run "rm -fr nodeodm/external/NodeODM || sudo rm -fr nodeodm/external/NodeODM" + run "rm -fr nodeodm/external/NodeODM/node_modules || sudo rm -fr nodeodm/external/NodeODM/node_modules" run "$docker_compose -f docker-compose.yml -f docker-compose.build.yml build --no-cache" #run "docker images --no-trunc -aqf \"dangling=true\" | xargs docker rmi" echo -e "\033[1mDone!\033[0m You can now start OpenDroneMap WebUI by running $0 start" @@ -608,7 +608,7 @@ update(){ if [[ -d "locale" ]] && [[ -n "$(ls -A locale)" ]]; then run "git submodule sync" fi - run "git pull origin master" + run "git pull origin main" else echo "Skipping source update (.git directory not found)" fi diff --git a/webui/settings.py b/webui/settings.py index bdef444b..808403d8 100644 --- a/webui/settings.py +++ b/webui/settings.py @@ -375,10 +375,12 @@ 'ALGORITHM': 'HS256', 'SIGNING_KEY': SECRET_KEY, 'VERIFYING_KEY': None, - 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_TYPES': ('Bearer', 'JWT'), 'USER_ID_FIELD': 'id', 'USER_ID_CLAIM': 'user_id', - 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.SlidingToken',), + 'SLIDING_TOKEN_LIFETIME': timedelta(hours=6), + 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=7), 'TOKEN_TYPE_CLAIM': 'token_type', }