kopia lustrzana https://github.com/OpenDroneMap/WebODM
commit
195463f1f0
16
README.md
16
README.md
|
@ -2,7 +2,7 @@
|
|||
|
||||
[![Build Status](https://travis-ci.org/OpenDroneMap/WebODM.svg?branch=master)](https://travis-ci.org/OpenDroneMap/WebODM) [![GitHub version](https://badge.fury.io/gh/OpenDroneMap%2FWebODM.svg)](https://badge.fury.io/gh/OpenDroneMap%2FWebODM)
|
||||
|
||||
A user-friendly, extendable application and [API](http://docs.webodm.org) for drone image processing. Generate georeferenced maps, point clouds, elevation models and textured 3D models from aerial images. It supports multiple engines for processing, currently [ODM](https://github.com/OpenDroneMap/ODM) and [MicMac](https://github.com/dronemapper-io/NodeMICMAC/) (experimental).
|
||||
A user-friendly, extendable application and [API](http://docs.webodm.org) for drone image processing. Generate georeferenced maps, point clouds, elevation models and textured 3D models from aerial images. It supports multiple engines for processing, currently [ODM](https://github.com/OpenDroneMap/ODM) and [MicMac](https://github.com/dronemapper-io/NodeMICMAC/).
|
||||
|
||||
![image](https://user-images.githubusercontent.com/1951843/73680798-efc02680-468a-11ea-9ae5-55e51427c6f1.png)
|
||||
|
||||
|
@ -35,6 +35,14 @@ A user-friendly, extendable application and [API](http://docs.webodm.org) for dr
|
|||
|
||||
## Getting Started
|
||||
|
||||
Windows and macOS users can purchase an automated [installer](https://www.opendronemap.org/webodm/download#installer), which makes the installation process easier.
|
||||
|
||||
You can also run WebODM from a Live USB/DVD. See [LiveODM](https://www.opendronemap.org/liveodm/).
|
||||
|
||||
Windows users looking for a UI-only native installation should also check [webodm.net](https://webodm.net).
|
||||
|
||||
To install WebODM manually, these steps should get you up and running:
|
||||
|
||||
* Install the following applications (if they are not installed already):
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [Docker](https://www.docker.com/)
|
||||
|
@ -76,12 +84,6 @@ To update WebODM to the latest version use:
|
|||
|
||||
We recommend that you read the [Docker Documentation](https://docs.docker.com/) to familiarize with the application lifecycle, setup and teardown, or for more advanced uses. Look at the contents of the webodm.sh script to understand what commands are used to launch WebODM.
|
||||
|
||||
Windows and macOS users can purchase an automated [installer](https://www.opendronemap.org/webodm/download#installer), which makes the installation process easier.
|
||||
|
||||
You can also run WebODM from a Live USB/DVD. See [LiveODM](https://www.opendronemap.org/liveodm/).
|
||||
|
||||
Windows users looking for a UI-only native installation should also check [webodm.net](https://webodm.net).
|
||||
|
||||
### Manage Processing Nodes
|
||||
|
||||
WebODM can be linked to one or more processing nodes that speak the [NodeODM API](https://github.com/OpenDroneMap/NodeODM/blob/master/docs/index.adoc), such as [NodeODM](https://github.com/OpenDroneMap/NodeODM), [NodeMICMAC](https://github.com/dronemapper-io/NodeMICMAC/) or [ClusterODM](https://github.com/OpenDroneMap/ClusterODM). The default configuration includes a "node-odm-1" processing node which runs on the same machine as WebODM, just to help you get started. As you become more familiar with WebODM, you might want to install processing nodes on separate machines.
|
||||
|
|
90
app/admin.py
90
app/admin.py
|
@ -1,3 +1,8 @@
|
|||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
|
||||
from django.conf.urls import url
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
|
@ -9,10 +14,13 @@ from guardian.admin import GuardedModelAdmin
|
|||
from app.models import PluginDatum
|
||||
from app.models import Preset
|
||||
from app.models import Plugin
|
||||
from app.plugins import get_plugin_by_name, enable_plugin, disable_plugin
|
||||
from app.plugins import get_plugin_by_name, enable_plugin, disable_plugin, delete_plugin, valid_plugin, \
|
||||
get_plugins_persistent_path, clear_plugins_cache, init_plugins
|
||||
from .models import Project, Task, ImageUpload, Setting, Theme
|
||||
from django import forms
|
||||
from codemirror2.widgets import CodeMirrorEditor
|
||||
from webodm import settings
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
|
||||
admin.site.register(Project, GuardedModelAdmin)
|
||||
|
||||
|
@ -74,6 +82,7 @@ admin.site.register(PluginDatum, admin.ModelAdmin)
|
|||
class PluginAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "description", "version", "author", "enabled", "plugin_actions")
|
||||
readonly_fields = ("name", )
|
||||
change_list_template = "admin/change_list_plugin.html"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
@ -105,6 +114,16 @@ class PluginAdmin(admin.ModelAdmin):
|
|||
self.admin_site.admin_view(self.plugin_disable),
|
||||
name='plugin-disable',
|
||||
),
|
||||
url(
|
||||
r'^(?P<plugin_name>.+)/delete/$',
|
||||
self.admin_site.admin_view(self.plugin_delete),
|
||||
name='plugin-delete',
|
||||
),
|
||||
url(
|
||||
r'^actions/upload/$',
|
||||
self.admin_site.admin_view(self.plugin_upload),
|
||||
name='plugin-upload',
|
||||
),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
|
@ -124,14 +143,81 @@ class PluginAdmin(admin.ModelAdmin):
|
|||
|
||||
return HttpResponseRedirect(reverse('admin:app_plugin_changelist'))
|
||||
|
||||
def plugin_delete(self, request, plugin_name, *args, **kwargs):
|
||||
try:
|
||||
delete_plugin(plugin_name)
|
||||
except Exception as e:
|
||||
messages.warning(request, "Cannot delete plugin {}: {}".format(plugin_name, str(e)))
|
||||
|
||||
return HttpResponseRedirect(reverse('admin:app_plugin_changelist'))
|
||||
|
||||
def plugin_upload(self, request, *args, **kwargs):
|
||||
file = request.FILES.get('file')
|
||||
if file is not None:
|
||||
# Save to tmp dir
|
||||
tmp_zip_path = tempfile.mktemp('plugin.zip', dir=settings.MEDIA_TMP)
|
||||
tmp_extract_path = tempfile.mkdtemp('plugin', dir=settings.MEDIA_TMP)
|
||||
|
||||
try:
|
||||
with open(tmp_zip_path, 'wb+') as fd:
|
||||
if isinstance(file, InMemoryUploadedFile):
|
||||
for chunk in file.chunks():
|
||||
fd.write(chunk)
|
||||
else:
|
||||
with open(file.temporary_file_path(), 'rb') as f:
|
||||
shutil.copyfileobj(f, fd)
|
||||
|
||||
# Extract
|
||||
with zipfile.ZipFile(tmp_zip_path, "r") as zip_h:
|
||||
zip_h.extractall(tmp_extract_path)
|
||||
|
||||
# Validate
|
||||
folders = os.listdir(tmp_extract_path)
|
||||
if len(folders) != 1:
|
||||
raise ValueError("The plugin has more than 1 root directory (it should have only one)")
|
||||
|
||||
plugin_name = folders[0]
|
||||
plugin_path = os.path.join(tmp_extract_path, plugin_name)
|
||||
if not valid_plugin(plugin_path):
|
||||
raise ValueError("This doesn't look like a plugin. Are plugin.py and manifest.json in the proper place?")
|
||||
|
||||
if os.path.exists(get_plugins_persistent_path(plugin_name)):
|
||||
raise ValueError("A plugin with the name {} already exist. Please remove it before uploading one with the same name.".format(plugin_name))
|
||||
|
||||
# Move
|
||||
shutil.move(plugin_path, get_plugins_persistent_path())
|
||||
|
||||
# Initialize
|
||||
clear_plugins_cache()
|
||||
init_plugins()
|
||||
|
||||
messages.info(request, "Plugin added successfully")
|
||||
except Exception as e:
|
||||
messages.warning(request, "Cannot load plugin: {}".format(str(e)))
|
||||
if os.path.exists(tmp_zip_path):
|
||||
os.remove(tmp_zip_path)
|
||||
if os.path.exists(tmp_extract_path):
|
||||
shutil.rmtree(tmp_extract_path)
|
||||
else:
|
||||
messages.error(request, "You need to upload a zip file")
|
||||
|
||||
return HttpResponseRedirect(reverse('admin:app_plugin_changelist'))
|
||||
|
||||
|
||||
def plugin_actions(self, obj):
|
||||
plugin = get_plugin_by_name(obj.name, only_active=False)
|
||||
return format_html(
|
||||
'<a class="button" href="{}" {}>Disable</a> '
|
||||
'<a class="button" href="{}" {}>Enable</a>',
|
||||
'<a class="button" href="{}" {}>Enable</a>'
|
||||
+ (' <a class="button" href="{}" onclick="return confirm(\'Are you sure you want to delete {}?\')"><i class="fa fa-trash"></i></a>' if not plugin.is_persistent() else ' ')
|
||||
,
|
||||
reverse('admin:plugin-disable', args=[obj.pk]) if obj.enabled else '#',
|
||||
'disabled' if not obj.enabled else '',
|
||||
reverse('admin:plugin-enable', args=[obj.pk]) if not obj.enabled else '#',
|
||||
'disabled' if obj.enabled else '',
|
||||
|
||||
reverse('admin:plugin-delete', args=[obj.pk]),
|
||||
obj.name
|
||||
)
|
||||
|
||||
plugin_actions.short_description = 'Actions'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.core.exceptions import ObjectDoesNotExist, SuspiciousFileOperation
|
||||
from rest_framework import exceptions
|
||||
import os, zipfile
|
||||
import os
|
||||
|
||||
from app import models
|
||||
|
||||
|
|
|
@ -19,7 +19,8 @@ from app import models, pending_actions
|
|||
from nodeodm import status_codes
|
||||
from nodeodm.models import ProcessingNode
|
||||
from worker import tasks as worker_tasks
|
||||
from .common import get_and_check_project, path_traversal_check
|
||||
from .common import get_and_check_project
|
||||
from app.security import path_traversal_check
|
||||
|
||||
|
||||
def flatten_files(request_files):
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from django.conf.urls import url, include
|
||||
|
||||
from app.api.presets import PresetViewSet
|
||||
from app.plugins import get_api_url_patterns
|
||||
from app.plugins.views import api_view_handler
|
||||
from .projects import ProjectViewSet
|
||||
from .tasks import TaskViewSet, TaskDownloads, TaskAssets, TaskAssetsImport
|
||||
from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView
|
||||
|
@ -49,6 +49,6 @@ urlpatterns = [
|
|||
|
||||
url(r'^auth/', include('rest_framework.urls')),
|
||||
url(r'^token-auth/', obtain_jwt_token),
|
||||
]
|
||||
|
||||
urlpatterns += get_api_url_patterns()
|
||||
url(r'^plugins/(?P<plugin_name>[^/.]+)/(.*)$', api_view_handler)
|
||||
]
|
||||
|
|
Plik binarny nie jest wyświetlany.
Plik binarny nie jest wyświetlany.
Plik binarny nie jest wyświetlany.
|
@ -7,6 +7,8 @@ import platform
|
|||
|
||||
import django
|
||||
import json
|
||||
|
||||
import shutil
|
||||
from django.conf.urls import url
|
||||
from functools import reduce
|
||||
from string import Template
|
||||
|
@ -16,6 +18,7 @@ from django.http import HttpResponse
|
|||
from app.models import Plugin
|
||||
from app.models import Setting
|
||||
from django.conf import settings
|
||||
from app.security import path_traversal_check
|
||||
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
||||
|
@ -59,7 +62,7 @@ def sync_plugin_db():
|
|||
defaults={'enabled': not disabled},
|
||||
)
|
||||
if created:
|
||||
logger.info("Added [{}] plugin to database".format(plugin.get_name()))
|
||||
logger.info("Added [{}] plugin to database".format(plugin))
|
||||
|
||||
|
||||
def clear_plugins_cache():
|
||||
|
@ -149,40 +152,11 @@ def register_plugins():
|
|||
disable_plugin(plugin.get_name())
|
||||
logger.warning("Cannot register {}: {}".format(plugin, str(e)))
|
||||
|
||||
|
||||
def get_app_url_patterns():
|
||||
"""
|
||||
@return the patterns to expose the /public directory of each plugin (if needed) and
|
||||
each mount point
|
||||
"""
|
||||
url_patterns = []
|
||||
for plugin in get_plugins():
|
||||
for mount_point in plugin.app_mount_points():
|
||||
url_patterns.append(url('^plugins/{}/{}'.format(plugin.get_name(), mount_point.url),
|
||||
mount_point.view,
|
||||
*mount_point.args,
|
||||
**mount_point.kwargs))
|
||||
|
||||
if plugin.path_exists("public"):
|
||||
url_patterns.append(url('^plugins/{}/(.*)'.format(plugin.get_name()),
|
||||
django.views.static.serve,
|
||||
{'document_root': plugin.get_path("public")}))
|
||||
|
||||
return url_patterns
|
||||
|
||||
def get_api_url_patterns():
|
||||
"""
|
||||
@return the patterns to expose the plugin API mount points (if any)
|
||||
"""
|
||||
url_patterns = []
|
||||
for plugin in get_plugins():
|
||||
for mount_point in plugin.api_mount_points():
|
||||
url_patterns.append(url('^plugins/{}/{}'.format(plugin.get_name(), mount_point.url),
|
||||
mount_point.view,
|
||||
*mount_point.args,
|
||||
**mount_point.kwargs))
|
||||
|
||||
return url_patterns
|
||||
def valid_plugin(plugin_path):
|
||||
initpy_path = os.path.join(plugin_path, "__init__.py")
|
||||
pluginpy_path = os.path.join(plugin_path, "plugin.py")
|
||||
manifest_path = os.path.join(plugin_path, "manifest.json")
|
||||
return os.path.isfile(initpy_path) and os.path.isfile(manifest_path) and os.path.isfile(pluginpy_path)
|
||||
|
||||
plugins = None
|
||||
def get_plugins():
|
||||
|
@ -193,51 +167,66 @@ def get_plugins():
|
|||
global plugins
|
||||
if plugins != None: return plugins
|
||||
|
||||
plugins_path = get_plugins_path()
|
||||
plugins_paths = get_plugins_paths()
|
||||
plugins = []
|
||||
|
||||
for dir in [d for d in os.listdir(plugins_path) if os.path.isdir(plugins_path)]:
|
||||
# Each plugin must have a manifest.json and a plugin.py
|
||||
plugin_path = os.path.join(plugins_path, dir)
|
||||
pluginpy_path = os.path.join(plugin_path, "plugin.py")
|
||||
manifest_path = os.path.join(plugin_path, "manifest.json")
|
||||
|
||||
# Do not load test plugin unless we're in test mode
|
||||
if os.path.basename(plugin_path) == 'test' and not settings.TESTING:
|
||||
for plugins_path in plugins_paths:
|
||||
if not os.path.isdir(plugins_path):
|
||||
continue
|
||||
|
||||
# Ignore .gitignore
|
||||
if os.path.basename(plugin_path) == '.gitignore':
|
||||
continue
|
||||
for dir in os.listdir(plugins_path):
|
||||
# Each plugin must have a manifest.json and a plugin.py
|
||||
plugin_path = os.path.join(plugins_path, dir)
|
||||
|
||||
# Check plugin required files
|
||||
if not os.path.isfile(manifest_path) or not os.path.isfile(pluginpy_path):
|
||||
logger.warning("Found invalid plugin in {}".format(plugin_path))
|
||||
continue
|
||||
|
||||
# Instantiate the plugin
|
||||
try:
|
||||
module = importlib.import_module("plugins.{}".format(dir))
|
||||
plugin = (getattr(module, "Plugin"))()
|
||||
|
||||
# Check version
|
||||
manifest = plugin.get_manifest()
|
||||
if 'webodmMinVersion' in manifest:
|
||||
min_version = manifest['webodmMinVersion']
|
||||
|
||||
if versionToInt(min_version) > versionToInt(settings.VERSION):
|
||||
logger.warning(
|
||||
"In {} webodmMinVersion is set to {} but WebODM version is {}. Plugin will not be loaded. Update WebODM.".format(
|
||||
manifest_path, min_version, settings.VERSION))
|
||||
continue
|
||||
|
||||
# Skip plugins in blacklist
|
||||
if plugin.get_name() in settings.PLUGINS_BLACKLIST:
|
||||
# Do not load test plugin unless we're in test mode
|
||||
if os.path.basename(plugin_path).endswith('test') and not settings.TESTING:
|
||||
continue
|
||||
|
||||
plugins.append(plugin)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to instantiate plugin {}: {}".format(dir, e))
|
||||
# Ignore .gitignore
|
||||
if os.path.basename(plugin_path) == '.gitignore':
|
||||
continue
|
||||
|
||||
# Check plugin required files
|
||||
if not valid_plugin(plugin_path):
|
||||
continue
|
||||
|
||||
# Instantiate the plugin
|
||||
try:
|
||||
try:
|
||||
if settings.TESTING:
|
||||
module = importlib.import_module("app.media_test.plugins.{}".format(dir))
|
||||
else:
|
||||
module = importlib.import_module("app.media.plugins.{}".format(dir))
|
||||
|
||||
plugin = (getattr(module, "Plugin"))()
|
||||
except (ModuleNotFoundError, AttributeError) as e:
|
||||
module = importlib.import_module("plugins.{}".format(dir))
|
||||
plugin = (getattr(module, "Plugin"))()
|
||||
|
||||
# Check version
|
||||
manifest = plugin.get_manifest()
|
||||
if 'webodmMinVersion' in manifest:
|
||||
min_version = manifest['webodmMinVersion']
|
||||
manifest_path = os.path.join(plugin_path, "manifest.json")
|
||||
|
||||
if versionToInt(min_version) > versionToInt(settings.VERSION):
|
||||
logger.warning(
|
||||
"In {} webodmMinVersion is set to {} but WebODM version is {}. Plugin will not be loaded. Update WebODM.".format(
|
||||
manifest_path, min_version, settings.VERSION))
|
||||
continue
|
||||
|
||||
# Skip plugins in blacklist
|
||||
if plugin.get_name() in settings.PLUGINS_BLACKLIST:
|
||||
continue
|
||||
|
||||
# Skip plugins already added
|
||||
if plugin.get_name() in [p.get_name() for p in plugins]:
|
||||
logger.warning("Duplicate plugin name found in {}, skipping".format(plugin_path))
|
||||
continue
|
||||
|
||||
plugins.append(plugin)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to instantiate plugin {}: {}".format(dir, e))
|
||||
|
||||
return plugins
|
||||
|
||||
|
@ -281,20 +270,27 @@ def get_current_plugin():
|
|||
"""
|
||||
caller_filename = traceback.extract_stack()[-2][0]
|
||||
|
||||
relp = os.path.relpath(caller_filename, get_plugins_path())
|
||||
parts = relp.split(os.sep)
|
||||
if len(parts) > 0:
|
||||
plugin_name = parts[0]
|
||||
return get_plugin_by_name(plugin_name, only_active=False)
|
||||
for p in get_plugins_paths():
|
||||
relp = os.path.relpath(caller_filename, p)
|
||||
if ".." in relp:
|
||||
continue
|
||||
|
||||
parts = relp.split(os.sep)
|
||||
if len(parts) > 0:
|
||||
plugin_name = parts[0]
|
||||
return get_plugin_by_name(plugin_name, only_active=False)
|
||||
|
||||
return None
|
||||
|
||||
def get_plugins_path():
|
||||
def get_plugins_paths():
|
||||
current_path = os.path.dirname(os.path.realpath(__file__))
|
||||
return os.path.abspath(os.path.join(current_path, "..", "..", "plugins"))
|
||||
return [
|
||||
os.path.abspath(get_plugins_persistent_path()),
|
||||
os.path.abspath(os.path.join(current_path, "..", "..", "plugins")),
|
||||
]
|
||||
|
||||
def get_plugins_persistent_path(*paths):
|
||||
return os.path.join(settings.MEDIA_ROOT, "plugins", *paths)
|
||||
return path_traversal_check(os.path.join(settings.MEDIA_ROOT, "plugins", *paths), os.path.join(settings.MEDIA_ROOT, "plugins"))
|
||||
|
||||
def get_dynamic_script_handler(script_path, callback=None, **kwargs):
|
||||
def handleRequest(request):
|
||||
|
@ -322,6 +318,12 @@ def enable_plugin(plugin_name):
|
|||
def disable_plugin(plugin_name):
|
||||
Plugin.objects.get(pk=plugin_name).disable()
|
||||
|
||||
def delete_plugin(plugin_name):
|
||||
Plugin.objects.get(pk=plugin_name).delete()
|
||||
if os.path.exists(get_plugins_persistent_path(plugin_name)):
|
||||
shutil.rmtree(get_plugins_persistent_path(plugin_name))
|
||||
clear_plugins_cache()
|
||||
|
||||
def get_site_settings():
|
||||
return Setting.objects.first()
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import importlib
|
||||
import json
|
||||
import logging, os, sys, subprocess
|
||||
from abc import ABC
|
||||
|
@ -120,12 +119,22 @@ class PluginBase(ABC):
|
|||
"""
|
||||
return "/plugins/{}/{}".format(self.get_name(), path)
|
||||
|
||||
def is_persistent(self):
|
||||
"""
|
||||
:return: whether this plugin is persistent (stored in the /plugins directory,
|
||||
instead of /app/media/plugins which are transient)
|
||||
"""
|
||||
return ".." in os.path.relpath(self.get_path(), get_plugins_persistent_path())
|
||||
|
||||
def template_path(self, path):
|
||||
"""
|
||||
:param path: unix-style path
|
||||
:return: path used to reference Django templates for a plugin
|
||||
"""
|
||||
return "plugins/{}/templates/{}".format(self.get_name(), path)
|
||||
if self.is_persistent():
|
||||
return "plugins/{}/templates/{}".format(self.get_name(), path)
|
||||
else:
|
||||
return "app/media/plugins/{}/templates/{}".format(self.get_name(), path)
|
||||
|
||||
def path_exists(self, path):
|
||||
return os.path.exists(self.get_path(path))
|
||||
|
@ -179,6 +188,16 @@ class PluginBase(ABC):
|
|||
"""
|
||||
return []
|
||||
|
||||
def serve_public_assets(self, request):
|
||||
"""
|
||||
Should be overriden by plugins that want to control which users
|
||||
have access to the public assets. By default anyone can access them,
|
||||
including anonymous users.
|
||||
:param request: HTTP request
|
||||
:return: boolean (whether the plugin's public assets should be exposed for this request)
|
||||
"""
|
||||
return True
|
||||
|
||||
def get_dynamic_script(self, script_path, callback = None, **template_args):
|
||||
"""
|
||||
Retrieves a view handler that serves a dynamic script from
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
// Magic to include node_modules of root WebODM's directory
|
||||
process.env.NODE_PATH = "../../../node_modules";
|
||||
const fs = require('fs');
|
||||
let webodmRoot = "../../../"; // Assuming plugins/<name>/public
|
||||
if (!fs.existsSync(webodmRoot + "webodm.sh")) webodmRoot = "../../../../../"; // Assuming app/media/plugins/<name>/public
|
||||
process.env.NODE_PATH = webodmRoot + "node_modules";
|
||||
require("module").Module._initPaths();
|
||||
|
||||
let path = require("path");
|
||||
|
@ -69,7 +72,7 @@ module.exports = {
|
|||
modules: ['node_modules', 'bower_components'],
|
||||
extensions: ['.js', '.jsx'],
|
||||
alias: {
|
||||
webodm: path.resolve(__dirname, '../../../app/static/app/js')
|
||||
webodm: path.resolve(__dirname, webodmRoot + 'app/static/app/js')
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -1,3 +1,61 @@
|
|||
import os
|
||||
|
||||
from app.api.tasks import TaskNestedView as TaskView
|
||||
from app.api.workers import CheckTask as CheckTask
|
||||
from app.api.workers import GetTaskResult as GetTaskResult
|
||||
from app.api.workers import GetTaskResult as GetTaskResult
|
||||
|
||||
from django.http import HttpResponse, Http404
|
||||
from .functions import get_plugin_by_name
|
||||
from django.conf.urls import url
|
||||
from django.views.static import serve
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def try_resolve_url(request, url):
|
||||
o = urlparse(request.get_full_path())
|
||||
res = url.resolve(o.path)
|
||||
if res:
|
||||
return res
|
||||
else:
|
||||
return (None, None, None)
|
||||
|
||||
def app_view_handler(request, plugin_name=None):
|
||||
plugin = get_plugin_by_name(plugin_name) # TODO: this pings the server, which might be bad for performance with very large amount of files
|
||||
if plugin is None:
|
||||
raise Http404("Plugin not found")
|
||||
|
||||
# Try mountpoints first
|
||||
for mount_point in plugin.app_mount_points():
|
||||
view, args, kwargs = try_resolve_url(request, url(r'^/plugins/{}/{}'.format(plugin_name, mount_point.url),
|
||||
mount_point.view,
|
||||
*mount_point.args,
|
||||
**mount_point.kwargs))
|
||||
if view:
|
||||
return view(request, *args, **kwargs)
|
||||
|
||||
# Try public assets
|
||||
if os.path.exists(plugin.get_path("public")) and plugin.serve_public_assets(request):
|
||||
view, args, kwargs = try_resolve_url(request, url('^/plugins/{}/(.*)'.format(plugin_name),
|
||||
serve,
|
||||
{'document_root': plugin.get_path("public")}))
|
||||
if view:
|
||||
return view(request, *args, **kwargs)
|
||||
|
||||
raise Http404("No valid routes")
|
||||
|
||||
|
||||
def api_view_handler(request, plugin_name=None):
|
||||
plugin = get_plugin_by_name(plugin_name) # TODO: this pings the server, which might be bad for performance with very large amount of files
|
||||
if plugin is None:
|
||||
raise Http404("Plugin not found")
|
||||
|
||||
for mount_point in plugin.api_mount_points():
|
||||
view, args, kwargs = try_resolve_url(request, url(r'^/api/plugins/{}/{}'.format(plugin_name, mount_point.url),
|
||||
mount_point.view,
|
||||
*mount_point.args,
|
||||
**mount_point.kwargs))
|
||||
|
||||
if view:
|
||||
return view(request, *args, **kwargs)
|
||||
|
||||
raise Http404("No valid routes")
|
|
@ -0,0 +1,12 @@
|
|||
from django.core.exceptions import SuspiciousFileOperation
|
||||
import os
|
||||
|
||||
def path_traversal_check(unsafe_path, known_safe_path):
|
||||
known_safe_path = os.path.abspath(known_safe_path)
|
||||
unsafe_path = os.path.abspath(unsafe_path)
|
||||
|
||||
if (os.path.commonprefix([known_safe_path, unsafe_path]) != known_safe_path):
|
||||
raise SuspiciousFileOperation("{} is not safe".format(unsafe_path))
|
||||
|
||||
# Passes the check
|
||||
return unsafe_path
|
|
@ -0,0 +1,29 @@
|
|||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content_title %}
|
||||
<style type="text/css">
|
||||
.plugin-upload{
|
||||
float: right;
|
||||
margin-left: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.plugin-upload input[type="file"]{
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<div class="plugin-upload">
|
||||
<form name="form" enctype="multipart/form-data"
|
||||
action="actions/upload/" id="plugin-upload-form" method = "POST" >{% csrf_token %}
|
||||
<input type="file"
|
||||
name="file"
|
||||
id="plugin-upload-file"
|
||||
onchange="document.getElementById('plugin-upload-form').submit();"
|
||||
accept=".zip"/>
|
||||
<button type="submit" class="btn btn-sm btn-primary" onclick="document.getElementById('plugin-upload-file').click(); return false;">
|
||||
<i class="glyphicon glyphicon-upload"></i> Load Plugin (.zip)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<h1>Manage Plugins</h1>
|
||||
<div style="clear: both;"></div>
|
||||
{% endblock %}
|
|
@ -2,6 +2,7 @@ import os
|
|||
import shutil
|
||||
|
||||
import sys
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import Client
|
||||
from rest_framework import status
|
||||
|
@ -29,6 +30,22 @@ class TestPlugins(BootTestCase):
|
|||
def test_core_plugins(self):
|
||||
client = Client()
|
||||
|
||||
# We cannot access public files core plugins (plugin is disabled)
|
||||
res = client.get('/plugins/test/file.txt')
|
||||
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Cannot access mount point (plugin is disabled)
|
||||
res = client.get('/plugins/test/app_mountpoint/')
|
||||
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# No python packages have been installed (plugin is disabled)
|
||||
self.assertFalse(os.path.exists(get_plugins_persistent_path("test", "site-packages")))
|
||||
|
||||
enable_plugin("test")
|
||||
|
||||
# Python packages have been installed
|
||||
self.assertTrue(os.path.exists(get_plugins_persistent_path("test", "site-packages")))
|
||||
|
||||
# We can access public files core plugins (without auth)
|
||||
res = client.get('/plugins/test/file.txt')
|
||||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
|
@ -38,13 +55,10 @@ class TestPlugins(BootTestCase):
|
|||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
self.assertTemplateUsed(res, 'plugins/test/templates/app.html')
|
||||
|
||||
# No python packages have been installed (plugin is disabled)
|
||||
self.assertFalse(os.path.exists(get_plugins_persistent_path("test", "site-packages")))
|
||||
|
||||
enable_plugin("test")
|
||||
|
||||
# Form was rendered correctly
|
||||
self.assertContains(res, '<input type="text" name="testField" class="form-control" required id="id_testField" />', count=1, status_code=200, html=True)
|
||||
self.assertContains(res,
|
||||
'<input type="text" name="testField" class="form-control" required id="id_testField" />',
|
||||
count=1, status_code=200, html=True)
|
||||
|
||||
# It uses regex properly
|
||||
res = client.get('/plugins/test/app_mountpoint/a')
|
||||
|
@ -66,6 +80,9 @@ class TestPlugins(BootTestCase):
|
|||
test_plugin = get_plugin_by_name("test")
|
||||
self.assertTrue(os.path.exists(test_plugin.get_path("public/node_modules")))
|
||||
|
||||
# This is a persistent plugin
|
||||
self.assertTrue(test_plugin.is_persistent())
|
||||
|
||||
# A webpack file and build directory have been created as a
|
||||
# result of the build_jsx_components directive
|
||||
self.assertTrue(os.path.exists(test_plugin.get_path("public/webpack.config.js")))
|
||||
|
@ -304,4 +321,112 @@ class TestPlugins(BootTestCase):
|
|||
self.assertEqual(p.get_manifest()['author'], "Piero Toffanin")
|
||||
|
||||
|
||||
def test_plugin_loading(self):
|
||||
c = Client()
|
||||
|
||||
plugin_file = open("app/fixtures/testabc_plugin.zip", 'rb')
|
||||
bad_dir_plugin_file = open("app/fixtures/bad_dir_plugin.zip", 'rb')
|
||||
missing_manifest_plugin_file = open("app/fixtures/missing_manifest_plugin.zip", 'rb')
|
||||
|
||||
# Cannot upload new plugins anonymously
|
||||
res = c.post('/admin/app/plugin/actions/upload/', {'file': plugin_file}, follow=True)
|
||||
self.assertRedirects(res, '/admin/login/?next=/admin/app/plugin/actions/upload/')
|
||||
self.assertFalse(os.path.exists(get_plugins_persistent_path("testabc")))
|
||||
plugin_file.seek(0)
|
||||
|
||||
# Cannot upload plugins as a normal user
|
||||
c.login(username='testuser', password='test1234')
|
||||
res = c.post('/admin/app/plugin/actions/upload/', {'file': plugin_file}, follow=True)
|
||||
self.assertRedirects(res, '/admin/login/?next=/admin/app/plugin/actions/upload/')
|
||||
self.assertFalse(os.path.exists(get_plugins_persistent_path("testabc")))
|
||||
self.assertEqual(Plugin.objects.filter(pk='testabc').count(), 0)
|
||||
plugin_file.seek(0)
|
||||
|
||||
# Can upload plugin as an admin
|
||||
c.login(username='testsuperuser', password='test1234')
|
||||
res = c.post('/admin/app/plugin/actions/upload/', {'file': plugin_file}, follow=True)
|
||||
self.assertRedirects(res, '/admin/app/plugin/')
|
||||
messages = list(res.context['messages'])
|
||||
self.assertTrue('Plugin added successfully' in str(messages[0]))
|
||||
self.assertTrue(os.path.exists(get_plugins_persistent_path("testabc")))
|
||||
plugin_file.seek(0)
|
||||
|
||||
# Plugin has been added to db
|
||||
self.assertEqual(Plugin.objects.filter(pk='testabc').count(), 1)
|
||||
|
||||
# This is not a persistent plugin
|
||||
self.assertFalse(get_plugin_by_name('testabc').is_persistent())
|
||||
|
||||
# Cannot upload the same plugin again (same name)
|
||||
res = c.post('/admin/app/plugin/actions/upload/', {'file': plugin_file}, follow=True)
|
||||
self.assertRedirects(res, '/admin/app/plugin/')
|
||||
messages = list(res.context['messages'])
|
||||
self.assertTrue('already exist' in str(messages[0]))
|
||||
plugin_file.seek(0)
|
||||
|
||||
# Can access paths (while being logged in)
|
||||
res = c.get('/plugins/testabc/hello/')
|
||||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
res = c.get('/api/plugins/testabc/hello/')
|
||||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
|
||||
# Can access public paths as logged-in, (per plugin directive)
|
||||
res = c.get('/plugins/testabc/file.txt')
|
||||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
|
||||
c.logout()
|
||||
|
||||
# Can still access the paths as anonymous
|
||||
res = c.get('/plugins/testabc/hello/')
|
||||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
res = c.get('/api/plugins/testabc/hello/')
|
||||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
|
||||
# But not the public paths as anonymous (per plugin directive)
|
||||
res = c.get('/plugins/testabc/file.txt')
|
||||
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Cannot delete plugin as normal user
|
||||
c.login(username='testuser', password='test1234')
|
||||
res = c.get('/admin/app/plugin/testabc/delete/', follow=True)
|
||||
self.assertRedirects(res, '/admin/login/?next=/admin/app/plugin/testabc/delete/')
|
||||
|
||||
# Can delete plugin as admin
|
||||
c.login(username='testsuperuser', password='test1234')
|
||||
res = c.get('/admin/app/plugin/testabc/delete/', follow=True)
|
||||
self.assertRedirects(res, '/admin/app/plugin/')
|
||||
messages = list(res.context['messages'])
|
||||
|
||||
# No errors
|
||||
self.assertEqual(len(messages), 0)
|
||||
|
||||
# Directories have been removed
|
||||
self.assertFalse(os.path.exists(get_plugins_persistent_path("testabc")))
|
||||
|
||||
# Cannot access the paths as anonymous
|
||||
res = c.get('/plugins/testabc/hello/')
|
||||
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
|
||||
res = c.get('/api/plugins/testabc/hello/')
|
||||
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
|
||||
res = c.get('/plugins/testabc/file.txt')
|
||||
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Try to add malformed plugins files
|
||||
res = c.post('/admin/app/plugin/actions/upload/', {'file': missing_manifest_plugin_file}, follow=True)
|
||||
self.assertRedirects(res, '/admin/app/plugin/')
|
||||
messages = list(res.context['messages'])
|
||||
self.assertTrue('Cannot load plugin' in str(messages[0]))
|
||||
self.assertFalse(os.path.exists(get_plugins_persistent_path("test123")))
|
||||
self.assertEqual(Plugin.objects.filter(pk='test123').count(), 0)
|
||||
missing_manifest_plugin_file.seek(0)
|
||||
|
||||
res = c.post('/admin/app/plugin/actions/upload/', {'file': bad_dir_plugin_file}, follow=True)
|
||||
self.assertRedirects(res, '/admin/app/plugin/')
|
||||
messages = list(res.context['messages'])
|
||||
self.assertTrue('Cannot load plugin' in str(messages[0]))
|
||||
missing_manifest_plugin_file.seek(0)
|
||||
|
||||
plugin_file.close()
|
||||
missing_manifest_plugin_file.close()
|
||||
bad_dir_plugin_file.close()
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from django.conf.urls import url, include
|
||||
|
||||
from .views import app as app_views, public as public_views
|
||||
from .plugins import get_app_url_patterns
|
||||
from .plugins.views import app_view_handler
|
||||
|
||||
from app.boot import boot
|
||||
from webodm import settings
|
||||
|
@ -35,11 +35,10 @@ urlpatterns = [
|
|||
url(r'^processingnode/([\d]+)/$', app_views.processing_node, name='processing_node'),
|
||||
|
||||
url(r'^api/', include("app.api.urls")),
|
||||
|
||||
url(r'^plugins/(?P<plugin_name>[^/.]+)/(.*)$', app_view_handler)
|
||||
]
|
||||
|
||||
handler404 = app_views.handler404
|
||||
handler500 = app_views.handler500
|
||||
|
||||
# TODO: is there a way to place plugins /public directories
|
||||
# into the static build directories and let nginx serve them?
|
||||
urlpatterns += get_app_url_patterns()
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "WebODM",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.6",
|
||||
"description": "User-friendly, extendable application and API for processing aerial imagery.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -22,10 +22,10 @@ class Plugin(PluginBase):
|
|||
return [Menu("Test", self.public_url("menu_url/"), "test-icon")]
|
||||
|
||||
def include_js_files(self):
|
||||
return ['test.js']
|
||||
return ['test.js']
|
||||
|
||||
def include_css_files(self):
|
||||
return ['test.css']
|
||||
return ['test.css']
|
||||
|
||||
def build_jsx_components(self):
|
||||
return ['component.jsx']
|
||||
|
|
Ładowanie…
Reference in New Issue