Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ pytest = "~=6.2"
pytest-cov = "~=3.0"

[packages]
setuptools = "*"
minio = "~=7.1"
mergin-client = "==0.9.3"
dynaconf = {extras = ["ini"],version = "~=3.1"}
google-api-python-client = "==2.24"
dropbox = "~=12.0"

[requires]
python_version = "3"
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Mergin Maps Media Sync
Sync media files from Mergin Maps projects to other storage backends. Currently, supported backend are MinIO (S3-like) backend, Google Drive and local drive (mostly used for testing).
Sync media files from Mergin Maps projects to other storage backends. Currently, supported backends are MinIO (S3-like), Dropbox, Google Drive and local drive (mostly used for testing).

Sync works in two modes, in COPY mode, where media files are only copied to external drive and MOVE mode, where files are
subsequently removed from Mergin Maps project (on cloud).
Expand Down Expand Up @@ -68,6 +68,58 @@ docker run -it \

The specification of `MINIO__BUCKET_SUBPATH` is optional and can be skipped if the files should be stored directly in `MINIO__BUCKET`.

#### Using Dropbox backend

You will need a Dropbox app with an OAuth2 refresh token. Follow these steps once to generate your credentials:

1. Go to [https://www.dropbox.com/developers/apps](https://www.dropbox.com/developers/apps) and create a new app.
- Choose **Scoped access** → **Full Dropbox** (or **App folder** if you prefer isolation).
- Under _Permissions_, enable **`files.content.write`** and **`sharing.write`**, then save.
2. On the app's _Settings_ tab, note your **App key** and **App secret**.
3. Generate a refresh token by running the following and following the prompts:
```shell
pip install dropbox
python3 - <<'EOF'
import dropbox
from dropbox import DropboxOAuth2FlowNoRedirect

APP_KEY = "<your_app_key>"
APP_SECRET = "<your_app_secret>"

auth_flow = DropboxOAuth2FlowNoRedirect(APP_KEY, APP_SECRET, token_access_type="offline")
print("Authorize this app:", auth_flow.start())
code = input("Enter auth code: ").strip()
result = auth_flow.finish(code)
print("Refresh token:", result.refresh_token)
EOF
```
4. Copy the printed **refresh token** — this is a long-lived credential that media-sync uses to authenticate.

```shell
docker run -it \
--name mergin-media-sync \
-e MERGIN__USERNAME=john \
-e MERGIN__PASSWORD=myStrongPassword \
-e MERGIN__PROJECT_NAME=john/my_project \
-e DRIVER=dropbox \
-e DROPBOX__APP_KEY=your_app_key \
-e DROPBOX__APP_SECRET=your_app_secret \
-e DROPBOX__REFRESH_TOKEN=your_refresh_token \
-e DROPBOX__FOLDER=mediasync \
lutraconsulting/mergin-media-sync python3 media_sync_daemon.py
```

Uploaded files are exposed as direct-download shared links (`?dl=1`) stored in the GeoPackage reference column. If a shared link already exists for a file it is reused automatically.

`DROPBOX__FOLDER` is optional. When set, all files are placed under that folder in your Dropbox (e.g. `DROPBOX__FOLDER=mediasync` stores files at `/mediasync/img1.png`).

| Environment variable | Required | Description |
|---|---|---|
| `DROPBOX__APP_KEY` | yes | Dropbox app key (from the developer console) |
| `DROPBOX__APP_SECRET` | yes | Dropbox app secret (from the developer console) |
| `DROPBOX__REFRESH_TOKEN` | yes | Long-lived OAuth2 refresh token (generated above) |
| `DROPBOX__FOLDER` | no | Root folder inside Dropbox for all uploaded files |

#### Using Google Drive backend
For setup instructions and more details, please refer to our [Google Drive guide](./docs/google-drive-setup.md).

Expand Down Expand Up @@ -136,6 +188,11 @@ To run automatic tests:
export TEST_MINIO_URL="localhost:9000"
export TEST_MINIO_ACCESS_KEY=EXAMPLE
export TEST_MINIO_SECRET_KEY=EXAMPLEKEY
# Dropbox backend tests (optional)
export TEST_DROPBOX_APP_KEY=<app_key>
export TEST_DROPBOX_APP_SECRET=<app_secret>
export TEST_DROPBOX_REFRESH_TOKEN=<refresh_token>
export TEST_DROPBOX_FOLDER=mediasync-test
pipenv run pytest test/
```

Expand Down
12 changes: 12 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def validate_config(config):
config.driver == DriverType.LOCAL
or config.driver == DriverType.MINIO
or config.driver == DriverType.GOOGLE_DRIVE
or config.driver == DriverType.DROPBOX
):
raise ConfigError("Config error: Unsupported driver")

Expand Down Expand Up @@ -78,6 +79,17 @@ def validate_config(config):
):
raise ConfigError("Config error: Incorrect GoogleDrive driver settings")

if config.driver == DriverType.DROPBOX and not (
hasattr(config, "dropbox")
and hasattr(config.dropbox, "app_key")
and hasattr(config.dropbox, "app_secret")
and hasattr(config.dropbox, "refresh_token")
and config.dropbox.app_key
and config.dropbox.app_secret
and config.dropbox.refresh_token
):
raise ConfigError("Config error: Incorrect Dropbox driver settings")


def update_config_path(
path_param: str,
Expand Down
8 changes: 7 additions & 1 deletion config.yaml.default
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,16 @@ minio:
bucket_subpath:

google_drive:
service_account_file:
service_account_file:
folder:
share_with:

dropbox:
app_key:
app_secret:
refresh_token:
folder: # optional root folder inside Dropbox (e.g. mediasync)

references:
- file: survey.gpkg
table: notes
Expand Down
71 changes: 71 additions & 0 deletions drivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@
from googleapiclient.discovery import build, Resource
from googleapiclient.http import MediaFileUpload

import dropbox
from dropbox.exceptions import ApiError, AuthError
from dropbox.files import WriteMode
from dropbox.sharing import SharedLinkAlreadyExistsMetadata


class DriverType(enum.Enum):
LOCAL = "local"
MINIO = "minio"
GOOGLE_DRIVE = "google_drive"
DROPBOX = "dropbox"

def __eq__(self, value):
if isinstance(value, str):
Expand Down Expand Up @@ -282,6 +288,69 @@ def _get_share_with(self, config_google_drive) -> typing.List[str]:
return emails_to_share_with


class DropboxDriver(Driver):
"""Driver to handle connection to Dropbox"""

def __init__(self, config):
super(DropboxDriver, self).__init__(config)

try:
self.client = dropbox.Dropbox(
app_key=config.dropbox.app_key,
app_secret=config.dropbox.app_secret,
oauth2_refresh_token=config.dropbox.refresh_token,
)
self.client.users_get_current_account()

self.folder = ""
if hasattr(config.dropbox, "folder") and config.dropbox.folder:
# Normalize to /folder (no trailing slash)
self.folder = "/" + config.dropbox.folder.strip("/")

except AuthError as e:
raise DriverError("Dropbox driver init error: " + str(e))
except Exception as e:
raise DriverError("Dropbox driver init error: " + str(e))

def upload_file(self, src: str, obj_path: str) -> str:
dest_path = f"{self.folder}/{obj_path}"
try:
with open(src, "rb") as data:
self.client.files_upload(
data.read(), dest_path, mode=WriteMode.overwrite
)
return self._get_shared_link(dest_path)
except ApiError as e:
raise DriverError("Dropbox driver error: " + str(e))

def _get_shared_link(self, path: str) -> str:
"""Return a direct-download shared link for the given Dropbox path."""
try:
result = self.client.sharing_create_shared_link_with_settings(path)
url = result.url
except ApiError as e:
if (
isinstance(e.error, dropbox.sharing.CreateSharedLinkWithSettingsError)
and e.error.is_shared_link_already_exists()
):
metadata = e.error.get_shared_link_already_exists()
if isinstance(metadata, SharedLinkAlreadyExistsMetadata):
url = metadata.metadata.url
else:
links = self.client.sharing_list_shared_links(
path=path, direct_only=True
).links
if not links:
raise DriverError(
f"Dropbox driver error: could not retrieve shared link for {path}"
)
url = links[0].url
else:
raise DriverError("Dropbox driver error: " + str(e))
# Replace ?dl=0 with ?dl=1 for a direct-download URL
return url.replace("?dl=0", "?dl=1")


def create_driver(config):
"""Create driver object based on type defined in config"""
driver = None
Expand All @@ -291,4 +360,6 @@ def create_driver(config):
driver = MinioDriver(config)
elif config.driver == DriverType.GOOGLE_DRIVE:
driver = GoogleDriveDriver(config)
elif config.driver == DriverType.DROPBOX:
driver = DropboxDriver(config)
return driver
8 changes: 8 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
GOOGLE_DRIVE_SERVICE_ACCOUNT_FILE = os.environ.get(
"TEST_GOOGLE_DRIVE_SERVICE_ACCOUNT_FILE"
)
DROPBOX_APP_KEY = os.environ.get("TEST_DROPBOX_APP_KEY")
DROPBOX_APP_SECRET = os.environ.get("TEST_DROPBOX_APP_SECRET")
DROPBOX_REFRESH_TOKEN = os.environ.get("TEST_DROPBOX_REFRESH_TOKEN")
DROPBOX_FOLDER = os.environ.get("TEST_DROPBOX_FOLDER", "mediasync-test")


@pytest.fixture(scope="function")
Expand Down Expand Up @@ -49,6 +53,10 @@ def setup_config():
"MINIO__BUCKET_SUBPATH": "",
"MINIO__SECURE": False,
"MINIO__REGION": "",
"DROPBOX__APP_KEY": "",
"DROPBOX__APP_SECRET": "",
"DROPBOX__REFRESH_TOKEN": "",
"DROPBOX__FOLDER": "",
}
)

Expand Down
95 changes: 94 additions & 1 deletion test/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import shutil
import sqlite3

from drivers import MinioDriver, LocalDriver, GoogleDriveDriver
from drivers import MinioDriver, LocalDriver, GoogleDriveDriver, DropboxDriver
from media_sync import (
main,
config,
Expand All @@ -33,6 +33,10 @@
MINIO_SECRET_KEY,
GOOGLE_DRIVE_SERVICE_ACCOUNT_FILE,
GOOGLE_DRIVE_FOLDER,
DROPBOX_APP_KEY,
DROPBOX_APP_SECRET,
DROPBOX_REFRESH_TOKEN,
DROPBOX_FOLDER,
cleanup,
prepare_mergin_project,
)
Expand Down Expand Up @@ -634,3 +638,92 @@ def test_google_drive_backend(mc):
# files in mergin project still exist (copy mode)
assert os.path.exists(os.path.join(work_project_dir, "img1.png"))
assert os.path.exists(os.path.join(work_project_dir, "images", "img2.jpg"))


def test_dropbox_backend(mc):
"""Test media sync connected to Dropbox backend (needs valid Dropbox credentials)"""
project_name = "mediasync_test_dropbox"
full_project_name = WORKSPACE + "/" + project_name
work_project_dir = os.path.join(TMP_DIR, project_name + "_work")

cleanup(mc, full_project_name, [work_project_dir])
prepare_mergin_project(mc, full_project_name)

# invalid config - missing refresh_token
config.update(
{
"MERGIN__USERNAME": API_USER,
"MERGIN__PASSWORD": USER_PWD,
"MERGIN__URL": SERVER_URL,
"MERGIN__PROJECT_NAME": full_project_name,
"PROJECT_WORKING_DIR": work_project_dir,
"OPERATION_MODE": "copy",
"REFERENCES": [
{
"file": None,
"table": None,
"local_path_column": None,
"driver_path_column": None,
}
],
"DRIVER": "dropbox",
"DROPBOX__APP_KEY": DROPBOX_APP_KEY,
"DROPBOX__APP_SECRET": DROPBOX_APP_SECRET,
"DROPBOX__REFRESH_TOKEN": "",
"DROPBOX__FOLDER": DROPBOX_FOLDER,
}
)

with pytest.raises(ConfigError):
validate_config(config)

# patch config to fit testing purposes
config.update(
{
"MERGIN__USERNAME": API_USER,
"MERGIN__PASSWORD": USER_PWD,
"MERGIN__URL": SERVER_URL,
"MERGIN__PROJECT_NAME": full_project_name,
"PROJECT_WORKING_DIR": work_project_dir,
"OPERATION_MODE": "copy",
"REFERENCES": [
{
"file": None,
"table": None,
"local_path_column": None,
"driver_path_column": None,
}
],
"DRIVER": "dropbox",
"DROPBOX__APP_KEY": DROPBOX_APP_KEY,
"DROPBOX__APP_SECRET": DROPBOX_APP_SECRET,
"DROPBOX__REFRESH_TOKEN": DROPBOX_REFRESH_TOKEN,
"DROPBOX__FOLDER": DROPBOX_FOLDER,
}
)

main()

# verify files were uploaded to Dropbox
driver = DropboxDriver(config)
folder_path = f"/{DROPBOX_FOLDER}"
dropbox_files = [
entry.name
for entry in driver.client.files_list_folder(
folder_path, recursive=True
).entries
]
assert "img1.png" in dropbox_files
assert "img2.jpg" in dropbox_files

# returned URL should be a direct-download Dropbox link
url = driver.upload_file(os.path.join(work_project_dir, "img1.png"), "img1.png")
assert url.startswith("https://www.dropbox.com/")
assert "dl=1" in url

# files in mergin project still exist (copy mode)
assert os.path.exists(os.path.join(work_project_dir, "img1.png"))
assert os.path.exists(os.path.join(work_project_dir, "images", "img2.jpg"))

# cleanup Dropbox folder after test
driver.client.files_delete_v2(folder_path)
Loading