From 3934a58b91e4fa59d2bb360f04693e7f7a7b103a Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 09:56:07 +0100 Subject: [PATCH 01/10] Restore NodeODM submodule at v3.6.2 The gitlink was dropped in 3623d56c. Without it, nodeodm/setup.sh fails in the TEST_BUILD image and the Test Docker workflow cannot run. --- nodeodm/external/NodeODM | 1 + 1 file changed, 1 insertion(+) create mode 160000 nodeodm/external/NodeODM 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 From c77b4dc126e29dfd15045d74952d41bf75888c46 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 09:56:07 +0100 Subject: [PATCH 02/10] Pin the default processing node to NodeODM 3.6.2 opendronemap/nodeodm:stable is no longer updated. Use the release tags 3.6.2 and 3.6.2-gpu. --- docker-compose.nodeodm.gpu.nvidia.yml | 2 +- docker-compose.nodeodm.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" From d9fbf79274a2ddf328798a6be947b19d98f88fac Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 10:11:53 +0100 Subject: [PATCH 03/10] Catch pyodm's OdmError when a processing node is unreachable The node info refresh caught exceptions.GenericError, which exists in pyodx but not in the pinned pyodm 1.5.11. An unreachable node raised AttributeError from update_node_info instead of returning False. nodeodm/tests.py imported django.utils.six, removed in Django 3.0. --- nodeodm/models.py | 4 ++-- nodeodm/tests.py | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) 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): From 32f14b9f0af6a192cf17e678ecaf71a67e52c892 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 11:00:20 +0100 Subject: [PATCH 04/10] Publish opendronemap/webui from main The publish workflow triggered on master, which no longer exists, and pushed to webodm/webodm_webapp. It now uses docker/github-builder: :edge from main, : and :latest from a v* tag, amd64 and arm64 on native runners. docker-compose.yml runs the published image; docker-compose.build.yml still overrides it for local builds. webui.sh update pulls main. --- .github/workflows/build-and-publish.yml | 106 ------------------------ .github/workflows/docker.yaml | 41 +++++++++ docker-compose.yml | 4 +- webui.sh | 2 +- 4 files changed, 44 insertions(+), 109 deletions(-) delete mode 100644 .github/workflows/build-and-publish.yml create mode 100644 .github/workflows/docker.yaml 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/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/webui.sh b/webui.sh index 8af4ac9b..a2de4553 100755 --- a/webui.sh +++ b/webui.sh @@ -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 From 4d5da3b4e2750c6e4dea47c97cb347c7ba92ffeb Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 11:00:20 +0100 Subject: [PATCH 05/10] Restore API routes dropped by the Alpine merge 7e6376a9 replaced app/api/urls.py and app/urls.py with older versions. The views for media, panorama tiles, video flight paths, external task import, worker cancel, users, groups, user profile, external token auth and OIDC still existed but nothing routed to them. --- app/api/urls.py | 27 +++++++++++++++++++++------ app/urls.py | 3 +++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/app/api/urls.py b/app/api/urls.py index e31b57c9..0f7424f1 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 .jwt import obtain_jwt_token, refresh_jwt_token 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/?$', refresh_jwt_token), + re_path(r'^token-auth/?$', obtain_jwt_token), 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/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')), From 7490dcdb9c3932ff414be2f8a06a7a83bb50aeec Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 11:00:20 +0100 Subject: [PATCH 06/10] Issue JSON Web Tokens with simplejwt sliding tokens /api/token-auth/ and /api/token-auth/refresh/ are simplejwt's sliding token views. Existing clients keep working: the response carries a token field, refresh takes a token, and the JWT header prefix is accepted alongside Bearer. A failed login returns 401 instead of 400. Superuser impersonation is not carried over. Query string authentication overrides authenticate(); simplejwt never calls get_raw_token when there is no Authorization header. jwt.decode calls use the PyJWT 2 signature. --- app/api/authentication.py | 18 ++++----- app/api/jwt.py | 74 ------------------------------------ app/api/urls.py | 6 +-- app/tests/test_api.py | 8 +++- app/tests/test_api_admin.py | 69 +++------------------------------ coreplugins/lightning/api.py | 2 +- webui/settings.py | 6 ++- 7 files changed, 28 insertions(+), 155 deletions(-) delete mode 100644 app/api/jwt.py 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/urls.py b/app/api/urls.py index 0f7424f1..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 .jwt import obtain_jwt_token, refresh_jwt_token +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 @@ -77,8 +77,8 @@ re_path(r'workers/cancel/(?P.+)', CancelTask.as_view()), path('auth/', include('rest_framework.urls')), - re_path(r'^token-auth/refresh/?$', refresh_jwt_token), - re_path(r'^token-auth/?$', obtain_jwt_token), + 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), 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/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/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', } From 107943cdcce267f1f2a29f18ae3352116acc7ba5 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 11:00:20 +0100 Subject: [PATCH 07/10] Use django-filter 24 attribute names and order project tasks filter_class and filter_fields are ignored by django-filter 24, so the id and has_available_options filters on processing nodes did nothing. Project task ids are listed newest first instead of in table order. --- app/api/processingnodes.py | 2 +- app/api/projects.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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__' From 9352955e7d43be6725334d403d97277418cce168 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 11:00:20 +0100 Subject: [PATCH 08/10] Fix Django 6 and Python 3.12 breakage in tests and backup import django.utils.timezone.utc was removed in Django 5, so restoring a task from a backup logged 'Cannot read backup file' and lost its fields. Tests: assertFormError takes the form, response headers are read through the response, assertEquals is gone in Python 3.12, orthophoto metadata bounds are reported in EPSG:4326, the WKT import fixture is fetched from the WebODM release that hosts it (the rebrand had rewritten the URL to a release this repository does not have), and the test processing node is terminated in a finally block so one failing test no longer leaves a node running for every test after it. --- app/models/task.py | 2 +- app/tests/test_api_export.py | 2 +- app/tests/test_api_task.py | 2 +- app/tests/test_task_wkt.py | 8 ++++---- app/tests/test_welcome.py | 2 +- app/tests/utils.py | 8 +++++--- 6 files changed, 13 insertions(+), 11 deletions(-) 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_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 = []): From 7c94efc996b912cab4db2dc359b3be9aef676c7e Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 11:00:20 +0100 Subject: [PATCH 09/10] Install exiftool in the runtime image app/imageutils.py calls exiftool to detect panoramas and it was not installed. --- Dockerfile | 1 + 1 file changed, 1 insertion(+) 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+) From 960a9666f66f1e1fc41da9659ecc95cd5033c4e7 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 28 Sep 2026 16:04:47 +0100 Subject: [PATCH 10/10] Keep the NodeODM submodule checkout across rebuilds webui.sh rebuild removed nodeodm/external/NodeODM, which is a submodule. The next test image build then failed in nodeodm/setup.sh and git showed the submodule as deleted. Only its node_modules is removed now. --- webui.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui.sh b/webui.sh index a2de4553..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"