├── .air.toml ├── .github └── workflows │ ├── docker-publish-dockerhub.yml │ ├── docker-publish.yml │ ├── docusaurus-deploy.yml │ └── go-release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.js ├── components ├── admin.templ ├── admin_audit.templ ├── admin_database.templ ├── admin_logs.templ ├── admin_role_form.templ ├── admin_roles.templ ├── auth_provider_form.templ ├── auth_providers.templ ├── auth_providers_buttons.templ ├── components.go ├── config_form.templ ├── configs.templ ├── dashboard.templ ├── dashboard_notifications.templ ├── dashboard_notifications_page.templ ├── error.templ ├── file_metadata.templ ├── file_metadata │ ├── details │ │ └── file_metadata_details.templ │ ├── dialog │ │ └── file_metadata_dialog.templ │ ├── list │ │ ├── file_metadata_list.templ │ │ └── file_metadata_list_partial.templ │ ├── search │ │ ├── file_metadata_search.templ │ │ └── file_metadata_search_content.templ │ ├── types.go │ └── utils │ │ ├── file_metadata_js.templ │ │ └── file_metadata_utils.go ├── forgot_password.templ ├── history.templ ├── home.templ ├── job_form.templ ├── job_run_details.templ ├── jobs.templ ├── layout.templ ├── login.templ ├── notifications │ ├── dialog │ │ ├── dialog.templ │ │ └── dialog_js.templ │ ├── form │ │ ├── fields │ │ │ ├── email.templ │ │ │ ├── gotify.templ │ │ │ ├── ntfy.templ │ │ │ ├── pushbullet.templ │ │ │ ├── pushover.templ │ │ │ └── webhook.templ │ │ ├── form.templ │ │ ├── form_js.templ │ │ └── utils │ │ │ └── utils.go │ ├── list │ │ ├── list.templ │ │ └── list_js.templ │ └── types │ │ └── types.go ├── profile.templ ├── providers │ ├── common │ │ └── common.templ │ ├── destination │ │ ├── b2.templ │ │ ├── destination.go │ │ ├── ftp.templ │ │ ├── gdrive.templ │ │ ├── gphotos.templ │ │ ├── hetzner.templ │ │ ├── local.templ │ │ ├── minio.templ │ │ ├── nextcloud.templ │ │ ├── s3.templ │ │ ├── sftp.templ │ │ ├── smb.templ │ │ ├── wasabi.templ │ │ └── webdav.templ │ ├── provider_form.templ │ ├── providers.go │ └── source │ │ ├── b2.templ │ │ ├── ftp.templ │ │ ├── gdrive.templ │ │ ├── gphotos.templ │ │ ├── hetzner.templ │ │ ├── local.templ │ │ ├── minio.templ │ │ ├── nextcloud.templ │ │ ├── s3.templ │ │ ├── sftp.templ │ │ ├── smb.templ │ │ ├── source.go │ │ ├── wasabi.templ │ │ └── webdav.templ ├── register.templ ├── settings.templ ├── shared │ └── toast │ │ ├── toast.templ │ │ └── toast_js.templ ├── test_result.templ ├── two_factor_backup_codes.templ ├── two_factor_backup_verify.templ ├── two_factor_setup.templ ├── two_factor_verify.templ ├── user_edit.templ ├── user_management.templ └── users.templ ├── docker-compose.yaml ├── docs ├── .gitignore ├── README.md ├── docs │ ├── advanced │ │ ├── admin-tools.md │ │ ├── gotify-notifications.md │ │ ├── notifications-overview.md │ │ ├── ntfy-notifications.md │ │ ├── pushbullet-notifications.md │ │ ├── pushover-notifications.md │ │ └── webhook-notifications.md │ ├── core-concepts │ │ ├── connections.md │ │ ├── monitoring.md │ │ ├── schedules.md │ │ └── transfers.md │ ├── development │ │ ├── code-review.md │ │ ├── contributing.md │ │ ├── project-structure.md │ │ └── release-process.md │ ├── getting-started │ │ ├── configuration.md │ │ ├── docker.md │ │ ├── installation.md │ │ ├── quick-start.md │ │ └── traditional.md │ ├── introduction │ │ ├── features.md │ │ └── overview.md │ └── security │ │ ├── authentication.md │ │ ├── best-practices.md │ │ └── non-root.md ├── docusaurus.config.ts ├── fix-image-paths.js ├── package-lock.json ├── package.json ├── sidebars.ts ├── src │ ├── components │ │ └── HomepageFeatures │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ ├── css │ │ └── custom.css │ ├── pages │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── markdown-page.md │ └── theme │ │ └── SearchBar.tsx ├── static │ ├── .nojekyll │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── css │ │ └── styles.dfe17619.css │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── images │ │ ├── dashboard.gomft-038de3445e0d9228c5621ea0b5577c42.png │ │ └── transfer.config.gomft-7583731408b086ca9c7bf3127aca9952.png │ ├── img │ │ ├── README.md │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── audit.logs.gomft.png │ │ ├── authentication.providers.gomft.png │ │ ├── create-transfer.png │ │ ├── dashboard.dark.gomft.png │ │ ├── dashboard.gomft.png │ │ ├── dashboard.png │ │ ├── database.tools.gomft.png │ │ ├── discord.svg │ │ ├── docker.png │ │ ├── docusaurus-social-card.jpg │ │ ├── docusaurus.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── file.details.gomft.png │ │ ├── file.metadata.gomft.png │ │ ├── gomft-social-card.jpg │ │ ├── job.run.details.gomft.png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── notification.service.gomft.png │ │ ├── notifications.gomft.png │ │ ├── role.management.gomft.png │ │ ├── scheduled.jobs.gomft.png │ │ ├── site.webmanifest │ │ ├── transfer.config.gomft.png │ │ ├── transfer.history.gomft.png │ │ ├── undraw_docusaurus_mountain.svg │ │ ├── undraw_docusaurus_react.svg │ │ ├── undraw_docusaurus_tree.svg │ │ └── user.management.gomft.png │ ├── screenshots │ │ └── README.md │ └── site.webmanifest └── tsconfig.json ├── entrypoint.sh ├── go.mod ├── go.sum ├── internal ├── api │ └── api.go ├── auth │ ├── jwt.go │ ├── password.go │ └── totp.go ├── config │ └── config.go ├── db │ ├── audit_log.go │ ├── auth_provider.go │ ├── auth_provider_store.go │ ├── db.go │ ├── file_metadata.go │ ├── file_metadata_store.go │ ├── job.go │ ├── job_store.go │ ├── migrations │ │ ├── 001_initial_schema.go │ │ ├── 002_update_gdrive_type.go │ │ ├── 003_add_2fa.go │ │ ├── 004_add_audit_logs.go │ │ ├── 005_add_default_roles.go │ │ ├── 006_add_timestamps_to_job_histories.go │ │ ├── 007_add_notification_services.go │ │ ├── 008_add_user_notifications.go │ │ ├── 009_add_rclone_tables.go │ │ ├── 010_add_rclone_command_to_config.go │ │ ├── 011_add_auth_providers.go │ │ ├── 012_alter_boolean_defaults.go │ │ ├── 012a_recover_transfer_configs_rename.go │ │ ├── 012b_recover_notification_services_rename.go │ │ ├── 012c_recover_auth_providers_rename.go │ │ ├── 013_cleanup_invalid_booleans.go │ │ └── migrations.go │ ├── notification.go │ ├── rclone.go │ ├── rclone_store.go │ ├── role.go │ ├── role_store.go │ ├── transfer_config.go │ ├── transfer_config_store.go │ ├── user.go │ ├── user_notification.go │ └── user_store.go ├── email │ ├── email.go │ ├── email_test.go │ └── mock_email.go ├── logging │ └── logger.go ├── rclone_service │ ├── rclone_service.go │ └── rclone_service_test.go ├── scheduler │ ├── job_executor.go │ ├── job_executor_test.go │ ├── logger.go │ ├── logger_test.go │ ├── metadata.go │ ├── metadata_test.go │ ├── mock_scheduler.go │ ├── notification.go │ ├── notification_test.go │ ├── scheduler.go │ ├── scheduler_interface.go │ ├── scheduler_test.go │ ├── transfer_executor.go │ ├── transfer_executor_test.go │ ├── utils.go │ └── utils_test.go ├── testutils │ └── testutils.go └── web │ ├── handlers.go │ └── handlers │ ├── admin_handlers.go │ ├── api_handlers.go │ ├── auth_handlers.go │ ├── auth_provider_handlers.go │ ├── basic_handlers.go │ ├── config_handlers.go │ ├── dashboard_handlers.go │ ├── database_handlers.go │ ├── error_handlers.go │ ├── file_metadata_handlers.go │ ├── gdrive_handlers.go │ ├── handler.go │ ├── job_handlers.go │ ├── notifications_handlers.go │ ├── oauth_handlers.go │ ├── path_handlers.go │ ├── profile_handlers.go │ ├── rclone_handlers.go │ ├── routes.go │ ├── settings_handlers.go │ ├── two_factor_handlers.go │ └── user_handlers.go ├── main.go ├── package-lock.json ├── package.json ├── screenshots ├── audit.logs.gomft.png ├── authentication.providers.gomft.png ├── dashboard.dark.gomft.png ├── dashboard.gomft.png ├── database.tools.gomft.png ├── file.details.gomft.png ├── file.metadata.gomft.png ├── job.run.details.gomft.png ├── notification.service.gomft.png ├── notifications.gomft.png ├── role.management.gomft.png ├── scheduled.jobs.gomft.png ├── transfer.config.gomft.png ├── transfer.history.gomft.png └── user.management.gomft.png ├── static ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── css │ └── app.css ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── img │ ├── authelia.svg │ ├── authentik.svg │ ├── keycloak.svg │ ├── logo.png │ ├── logo.svg │ ├── oauth2.svg │ ├── oidc.svg │ ├── pocket-id.svg │ └── saml.svg ├── js │ ├── app.js │ ├── init.js │ └── vendor.js └── site.webmanifest ├── tailwind.config.js └── testing.md /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "templ generate && go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go", "_templ.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "templ", "html"] 18 | kill_delay = "0s" 19 | log = "build-errors.log" 20 | send_interrupt = false 21 | stop_on_error = true 22 | 23 | [color] 24 | app = "" 25 | build = "yellow" 26 | main = "magenta" 27 | runner = "green" 28 | watcher = "cyan" 29 | 30 | [log] 31 | time = false 32 | 33 | [misc] 34 | clean_on_exit = false 35 | 36 | [screen] 37 | clear_on_rebuild = false 38 | keep_scroll = true -------------------------------------------------------------------------------- /.github/workflows/docker-publish-dockerhub.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker Image DockHub 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | workflow_dispatch: 8 | inputs: 9 | manual_version: 10 | description: 'Manual version override (leave empty to use git tag)' 11 | required: false 12 | default: '' 13 | 14 | env: 15 | # Use github.repository as the default image name 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | jobs: 19 | build-and-push: 20 | runs-on: ubuntu-latest 21 | # Set the permissions needed for the DockerHub token to push to GHCR 22 | permissions: 23 | contents: read 24 | packages: write 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 # Needed to get all tags for versioning 31 | 32 | # Set up Node.js for frontend build 33 | - name: Set up Node.js 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: '20' 37 | cache: 'npm' 38 | 39 | # Install dependencies 40 | - name: Install dependencies 41 | run: npm ci 42 | 43 | # Build frontend assets 44 | - name: Build frontend assets 45 | run: | 46 | node build.js 47 | ls -la static/dist/ 48 | 49 | # Set version information 50 | - name: Set Version 51 | id: version 52 | run: | 53 | if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then 54 | echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV 55 | elif [[ "${{ github.ref }}" == refs/tags/* ]]; then 56 | VERSION=${GITHUB_REF#refs/tags/} 57 | echo "VERSION=$VERSION" >> $GITHUB_ENV 58 | else 59 | VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD) 60 | echo "VERSION=$VERSION" >> $GITHUB_ENV 61 | fi 62 | echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV 63 | echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV 64 | 65 | # Set up Docker Buildx for efficient builds 66 | - name: Set up Docker Buildx 67 | uses: docker/setup-buildx-action@v3 68 | 69 | # Login to DockerHub Container Registry 70 | - name: Log in to DockerHub Container Registry 71 | uses: docker/login-action@v3 72 | with: 73 | username: ${{ secrets.DOCKERHUB_USERNAME }} 74 | password: ${{ secrets.DOCKERHUB_TOKEN }} 75 | 76 | 77 | # Extract metadata for Docker image 78 | - name: Extract Docker metadata 79 | id: meta 80 | uses: docker/metadata-action@v5 81 | with: 82 | images: starfleetcptn/gomft 83 | tags: | 84 | type=semver,pattern={{version}} 85 | type=semver,pattern={{major}}.{{minor}} 86 | type=ref,event=branch 87 | type=ref,event=pr 88 | type=sha,format=long 89 | type=raw,value=latest,enable={{is_default_branch}} 90 | 91 | # Build and push Docker image 92 | - name: Build and push 93 | uses: docker/build-push-action@v5 94 | with: 95 | context: . 96 | push: ${{ github.event_name != 'pull_request' }} 97 | tags: ${{ steps.meta.outputs.tags }} 98 | labels: ${{ steps.meta.outputs.labels }} 99 | platforms: linux/amd64,linux/arm64,linux/arm/v7 100 | build-args: | 101 | VERSION=${{ env.VERSION }} 102 | BUILD_TIME=${{ env.BUILD_TIME }} 103 | COMMIT=${{ env.COMMIT }} 104 | UID=1000 105 | GID=1000 106 | cache-from: type=gha 107 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | workflow_dispatch: 8 | inputs: 9 | manual_version: 10 | description: 'Manual version override (leave empty to use git tag)' 11 | required: false 12 | default: '' 13 | 14 | env: 15 | # Use github.repository as the default image name 16 | IMAGE_NAME: ${{ github.repository }} 17 | REGISTRY: ghcr.io 18 | 19 | jobs: 20 | build-and-push: 21 | runs-on: ubuntu-latest 22 | # Set the permissions needed for the GitHub token to push to GHCR 23 | permissions: 24 | contents: read 25 | packages: write 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 0 # Needed to get all tags for versioning 32 | 33 | # Set up Node.js for frontend build 34 | - name: Set up Node.js 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: '20' 38 | cache: 'npm' 39 | 40 | # Install dependencies 41 | - name: Install dependencies 42 | run: npm ci 43 | 44 | # Build frontend assets 45 | - name: Build frontend assets 46 | run: | 47 | node build.js 48 | ls -la static/dist/ 49 | 50 | # Set version information 51 | - name: Set Version 52 | id: version 53 | run: | 54 | if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then 55 | echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV 56 | elif [[ "${{ github.ref }}" == refs/tags/* ]]; then 57 | VERSION=${GITHUB_REF#refs/tags/} 58 | echo "VERSION=$VERSION" >> $GITHUB_ENV 59 | else 60 | VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD) 61 | echo "VERSION=$VERSION" >> $GITHUB_ENV 62 | fi 63 | echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV 64 | echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV 65 | 66 | # Set up Docker Buildx for efficient builds 67 | - name: Set up Docker Buildx 68 | uses: docker/setup-buildx-action@v3 69 | 70 | # Login to GitHub Container Registry 71 | - name: Log in to GitHub Container Registry 72 | uses: docker/login-action@v3 73 | with: 74 | registry: ${{ env.REGISTRY }} 75 | username: ${{ github.actor }} 76 | password: ${{ secrets.GITHUB_TOKEN }} 77 | 78 | # Extract metadata for Docker image 79 | - name: Extract Docker metadata 80 | id: meta 81 | uses: docker/metadata-action@v5 82 | with: 83 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 84 | tags: | 85 | type=semver,pattern={{version}} 86 | type=semver,pattern={{major}}.{{minor}} 87 | type=ref,event=branch 88 | type=ref,event=pr 89 | type=sha,format=long 90 | type=raw,value=latest,enable={{is_default_branch}} 91 | 92 | # Build and push Docker image 93 | - name: Build and push 94 | uses: docker/build-push-action@v5 95 | with: 96 | context: . 97 | push: ${{ github.event_name != 'pull_request' }} 98 | tags: ${{ steps.meta.outputs.tags }} 99 | labels: ${{ steps.meta.outputs.labels }} 100 | platforms: linux/amd64,linux/arm64,linux/arm/v7 101 | build-args: | 102 | VERSION=${{ env.VERSION }} 103 | BUILD_TIME=${{ env.BUILD_TIME }} 104 | COMMIT=${{ env.COMMIT }} 105 | UID=1000 106 | GID=1000 107 | cache-from: type=gha 108 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /.github/workflows/docusaurus-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docusaurus to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: ['docs/**'] 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | # Allow only one concurrent deployment 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | build: 23 | name: Deploy Docusaurus 24 | runs-on: ubuntu-latest 25 | defaults: 26 | run: 27 | working-directory: ./docs 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup Node.js 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: 18 36 | cache: npm 37 | cache-dependency-path: ./docs/package-lock.json 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | 42 | - name: Copy screenshots to static directory 43 | run: | 44 | npm run prepare-screenshots 45 | # Ensure the images referenced in the docs are definitely available 46 | mkdir -p static/img 47 | cp -v ../screenshots/dashboard.gomft.png static/img/ 48 | cp -v ../screenshots/transfer.config.gomft.png static/img/ 49 | ls -la static/img/dashboard.gomft.png static/img/transfer.config.gomft.png 50 | 51 | - name: Fix Markdown image paths 52 | run: npm run fix-image-paths 53 | 54 | - name: Verify screenshots exist 55 | run: | 56 | echo "Checking if screenshots were copied correctly..." 57 | if [ -f "static/img/dashboard.gomft.png" ]; then 58 | echo "✅ Found dashboard.gomft.png in static/img/" 59 | else 60 | echo "❌ Missing dashboard.gomft.png in static/img/" 61 | exit 1 62 | fi 63 | if [ -f "static/img/transfer.config.gomft.png" ]; then 64 | echo "✅ Found transfer.config.gomft.png in static/img/" 65 | else 66 | echo "❌ Missing transfer.config.gomft.png in static/img/" 67 | exit 1 68 | fi 69 | 70 | - name: Build 71 | run: npm run build 72 | 73 | - name: Setup Pages 74 | uses: actions/configure-pages@v4 75 | 76 | - name: Upload artifact 77 | uses: actions/upload-pages-artifact@v3 78 | with: 79 | path: ./docs/build 80 | 81 | - name: Deploy to GitHub Pages 82 | id: deployment 83 | uses: actions/deploy-pages@v4 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build output 8 | *.test 9 | *.out 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.cov 13 | 14 | # Temporary files 15 | *.tmp 16 | *.temp 17 | 18 | # Build directories 19 | _build/ 20 | build/ 21 | 22 | # Vendor directory 23 | /vendor/ 24 | 25 | # Go workspace file 26 | go.work 27 | go.work.sum 28 | 29 | # IDE/editor specific files 30 | .idea/ 31 | .vscode/ 32 | *.swp 33 | *~ 34 | 35 | # Logs 36 | *.log 37 | 38 | # Dependency directories 39 | node_modules/ 40 | 41 | # OS generated files 42 | .DS_Store 43 | Thumbs.db 44 | 45 | # Ignore all Go files in the components directory 46 | *_templ.go 47 | 48 | # Ignore the data directory 49 | data/ 50 | 51 | # Ignore .env files 52 | .env 53 | *.env 54 | 55 | # Ignore the tmp directory 56 | tmp/ 57 | 58 | # Ignore the configs directory 59 | configs/ 60 | 61 | # Ignore the backups directory 62 | backups/ 63 | 64 | # Ignore Dirs 65 | /source/ 66 | /destination/ 67 | /archive/ 68 | 69 | # Ignore the dist directory 70 | static/dist/ 71 | 72 | # Ignore binaries 73 | gomft 74 | 75 | # Docusaurus files 76 | docs/.docusaurus/ 77 | docs/.cache-loader/ 78 | docs/build/ 79 | docs/build-searchindex/ 80 | docs/static/search/ 81 | docs/static/js/ 82 | docs/node_modules/ 83 | docs/.env* 84 | docs/*.log -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build frontend assets 2 | FROM node:20-alpine AS frontend-builder 3 | 4 | WORKDIR /app 5 | 6 | # Copy all files needed for the build first 7 | COPY package.json package-lock.json ./ 8 | COPY build.js ./ 9 | COPY static/ ./static/ 10 | COPY tailwind.config.js ./ 11 | 12 | # Debug: Show the contents of build.js 13 | RUN echo "Contents of build.js:" && cat build.js 14 | 15 | # Remove the build script from postinstall 16 | RUN sed -i 's/"postinstall": "npm run build",//' package.json 17 | 18 | # Install dependencies and build with verbose output 19 | RUN npm ci && \ 20 | echo "Building frontend assets..." && \ 21 | node build.js && \ 22 | echo "Build complete. Contents of dist:" && \ 23 | ls -la static/dist/ && \ 24 | echo "Sample of app.js:" && \ 25 | head -n 10 static/dist/app.js && \ 26 | echo "Sample of app.css:" && \ 27 | head -n 10 static/dist/app.css 28 | 29 | # Go build stage 30 | FROM golang:1.24-alpine AS builder 31 | 32 | WORKDIR /app 33 | 34 | # Accept build arguments for version information 35 | ARG VERSION=dev 36 | ARG BUILD_TIME=unknown 37 | ARG COMMIT=unknown 38 | # Architecture-related build arguments 39 | ARG TARGETOS=linux 40 | ARG TARGETARCH=amd64 41 | ARG TARGETVARIANT="" 42 | 43 | # Install build dependencies 44 | RUN apk add --no-cache git build-base 45 | 46 | # Install templ compiler 47 | RUN go install github.com/a-h/templ/cmd/templ@latest 48 | 49 | # Copy go module files first for better layer caching 50 | COPY go.mod go.sum ./ 51 | RUN go mod download 52 | 53 | # Create static directory structure 54 | RUN mkdir -p /app/static/dist 55 | 56 | # Copy built frontend assets from frontend-builder BEFORE copying Go source 57 | COPY --from=frontend-builder /app/static/dist/ /app/static/dist/ 58 | 59 | # Copy the rest of the static files 60 | COPY static/ /app/static/ 61 | 62 | # Verify static files are in place before Go build 63 | RUN echo "Verifying static files before Go build:" && \ 64 | ls -la /app/static/dist/ 65 | 66 | # Now copy the rest of the source code 67 | COPY . . 68 | 69 | # Generate template files from .templ files 70 | RUN templ generate 71 | 72 | # Compile the application with version information 73 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ 74 | -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \ 75 | -o /app/gomft 76 | 77 | # Install rclone with appropriate architecture 78 | RUN apk add --no-cache curl unzip && \ 79 | if [ "$TARGETARCH" = "arm64" ]; then \ 80 | RCLONE_ARCH="arm64"; \ 81 | elif [ "$TARGETARCH" = "arm" ]; then \ 82 | RCLONE_ARCH="arm-v7"; \ 83 | else \ 84 | RCLONE_ARCH="amd64"; \ 85 | fi && \ 86 | curl -O https://downloads.rclone.org/rclone-current-linux-${RCLONE_ARCH}.zip && \ 87 | unzip rclone-current-linux-${RCLONE_ARCH}.zip && \ 88 | cd rclone-*-linux-${RCLONE_ARCH} && \ 89 | cp rclone /usr/local/bin/ && \ 90 | chmod 755 /usr/local/bin/rclone && \ 91 | cd .. && \ 92 | rm -rf rclone* 93 | 94 | # Create a smaller runtime image 95 | FROM alpine:3.19 96 | 97 | # Add arguments for UID and GID with defaults 98 | ARG UID=1000 99 | ARG GID=1000 100 | ARG USERNAME=gomft 101 | ARG TARGETOS 102 | ARG TARGETARCH 103 | 104 | WORKDIR /app 105 | 106 | # Install runtime dependencies 107 | RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \ 108 | && apk add --no-cache --virtual .user-deps \ 109 | shadow curl xz 110 | 111 | # Create user and group with specified IDs 112 | RUN addgroup -g ${GID} ${USERNAME} && \ 113 | adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME} 114 | 115 | # Copy the binary from the builder stage 116 | COPY --from=builder /app/gomft /app/ 117 | COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone 118 | 119 | # Copy components 120 | COPY components/ /app/components/ 121 | 122 | # Copy entrypoint script 123 | COPY entrypoint.sh /entrypoint.sh 124 | RUN chmod +x /entrypoint.sh 125 | 126 | # Create data and backup directories 127 | RUN mkdir -p /app/data /app/backups 128 | 129 | # Create a placeholder .env file with proper permissions 130 | RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env 131 | 132 | # Set executable permissions 133 | RUN chmod +x /app/gomft 134 | 135 | # Set ownership of application files 136 | RUN chown -R ${USERNAME}:${USERNAME} /app 137 | 138 | # Expose the application port 139 | EXPOSE 8080 140 | 141 | # Use our entrypoint script 142 | ENTRYPOINT ["/entrypoint.sh"] 143 | 144 | # Run the application 145 | CMD ["/app/gomft"] 146 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ben Busby 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import { execSync } from 'child_process'; 5 | import fs from 'fs'; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | const isWatch = process.argv.includes('--watch'); 11 | 12 | // Ensure the dist directory exists 13 | const distDir = path.join(__dirname, 'static', 'dist'); 14 | if (!fs.existsSync(distDir)) { 15 | fs.mkdirSync(distDir, { recursive: true }); 16 | } 17 | 18 | // Copy Font Awesome files 19 | const fontAwesomeSrcDir = path.join(__dirname, 'node_modules', '@fortawesome', 'fontawesome-free'); 20 | const fontAwesomeDestDir = path.join(distDir, 'fontawesome'); 21 | 22 | // Copy CSS files 23 | const cssFiles = [ 24 | 'css/all.min.css', 25 | 'css/fontawesome.min.css', 26 | 'css/solid.min.css', 27 | 'css/regular.min.css', 28 | 'css/brands.min.css' 29 | ]; 30 | 31 | cssFiles.forEach(file => { 32 | const srcFile = path.join(fontAwesomeSrcDir, file); 33 | const destFile = path.join(fontAwesomeDestDir, file); 34 | const destDir = path.dirname(destFile); 35 | 36 | if (!fs.existsSync(destDir)) { 37 | fs.mkdirSync(destDir, { recursive: true }); 38 | } 39 | 40 | if (fs.existsSync(srcFile)) { 41 | fs.copyFileSync(srcFile, destFile); 42 | } 43 | }); 44 | 45 | // Copy webfonts 46 | const webfontsSrcDir = path.join(fontAwesomeSrcDir, 'webfonts'); 47 | const webfontsDestDir = path.join(fontAwesomeDestDir, 'webfonts'); 48 | 49 | if (!fs.existsSync(webfontsDestDir)) { 50 | fs.mkdirSync(webfontsDestDir, { recursive: true }); 51 | } 52 | 53 | fs.readdirSync(webfontsSrcDir).forEach(file => { 54 | fs.copyFileSync( 55 | path.join(webfontsSrcDir, file), 56 | path.join(webfontsDestDir, file) 57 | ); 58 | }); 59 | 60 | const commonConfig = { 61 | sourcemap: true, 62 | minify: true, 63 | bundle: true, 64 | platform: 'browser', 65 | target: ['es2020'], 66 | }; 67 | 68 | async function buildTailwind() { 69 | console.log('Building Tailwind CSS...'); 70 | execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --minify'); 71 | } 72 | 73 | async function build() { 74 | try { 75 | // Build vendor JavaScript bundle (CDN dependencies) 76 | await esbuild.build({ 77 | ...commonConfig, 78 | entryPoints: ['static/js/vendor.js'], 79 | outfile: 'static/dist/vendor.js', 80 | format: 'iife', 81 | }); 82 | 83 | // Build application JavaScript 84 | await esbuild.build({ 85 | ...commonConfig, 86 | entryPoints: ['static/js/app.js'], 87 | outfile: 'static/dist/app.js', 88 | format: 'iife', 89 | }); 90 | 91 | // Build initialization JavaScript 92 | await esbuild.build({ 93 | ...commonConfig, 94 | entryPoints: ['static/js/init.js'], 95 | outfile: 'static/dist/init.js', 96 | format: 'iife', 97 | }); 98 | 99 | // Build CSS with Tailwind 100 | await buildTailwind(); 101 | 102 | console.log('Build completed successfully!'); 103 | } catch (error) { 104 | console.error('Build failed:', error); 105 | process.exit(1); 106 | } 107 | } 108 | 109 | if (isWatch) { 110 | // Watch mode 111 | console.log('Starting watch mode...'); 112 | const ctx = await esbuild.context(commonConfig); 113 | await ctx.watch(); 114 | 115 | // Watch Tailwind CSS 116 | execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --watch'); 117 | 118 | console.log('Watching for changes...'); 119 | } else { 120 | // Single build 121 | build(); 122 | } -------------------------------------------------------------------------------- /components/auth_providers_buttons.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "fmt" 5 | "github.com/starfleetcptn/gomft/internal/db" 6 | ) 7 | 8 | // getProviderIcon returns the appropriate icon for a provider 9 | func getProviderIcon(provider db.AuthProvider) templ.Component { 10 | // If provider has a custom icon URL, use it 11 | if provider.IconURL != "" { 12 | return templ.Raw(fmt.Sprintf(`%s icon`, provider.IconURL, provider.Name)) 13 | } 14 | 15 | // Otherwise fall back to default icons based on type 16 | switch provider.Type { 17 | case db.ProviderTypeAuthentik: 18 | return templ.Raw(`Authentik`) 19 | case db.ProviderTypeOIDC: 20 | return templ.Raw(`OIDC`) 21 | case db.ProviderTypeSAML: 22 | return templ.Raw(`SAML`) 23 | case db.ProviderTypeOAuth2: 24 | return templ.Raw(`OAuth2`) 25 | default: 26 | return templ.Raw(``) 27 | } 28 | } 29 | 30 | templ AuthProviderButtons(providers []db.AuthProvider) { 31 | if len(providers) == 0 { 32 |
33 | No external authentication providers available 34 |
35 | } else { 36 |
37 | for _, provider := range providers { 38 | if provider.GetEnabled() { 39 | 43 | 44 | @getProviderIcon(provider) 45 | 46 | { provider.Name } 47 | 48 | } 49 | } 50 |
51 | } 52 | } -------------------------------------------------------------------------------- /components/components.go: -------------------------------------------------------------------------------- 1 | // Package components contains the UI components for the GoMFT application. 2 | // This file serves as a marker for the components package to ensure it's properly recognized by Go. 3 | package components -------------------------------------------------------------------------------- /components/file_metadata/dialog/file_metadata_dialog.templ: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "fmt" 5 | "github.com/starfleetcptn/gomft/components/file_metadata/utils" 6 | ) 7 | 8 | // FileMetadataDialog renders a confirmation dialog for file metadata actions 9 | templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) { 10 | @utils.FileMetadataJS() 11 | 57 | } 58 | -------------------------------------------------------------------------------- /components/file_metadata/search/file_metadata_search_content.templ: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "github.com/starfleetcptn/gomft/components/file_metadata" 6 | "github.com/starfleetcptn/gomft/components/file_metadata/list" 7 | ) 8 | 9 | // FileMetadataSearchContent renders only the search results table and pagination 10 | templ FileMetadataSearchContent(ctx context.Context, data file_metadata.FileMetadataSearchData) { 11 | 12 |
13 | if len(data.Files) > 0 { 14 | @list.FileMetadataListPartial(ctx, file_metadata.FileMetadataListData{ 15 | Files: data.Files, 16 | Page: data.Page, 17 | Limit: data.Limit, 18 | TotalCount: data.TotalCount, 19 | TotalPages: data.TotalPages, 20 | Filter: data.Filter, // Pass filter data for pagination links 21 | SortBy: data.SortBy, 22 | SortDir: data.SortDir, 23 | }, "/files/search/partial", "#search-results-container") // Pass correct base path and target ID 24 | } else { 25 |
26 | 27 | 28 | 29 |

No files found matching your search criteria.

30 |
31 | } 32 |
33 | } -------------------------------------------------------------------------------- /components/file_metadata/types.go: -------------------------------------------------------------------------------- 1 | package file_metadata 2 | 3 | import "github.com/starfleetcptn/gomft/internal/db" 4 | 5 | // FileMetadataFilter represents filter parameters for file metadata queries 6 | type FileMetadataFilter struct { 7 | Status string 8 | JobID string 9 | FileName string 10 | Hash string 11 | StartDate string 12 | EndDate string 13 | } 14 | 15 | // FileMetadataListData contains data for the file metadata list template 16 | type FileMetadataListData struct { 17 | Files []db.FileMetadata 18 | TotalCount int64 19 | Page int 20 | Limit int 21 | TotalPages int 22 | Job *db.Job // Optional: if viewing files for a specific job 23 | Filter FileMetadataFilter 24 | SortBy string // Added for sorting 25 | SortDir string // Added for sorting ("asc" or "desc") 26 | } 27 | 28 | // FileMetadataDetailsData contains data for the file metadata details template 29 | type FileMetadataDetailsData struct { 30 | File db.FileMetadata 31 | } 32 | 33 | // FileMetadataSearchData contains data for the file metadata search template 34 | type FileMetadataSearchData struct { 35 | Files []db.FileMetadata 36 | TotalCount int64 37 | Page int 38 | Limit int 39 | TotalPages int 40 | Filter FileMetadataFilter 41 | SortBy string // Added for sorting 42 | SortDir string // Added for sorting ("asc" or "desc") 43 | } 44 | -------------------------------------------------------------------------------- /components/file_metadata/utils/file_metadata_utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "fmt" 4 | 5 | // GetStatusBadgeClass returns the appropriate CSS class for a file status badge 6 | func GetStatusBadgeClass(status string) string { 7 | switch status { 8 | case "processed": 9 | return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300" 10 | case "archived": 11 | return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300" 12 | case "deleted": 13 | return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300" 14 | case "archived_and_deleted": 15 | return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300" 16 | case "error": 17 | return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300" 18 | default: 19 | return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" 20 | } 21 | } 22 | 23 | // FormatFileSize formats a file size in bytes to a human-readable string 24 | func FormatFileSize(size int64) string { 25 | if size < 1024 { 26 | return fmt.Sprintf("%d B", size) 27 | } else if size < 1024*1024 { 28 | return fmt.Sprintf("%.2f KB", float64(size)/1024) 29 | } else if size < 1024*1024*1024 { 30 | return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024)) 31 | } else { 32 | return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /components/notifications/dialog/dialog.templ: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "fmt" 5 | // "strconv" // No longer needed here 6 | ) 7 | 8 | // NotificationDialog component for confirmation dialogs using Flowbite modal 9 | templ NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) { 10 | 41 | } 42 | 43 | // Scripts (triggerServiceDelete, closeModal, showModal) are now expected to be defined globally or in the calling template (e.g., list.templ). -------------------------------------------------------------------------------- /components/notifications/dialog/dialog_js.templ: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | // DialogScripts provides the JavaScript function specific to the notification delete confirmation dialog. 4 | templ DialogScripts() { 5 | 20 | } -------------------------------------------------------------------------------- /components/notifications/form/fields/email.templ: -------------------------------------------------------------------------------- 1 | package fields 2 | 3 | import ( 4 | "github.com/starfleetcptn/gomft/components/notifications/types" 5 | // No utils needed for this specific template yet 6 | ) 7 | 8 | templ EmailFields(data types.NotificationFormData) { 9 | 10 | 32 | } -------------------------------------------------------------------------------- /components/notifications/form/form_js.templ: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | // FormScripts contains JavaScript specific to the notification form page. 4 | templ FormScripts() { 5 | 45 | } -------------------------------------------------------------------------------- /components/notifications/form/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // GetNotificationFormTitle returns the title for the notification form page. 8 | func GetNotificationFormTitle(isNew bool) string { 9 | if isNew { 10 | return "Add Notification Service" 11 | } 12 | return "Edit Notification Service" 13 | } 14 | 15 | // Contains checks if a string slice contains a specific string. 16 | func Contains(slice []string, item string) bool { 17 | for _, a := range slice { 18 | if a == item { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | 25 | // BoolToString converts a boolean to its string representation "true" or "false". 26 | // Useful for setting HTML attributes that expect string values. 27 | func BoolToString(b bool) string { 28 | if b { 29 | return "true" 30 | } 31 | return "false" 32 | } 33 | 34 | // IsEventTriggerSelected checks if a specific event trigger should be pre-selected. 35 | // It now accepts the anonymous struct type defined in types.NotificationFormData. 36 | // Defaults to checking 'job_complete' and 'job_error' when creating a new service. 37 | func IsEventTriggerSelected(service *struct { 38 | ID uint 39 | Name string 40 | Description string 41 | Type string 42 | IsEnabled bool 43 | EventTriggers []string 44 | RetryPolicy string 45 | WebhookURL string 46 | Method string 47 | Headers string 48 | PayloadTemplate string 49 | SecretKey string 50 | PushbulletAPIKey string 51 | PushbulletDeviceID string 52 | PushbulletTitleTemplate string 53 | PushbulletBodyTemplate string 54 | NtfyServer string 55 | NtfyTopic string 56 | NtfyPriority string 57 | NtfyUsername string 58 | NtfyPassword string 59 | NtfyTitleTemplate string 60 | NtfyMessageTemplate string 61 | GotifyURL string 62 | GotifyToken string 63 | GotifyPriority string 64 | GotifyTitleTemplate string 65 | GotifyMessageTemplate string 66 | PushoverAPIToken string 67 | PushoverUserKey string 68 | PushoverDevice string 69 | PushoverPriority string 70 | PushoverSound string 71 | PushoverTitleTemplate string 72 | PushoverMessageTemplate string 73 | }, event string, isNew bool) bool { 74 | if !isNew && service != nil { 75 | // Use the Contains helper function 76 | return Contains(service.EventTriggers, event) 77 | } 78 | // Default for new services: check complete and error 79 | return isNew && (event == "job_complete" || event == "job_error") 80 | } 81 | 82 | // FormatEventTriggerName converts event trigger keys to human-readable names. 83 | func FormatEventTriggerName(event string) string { 84 | // Replace underscores with spaces and capitalize words 85 | name := strings.ReplaceAll(event, "_", " ") 86 | name = strings.Title(name) // Use strings.Title for capitalization 87 | return name 88 | } 89 | -------------------------------------------------------------------------------- /components/notifications/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // SettingsNotificationsData defines the data needed for the notifications list page 4 | type SettingsNotificationsData struct { 5 | NotificationServices []NotificationServiceData 6 | SuccessMessage string 7 | ErrorMessage string 8 | } 9 | 10 | // NotificationServiceData defines the data for a single service in the list 11 | type NotificationServiceData struct { 12 | ID uint 13 | Name string 14 | Type string 15 | IsEnabled bool 16 | Config map[string]string // Keep for now, might refine later 17 | Description string 18 | EventTriggers []string 19 | PayloadTemplate string 20 | SecretKey string 21 | RetryPolicy string 22 | SuccessCount int 23 | FailureCount int 24 | } 25 | 26 | // NotificationFormData defines the data needed for the notification add/edit form 27 | // TODO: This will be moved from notification_form.templ later 28 | type NotificationFormData struct { 29 | NotificationService *struct { 30 | ID uint 31 | Name string 32 | Description string 33 | Type string 34 | IsEnabled bool 35 | EventTriggers []string 36 | RetryPolicy string 37 | WebhookURL string 38 | Method string 39 | Headers string 40 | PayloadTemplate string 41 | SecretKey string 42 | PushbulletAPIKey string 43 | PushbulletDeviceID string 44 | PushbulletTitleTemplate string 45 | PushbulletBodyTemplate string 46 | NtfyServer string 47 | NtfyTopic string 48 | NtfyPriority string 49 | NtfyUsername string 50 | NtfyPassword string 51 | NtfyTitleTemplate string 52 | NtfyMessageTemplate string 53 | GotifyURL string 54 | GotifyToken string 55 | GotifyPriority string 56 | GotifyTitleTemplate string 57 | GotifyMessageTemplate string 58 | PushoverAPIToken string 59 | PushoverUserKey string 60 | PushoverDevice string 61 | PushoverPriority string 62 | PushoverSound string 63 | PushoverTitleTemplate string 64 | PushoverMessageTemplate string 65 | } 66 | IsNew bool 67 | SuccessMessage string 68 | ErrorMessage string 69 | } 70 | -------------------------------------------------------------------------------- /components/providers/destination/destination.go: -------------------------------------------------------------------------------- 1 | // Package destination contains the UI destination forms for the GoMFT application. 2 | // This file serves as a marker for the destination package to ensure it's properly recognized by Go. 3 | package destination 4 | -------------------------------------------------------------------------------- /components/providers/destination/local.templ: -------------------------------------------------------------------------------- 1 | package destination 2 | 3 | templ LocalDestinationForm() { 4 |
5 |
6 | 7 |
8 |
9 | 10 |
11 | 15 |
16 |

Full path to the local directory containing your files

17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 |
25 | 26 | 27 |
28 |
29 |
30 |
31 | } 32 | -------------------------------------------------------------------------------- /components/providers/destination/nextcloud.templ: -------------------------------------------------------------------------------- 1 | package destination 2 | 3 | templ NextCloudDestinationForm() { 4 |
5 | 11 | 12 |
13 | 14 |
15 |
16 | 17 |
18 | 21 |
22 |

23 | Full URL to your NextCloud server including protocol (https://) 24 |

25 |
26 | 27 |
28 | 29 |
30 |
31 | 32 |
33 | 36 |
37 |

38 | Your NextCloud username 39 |

40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 |
48 | 51 |
52 |

53 | Your NextCloud account password 54 |

55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 |
63 | 66 |
67 |

68 | Path to your files in NextCloud 69 |

70 |
71 | 72 | 81 |
82 | } -------------------------------------------------------------------------------- /components/providers/destination/webdav.templ: -------------------------------------------------------------------------------- 1 | package destination 2 | 3 | templ WebDAVDestinationForm() { 4 |
5 | 11 | 12 |
13 | 14 |
15 |
16 | 17 |
18 | 21 |
22 |

23 | Full URL to your WebDAV server including protocol (https://) 24 |

25 |
26 | 27 |
28 | 29 |
30 |
31 | 32 |
33 | 36 |
37 |

38 | Your WebDAV username 39 |

40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 |
48 | 51 |
52 |

53 | Your WebDAV account password 54 |

55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 |
63 | 66 |
67 |

68 | Path to your files on the WebDAV server 69 |

70 |
71 | 72 | 81 |
82 | } -------------------------------------------------------------------------------- /components/providers/providers.go: -------------------------------------------------------------------------------- 1 | // Package providers contains the UI providers for the GoMFT application. 2 | // This file serves as a marker for the providers package to ensure it's properly recognized by Go. 3 | package providers 4 | -------------------------------------------------------------------------------- /components/providers/source/local.templ: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | templ LocalSourceForm() { 4 |
5 |
6 | 7 |
8 |
9 | 10 |
11 | 15 |
16 |

Full path to the local directory containing your files

17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 |
25 | 26 | 27 |
28 |
29 |
30 |
31 | } 32 | -------------------------------------------------------------------------------- /components/providers/source/nextcloud.templ: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | templ NextCloudSourceForm() { 4 |
5 | 11 | 12 |
13 | 14 |
15 |
16 | 17 |
18 | 21 |
22 |

23 | Full URL to your NextCloud server including protocol (https://) 24 |

25 |
26 | 27 |
28 | 29 |
30 |
31 | 32 |
33 | 36 |
37 |

38 | Your NextCloud username 39 |

40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 |
48 | 51 |
52 |

53 | Your NextCloud account password 54 |

55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 |
63 | 66 |
67 |

68 | Path to your files in NextCloud 69 |

70 |
71 | 72 | 81 |
82 | } -------------------------------------------------------------------------------- /components/providers/source/source.go: -------------------------------------------------------------------------------- 1 | // Package source contains the UI source forms for the GoMFT application. 2 | // This file serves as a marker for the source package to ensure it's properly recognized by Go. 3 | package source 4 | -------------------------------------------------------------------------------- /components/providers/source/webdav.templ: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | templ WebDAVSourceForm() { 4 |
5 | 11 | 12 |
13 | 14 |
15 |
16 | 17 |
18 | 21 |
22 |

23 | Full URL to your WebDAV server including protocol (https://) 24 |

25 |
26 | 27 |
28 | 29 |
30 |
31 | 32 |
33 | 36 |
37 |

38 | Your WebDAV username 39 |

40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 |
48 | 51 |
52 |

53 | Your WebDAV account password 54 |

55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 |
63 | 66 |
67 |

68 | Path to your files on the WebDAV server 69 |

70 |
71 | 72 | 81 |
82 | } -------------------------------------------------------------------------------- /components/shared/toast/toast.templ: -------------------------------------------------------------------------------- 1 | package toast 2 | 3 | templ Container() { 4 |
5 | } -------------------------------------------------------------------------------- /components/shared/toast/toast_js.templ: -------------------------------------------------------------------------------- 1 | package toast 2 | 3 | templ ShowToastJS() { 4 | 93 | } -------------------------------------------------------------------------------- /components/test_result.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ TestResult(success bool, message string) { 4 | if success { 5 |
6 | 7 | { message } 8 |
9 | } else { 10 |
11 | 12 | { message } 13 |
14 | } 15 | } -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | gomft: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | args: 7 | # Set the UID/GID to match your host user for better file permissions 8 | # Default is 1000:1000 if not specified 9 | UID: ${UID:-1000} 10 | GID: ${GID:-1000} 11 | # Version information 12 | VERSION: ${VERSION:-dev} 13 | BUILD_TIME: ${BUILD_TIME:-unknown} 14 | COMMIT: ${COMMIT:-unknown} 15 | container_name: gomft 16 | restart: unless-stopped 17 | ports: 18 | - "8080:8080" 19 | volumes: 20 | # Main data directory - contains DB and configs 21 | - gomft-data:/app/data 22 | # Separate backups directory 23 | - gomft-backups:/app/backups 24 | # For development, you can mount the source code 25 | # - .:/app 26 | environment: 27 | - TZ=UTC 28 | - DATA_DIR=/app/data 29 | - BACKUP_DIR=/app/backups 30 | - LOGS_DIR=/app/data/logs 31 | # - LOG_LEVEL=info 32 | networks: 33 | - gomft-network 34 | # For non-root installs, the container needs to run with the same UID 35 | # as the host user to access mounted volumes properly 36 | user: ${UID:-1000}:${GID:-1000} 37 | 38 | networks: 39 | gomft-network: 40 | driver: bridge 41 | 42 | volumes: 43 | gomft-data: 44 | driver: local 45 | gomft-backups: 46 | driver: local -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Keep the screenshots directory README 23 | !/static/screenshots/README.md 24 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # GoMFT Documentation 2 | 3 | This repository contains the documentation for [GoMFT](https://github.com/StarFleetCPTN/GoMFT), a modern, web-based managed file transfer solution written in Go. 4 | 5 | [![Discord](https://img.shields.io/discord/1351354052654403675?color=7289da&logo=discord&logoColor=white&label=Discord)](https://discord.gg/f9dwtM3j) 6 | 7 | ## Getting Started 8 | 9 | ### Installation 10 | 11 | ```bash 12 | # Install dependencies 13 | npm install 14 | 15 | # Start the development server 16 | npm run start 17 | ``` 18 | 19 | ### Build 20 | 21 | ```bash 22 | # Build the static site 23 | npm run build 24 | ``` 25 | 26 | The built files will be in the `build` directory. 27 | 28 | ## Documentation Structure 29 | 30 | - **Introduction**: Overview and features of GoMFT 31 | - **Getting Started**: Installation guides and quick start 32 | - **Core Concepts**: Detailed information about transfers, connections, and schedules 33 | - **Advanced Features**: Webhooks, email notifications, and admin tools 34 | - **Security**: Security best practices and configuration 35 | - **Development**: Project structure and contributing guidelines 36 | 37 | ## Contributing 38 | 39 | Contributions to the documentation are welcome! Please submit a PR with your changes. 40 | 41 | ## Community 42 | 43 | Join our [Discord community](https://discord.gg/f9dwtM3j) for support, discussions, and updates about GoMFT. 44 | 45 | ## License 46 | 47 | This documentation is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 48 | -------------------------------------------------------------------------------- /docs/docs/advanced/gotify-notifications.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | title: Gotify Notifications 4 | --- 5 | 6 | # Gotify Notifications 7 | 8 | Gotify is a simple server for sending and receiving push notifications. GoMFT integrates with Gotify to provide real-time notifications for transfer events and system alerts. 9 | 10 | ## Overview 11 | 12 | Gotify integration allows GoMFT to: 13 | 14 | - Send push notifications to your self-hosted Gotify server 15 | - Customize notification priority based on event importance 16 | - Include detailed transfer information in notifications 17 | - Support private and secure notification delivery 18 | 19 | ## Prerequisites 20 | 21 | Before setting up Gotify notifications in GoMFT, you need: 22 | 23 | 1. A running Gotify server (self-hosted) 24 | 2. An application token from your Gotify server 25 | 3. Network connectivity between GoMFT and the Gotify server 26 | 27 | ## Configuration 28 | 29 | ### Global Gotify Settings 30 | 31 | To configure Gotify notifications: 32 | 33 | 1. Navigate to **Settings** > **Notification Services** > **Add New** > **Gotify** 34 | 2. Configure the following settings: 35 | - **Gotify Server URL**: The URL of your Gotify server (e.g., `https://gotify.example.com`) 36 | - **Application Token**: The token for your GoMFT application in Gotify 37 | - **Default Priority**: The default priority level for notifications (1-10) 38 | - **Verify SSL**: Whether to verify SSL certificates (recommended for production) 39 | 40 | ### Testing Gotify Connection 41 | 42 | After configuring your Gotify settings: 43 | 44 | 1. Click **Test Connection** to verify connectivity with your Gotify server 45 | 2. Click **Send Test Notification** to send a test message 46 | 47 | ## Notification Content 48 | 49 | ### Priority Levels 50 | 51 | Gotify uses numeric priority levels that GoMFT leverages for different event types: 52 | 53 | | Priority | Usage in GoMFT | 54 | |----------|----------------| 55 | | 1-3 | Low priority: successful transfers, routine events | 56 | | 4-7 | Medium priority: warnings, transfers with issues | 57 | | 8-10 | High priority: failed transfers, critical system issues | 58 | 59 | ### Example Notifications 60 | 61 | GoMFT sends structured notifications with helpful information: 62 | 63 | #### Successful Transfer 64 | 65 | ``` 66 | Title: Transfer Completed: Daily Backup 67 | Message: Successfully transferred 123 files (1.45 GB) in 2:15 68 | Priority: 3 69 | ``` 70 | 71 | #### Failed Transfer 72 | 73 | ``` 74 | Title: Transfer Failed: Daily Backup 75 | Message: Error: Connection refused to destination server 76 | Files processed: 45/123 77 | Size transferred: 0.5/1.45 GB 78 | Priority: 8 79 | ``` 80 | 81 | ## Troubleshooting 82 | 83 | ### Common Issues 84 | 85 | - **Connection Refused**: Ensure the Gotify server URL is correct and accessible 86 | - **Authentication Failed**: Verify the Application Token is correct 87 | - **SSL Certificate Errors**: Check the Verify SSL setting and certificate validity 88 | 89 | ### Gotify Logs 90 | 91 | To troubleshoot notification issues: 92 | 93 | 1. Check the GoMFT logs: **Admin Tools** > **Logs** > filter for "gotify" 94 | 2. Review the Gotify server logs for any errors 95 | 3. Verify network connectivity between GoMFT and the Gotify server 96 | 97 | ## Best Practices 98 | 99 | - **Use HTTPS** for your Gotify server to ensure secure communication 100 | - **Set Appropriate Priorities** to differentiate between routine and critical notifications 101 | - **Use Client Applications** on your devices to receive Gotify notifications 102 | - **Set Up Multiple Notification Methods** for critical events -------------------------------------------------------------------------------- /docs/docs/advanced/pushbullet-notifications.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | title: Pushbullet Notifications 4 | --- 5 | 6 | # Pushbullet Notifications 7 | 8 | Pushbullet is a cross-platform notification service that allows you to receive notifications on multiple devices. GoMFT integrates with Pushbullet to deliver timely notifications about your file transfers and system events. 9 | 10 | ## Overview 11 | 12 | Pushbullet integration in GoMFT offers: 13 | 14 | - Cross-platform notifications across your devices (Android, iOS, Chrome, Firefox, etc.) 15 | - Option to send to all devices or specific devices 16 | - Rich notification content with transfer details 17 | - Support for notification mirroring between devices 18 | 19 | ## Prerequisites 20 | 21 | Before configuring Pushbullet notifications in GoMFT, you need: 22 | 23 | 1. A Pushbullet account 24 | 2. Pushbullet API access token 25 | 3. Pushbullet app installed on your devices 26 | 27 | ## Configuration 28 | 29 | ### Global Pushbullet Settings 30 | 31 | To configure Pushbullet notifications: 32 | 33 | 1. Navigate to **Settings** > **Notification Services** > **Add New** > **Pushbullet** 34 | 2. Configure the following settings: 35 | - **API Token**: Your Pushbullet access token 36 | - **Default Device**: The device identifier to send notifications to (optional) 37 | - **Default Type**: "Note" (default) or "Link" 38 | 39 | ### Getting Your Pushbullet API Token 40 | 41 | 1. Log in to your Pushbullet account at [pushbullet.com](https://www.pushbullet.com/) 42 | 2. Go to **Settings** > **Account** 43 | 3. In the **Access Tokens** section, click **Create Access Token** 44 | 4. Copy the generated token and paste it into GoMFT 45 | 46 | ### Testing Pushbullet Connection 47 | 48 | After configuring your Pushbullet settings: 49 | 50 | 1. Click **Send Test Notification** to send a test notification to your devices 51 | 52 | ## Notification Content 53 | 54 | ### Notification Types 55 | 56 | GoMFT supports two types of Pushbullet notifications: 57 | 58 | #### Note Type 59 | 60 | Simple notifications with a title and body: 61 | 62 | ``` 63 | Title: Transfer Complete: Daily Backup 64 | Body: Successfully transferred 123 files (1.45 GB) in 2:15 65 | ``` 66 | 67 | #### Link Type 68 | 69 | Notifications that include a link to the GoMFT interface: 70 | 71 | ``` 72 | Title: Transfer Failed: Daily Backup 73 | Body: Error: Connection refused to destination server 74 | URL: https://gomft.example.com/transfers/123 75 | ``` 76 | 77 | ### Example Notifications 78 | 79 | #### Successful Transfer 80 | 81 | ``` 82 | Title: Transfer Complete: Daily Backup 83 | Body: Transfer completed successfully at 2023-09-15 14:22:33 84 | Files: 123 85 | Size: 1.45 GB 86 | Duration: 00:02:15 87 | ``` 88 | 89 | #### Failed Transfer 90 | 91 | ``` 92 | Title: Transfer Failed: Daily Backup 93 | Body: Transfer failed with error: Connection refused 94 | Files Processed: 45/123 95 | Size Transferred: 0.5/1.45 GB 96 | Duration: 00:01:05 97 | Error: Failed to connect to destination server 98 | URL: https://gomft.example.com/transfers/123 99 | ``` 100 | 101 | ## Troubleshooting 102 | 103 | ### Common Issues 104 | 105 | - **API Token Errors**: Verify your Pushbullet API token is correct 106 | - **No Notifications Arriving**: Check device connectivity and Pushbullet app settings 107 | - **Rate Limiting**: Pushbullet has API rate limits; spread out notification frequency 108 | - **Device Selection Issues**: Verify device identifiers if targeting specific devices 109 | 110 | ### Pushbullet Logs 111 | 112 | To troubleshoot notification issues: 113 | 114 | 1. Check the GoMFT logs: **Administration** > **Log Viewer** > filter for "pushbullet" 115 | 2. Review the Pushbullet account activity in your Pushbullet account 116 | 117 | ## Best Practices 118 | 119 | - **Secure Your API Token**: Treat your Pushbullet API token as sensitive information 120 | - **Group Related Notifications** to avoid notification fatigue 121 | - **Include Action Links** for quick access to relevant GoMFT pages 122 | - **Set Up Multiple Notification Methods** for critical systems 123 | 124 | ## Pushbullet Alternatives 125 | 126 | If you encounter limitations with Pushbullet, GoMFT also supports: 127 | 128 | - [Email Notifications](./email-notifications) 129 | - [Gotify](./gotify-notifications) 130 | - [Ntfy](./ntfy-notifications) 131 | - [Pushover](./pushover-notifications) -------------------------------------------------------------------------------- /docs/docs/core-concepts/monitoring.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | title: Monitoring 4 | --- 5 | 6 | # Monitoring and Reporting 7 | 8 | GoMFT provides comprehensive monitoring and reporting features to help you track transfer activities, analyze performance, and ensure reliable operation. This page explains the monitoring tools available in GoMFT. 9 | 10 | ## Dashboard 11 | 12 | The GoMFT dashboard provides a real-time overview of your file transfer system: 13 | 14 | ### Dashboard Components 15 | 16 | - **Transfer Status**: Overview of currently running, completed, and failed transfers 17 | - **Recent Transfers**: List of the most recent transfer executions 18 | - **System Health**: Indicators for system health and resource usage 19 | - **Quick Actions**: Buttons for common tasks like creating transfers or checking logs 20 | 21 | To access the dashboard: 22 | 1. Log in to GoMFT 23 | 2. The dashboard is the default landing page 24 | 3. You can return to it anytime by clicking **Dashboard** in the sidebar 25 | 26 | ## Transfer History 27 | 28 | The transfer history section provides detailed information about all transfer executions: 29 | 30 | ### Transfer History Features 31 | 32 | - **Comprehensive Logs**: Complete transfer history with filtering options 33 | - **Status Tracking**: Visual indicators for transfer status (successful, failed, in progress) 34 | - **Performance Metrics**: Data on transfer speed, file counts, and total bytes 35 | - **Time Tracking**: Start time, end time, and duration for all transfers 36 | - **Filter and Search**: Find specific transfers by name, status, date, or other criteria 37 | 38 | To access transfer history: 39 | 1. Navigate to **Transfer History** in the sidebar 40 | 2. Use filters to narrow down the list of transfers 41 | 3. Click on any transfer to see detailed information 42 | 43 | ## Real-Time Monitoring 44 | 45 | GoMFT provides real-time monitoring of active transfers: 46 | 47 | ### Active Transfers 48 | 49 | - **Live Progress**: See transfer progress as it happens 50 | - **File Counters**: Track files transferred, remaining, and skipped 51 | - **Bandwidth Usage**: Monitor current transfer speeds 52 | - **Cancel Option**: Ability to cancel running transfers 53 | - **Log Streaming**: View logs as they're generated 54 | 55 | To monitor active transfers: 56 | 1. Navigate to **Transfer History** in the sidebar 57 | 2. View all currently running transfers 58 | 3. Click on any transfer to see detailed progress 59 | 60 | ## Detailed Transfer Logs 61 | 62 | For each transfer execution, GoMFT maintains detailed logs: 63 | 64 | ### Log Information 65 | 66 | - **File Details**: Information about each transferred file 67 | - **Error Messages**: Detailed error information for failed transfers 68 | - **Warning Messages**: Warnings that occurred during transfer 69 | - **Transfer Summary**: Overall summary of the transfer operation 70 | - **Performance Data**: Transfer rates and timing information 71 | 72 | To access detailed logs: 73 | 1. Navigate to **Transfer History** in the sidebar 74 | 2. Find the transfer of interest 75 | 3. Click on **View Details** to open details 76 | 77 | ## System Monitoring 78 | 79 | GoMFT monitors the health and performance of the system itself: 80 | 81 | ## Alerts and Notifications 82 | 83 | GoMFT can alert you to important events: 84 | 85 | ### Alert Types 86 | 87 | - **Transfer Failures**: Notifications when transfers fail 88 | - **Transfer Completion**: Alerts when transfers complete 89 | 90 | To configure alerts: 91 | 1. Navigate to **Notification Providers** 92 | 2. Set up notification methods (email, webhook, gotify, ntfy, pushover, pushbullet) 93 | 4. Configure alert severity levels 94 | 95 | ## Export and API Access 96 | 97 | GoMFT allows you to export monitoring data: 98 | 99 | ### Export Options 100 | 101 | - **CSV Export**: Download transfer history in CSV format 102 | - **JSON Export**: Export data in JSON format for further processing 103 | 104 | ## Best Practices 105 | 106 | - **Review the dashboard daily** to stay informed of transfer status 107 | - **Set up alerts** for critical transfers to be notified of failures 108 | - **Generate regular reports** for compliance and performance tracking 109 | - **Monitor system health** to prevent resource issues 110 | - **Archive logs** for long-term storage and compliance 111 | - **Use filters** to focus on the most important information 112 | - **Export data** for backup and external analysis -------------------------------------------------------------------------------- /docs/docs/getting-started/quick-start.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | title: Quick Start 4 | --- 5 | 6 | # GoMFT Quick Start Guide 7 | 8 | This guide will help you get up and running with GoMFT quickly. We'll cover logging in, creating your first connection configuration, and setting up a file transfer. 9 | 10 | ## Accessing the Web Interface 11 | 12 | After installation, access the GoMFT web interface at `http://your-server:8080` (or the appropriate port if you've modified it). 13 | 14 | 1. Log in with the default credentials: 15 | - **Username**: admin@example.com 16 | - **Password**: admin 17 | 18 | ## Initial Dashboard 19 | 20 | The dashboard provides an overview of: 21 | - Recent transfer jobs 22 | - Upcoming scheduled transfers 23 | - System status 24 | - Quick action buttons 25 | 26 | ![GoMFT Dashboard](../../static/img/dashboard.gomft.png) 27 | 28 | ## Creating Your First Transfer Configuration 29 | 30 | 1. Navigate to **Transfer Configurations** in the sidebar menu 31 | 2. Click **+ New Configuration** 32 | 3. Configure the transfer: 33 | - Select source and destination configurations 34 | - Specify source and destination paths 35 | - Choose the transfer type (Copy, Sync, Move, etc.) 36 | - Configure transfer options (file filtering, bandwidth limits, etc.) 37 | 4. Click **Save Transfer** 38 | 39 | ![Create Transfer](../../static/img/transfer.config.gomft.png) 40 | 41 | ## Create Your First Scheduled Job 42 | 1. Naviagate to **Scheduled Jobs** in the sidebar menu 43 | 2. Click **+ New Job** 44 | 3. Configure the job: 45 | - Specifiy schedule 46 | - Select Job(s) to run this can be 1 or more 47 | - Change job run order if needed 48 | 49 | ## Running a Transfer 50 | 51 | Once you've created a transfer configuration, you can: 52 | 53 | ### Run On-Demand 54 | 55 | 1. Navigate to **Secheduled Jobs** 56 | 2. Find your transfer in the list 57 | 3. Click the **Run Now** button 58 | 4. The transfer will execute immediately 59 | 60 | ### Schedule a Transfer 61 | 62 | 1. Navigate to **Schedules** 63 | 2. Click **Create New Schedule** 64 | 3. Select your transfer configuration 65 | 4. Set the schedule using cron syntax or the schedule builder 66 | 5. Set additional options (timeout, max retries, etc.) 67 | 6. Click **Save Schedule** 68 | 69 | ## Monitoring Transfers 70 | 71 | 1. Navigate to **Transfer History** to view all past and ongoing transfers 72 | 2. Click on a specific transfer to view detailed information: 73 | - Transfer status 74 | - Start and end times 75 | - Files transferred 76 | - Bytes transferred 77 | - Errors (if any) 78 | - Transfer log 79 | 80 | ## Next Steps 81 | 82 | Now that you've set up your first transfer, explore these additional features: 83 | 84 | - [Docker Deployment](/docs/getting-started/docker) - For containerized deployment 85 | - [Traditional Installation](/docs/getting-started/traditional) - For non-Docker environments 86 | - [Transfer Concepts](/docs/core-concepts/transfers) - Learn more about transfer operations 87 | - [Scheduling](/docs/core-concepts/schedules) - Advanced scheduling options 88 | - [Monitoring](/docs/core-concepts/monitoring) - Advanced monitoring capabilities -------------------------------------------------------------------------------- /docs/docs/introduction/features.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | title: Features 4 | --- 5 | 6 | # GoMFT Features 7 | 8 | GoMFT offers a comprehensive set of features that make it a powerful solution for managed file transfers. Here's a detailed breakdown of what GoMFT offers: 9 | 10 | ## Core Features 11 | 12 | ### Multi-Protocol Support 13 | 14 | GoMFT leverages rclone to support a wide range of storage providers and protocols: 15 | 16 | - **Cloud Storage**: Amazon S3, Google Cloud Storage 17 | - **Object Storage**: MinIO, Backblaze B2, Wasabi 18 | - **FTP/SFTP**: FTP, FTPS, SFTP servers 19 | - **WebDAV**: WebDAV servers and services 20 | - **Local Storage**: Local disk, SMB/CIFS shares 21 | - **And many more**: Over 40 storage systems supported 22 | 23 | ### Intuitive Web Interface 24 | 25 | - **Clean, Modern UI**: Easy-to-use web interface built with Tailwind CSS and HTMX 26 | - **Dashboard**: Overview of recent transfers, scheduled jobs, and system status 27 | - **Configuration Manager**: Visual interface for creating and editing transfer configurations 28 | - **Job Scheduler**: Interface for creating and managing scheduled jobs 29 | - **Transfer Logs**: Detailed logs of all transfer operations 30 | - **Dark Mode**: Support for light and dark themes 31 | 32 | ### Powerful Scheduling 33 | 34 | - **Cron-style Scheduling**: Set up transfers using familiar cron syntax 35 | - **Recurring Transfers**: Schedule transfers to run on a regular basis 36 | - **One-time Transfers**: Run transfers immediately or at a specific time 37 | - **Schedule Grouping**: Organize schedules into logical groups 38 | - **Priority Control**: Set priority levels for scheduled tasks 39 | 40 | ## Advanced Features 41 | 42 | ### Transfer Options 43 | 44 | - **Bidirectional Sync**: Synchronize files in both directions 45 | - **File Filtering**: Include or exclude files based on patterns 46 | - **Bandwidth Limiting**: Restrict bandwidth usage for transfers 47 | - **Parallel Transfers**: Configure the number of simultaneous transfers 48 | - **Delta Transfers**: Transfer only changed parts of files 49 | - **Checksumming**: Verify file integrity during transfers 50 | 51 | ### Notification System 52 | 53 | - **Notifications**: Receive alerts when transfers complete or fail 54 | - **Custom Templates**: Customize notification content and format 55 | - **Notification Rules**: Configure which events trigger notifications 56 | - **Notification Providers**: Webhooks, Ntfy, Gotify, Pushover, Pushbullet 57 | 58 | ### Admin Tools 59 | 60 | - **User Management**: Create and manage users with different roles 61 | - **Role-Based Access Control**: Control access to different parts of the application 62 | - **Audit Logging**: Track user actions for security and compliance 63 | - **System Monitoring**: Monitor system performance and resource usage 64 | - **Database Backup/Restore**: Back up and restore the application database 65 | - **Log Viewer**: Browse and search through application logs 66 | 67 | ### Security Features 68 | 69 | - **Authentication**: Secure login with optional MFA support 70 | - **Encryption**: Encrypt data in transit and at rest 71 | - **Secure Credential Storage**: Safely store connection credentials 72 | - **Non-Root Container Support**: Run containers as non-root users for enhanced security 73 | 74 | ## Integration Capabilities 75 | 76 | - **Docker Support**: Easy deployment with Docker containers 77 | - **Docker Compose**: Multi-container deployment using Docker Compose 78 | - **Reverse Proxy Compatible**: Works behind reverse proxies like Nginx or Traefik -------------------------------------------------------------------------------- /docs/docs/introduction/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: Overview 4 | --- 5 | 6 | # GoMFT Overview 7 | 8 | GoMFT is a modern, web-based managed file transfer solution written in Go. It provides an intuitive interface for setting up, scheduling, and monitoring file transfers across various storage backends. 9 | 10 | ## What is GoMFT? 11 | 12 | GoMFT (Go Managed File Transfer) is an open-source file transfer platform that enables reliable, secure, and automated file transfers. Built on top of the powerful [rclone](https://rclone.org/) engine, GoMFT provides a user-friendly web interface that makes it easy to configure and manage complex file transfer operations. 13 | 14 | ## Key Benefits 15 | 16 | - **User-Friendly Interface**: Intuitive web UI for configuring and monitoring file transfers 17 | - **Multi-Protocol Support**: Transfer files using SFTP, S3, Google Drive, and many more protocols 18 | - **Automated Scheduling**: Set up recurring transfers with flexible scheduling options 19 | - **Comprehensive Logging**: Detailed logs for troubleshooting and audit purposes 20 | - **Notifications**: Get alerts when transfers succeed or fail 21 | - **Docker Support**: Easy deployment with Docker containers 22 | - **Security**: Role-based access control and secure credential management 23 | 24 | ## Use Cases 25 | 26 | - **Data Synchronization**: Keep files in sync across different storage systems 27 | - **Backup and Archiving**: Automate backup processes to cloud or local storage 28 | - **Secure File Exchange**: Transfer files securely between organizations 29 | - **Cloud Migration**: Move data between different cloud providers 30 | - **Workflow Automation**: Trigger file transfers as part of larger workflows 31 | - **Compliance**: Maintain audit logs for regulatory compliance 32 | 33 | GoMFT is designed to be simple to deploy and use, while providing the reliability and features needed for enterprise file transfer needs. 34 | 35 | ## Community Support 36 | 37 | Join our Discord community for support, discussions, and updates about GoMFT: 38 | 39 | Join Discord Community -------------------------------------------------------------------------------- /docs/fix-image-paths.js: -------------------------------------------------------------------------------- 1 | // Fix image paths in Markdown files 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | // Function to recursively find all Markdown files 6 | function findMarkdownFiles(directory) { 7 | const files = []; 8 | 9 | function traverse(dir) { 10 | const entries = fs.readdirSync(dir, { withFileTypes: true }); 11 | 12 | for (const entry of entries) { 13 | const fullPath = path.join(dir, entry.name); 14 | 15 | if (entry.isDirectory()) { 16 | traverse(fullPath); 17 | } else if (entry.isFile() && entry.name.endsWith('.md')) { 18 | files.push(fullPath); 19 | } 20 | } 21 | } 22 | 23 | traverse(directory); 24 | return files; 25 | } 26 | 27 | // Function to fix image paths in a file 28 | function fixImagePaths(filePath) { 29 | let content = fs.readFileSync(filePath, 'utf8'); 30 | let originalContent = content; 31 | 32 | // Replace absolute paths with relative ones based on file location 33 | const relativeToRoot = path.relative(path.dirname(filePath), path.resolve('static')); 34 | const relativePath = relativeToRoot.replace(/\\/g, '/'); 35 | 36 | // Replace any occurrence of ![...](/img/...) with ![...](relativePath/img/...) 37 | content = content.replace(/!\[(.*?)\]\(\/img\/(.*?)\)/g, 38 | (match, alt, imgPath) => `![${alt}](${relativePath}/img/${imgPath})`); 39 | 40 | // If content changed, write back to file 41 | if (content !== originalContent) { 42 | console.log(`Fixed image paths in ${filePath}`); 43 | fs.writeFileSync(filePath, content, 'utf8'); 44 | return true; 45 | } 46 | 47 | return false; 48 | } 49 | 50 | // Main function 51 | function main() { 52 | console.log('Finding Markdown files...'); 53 | const docsDir = path.resolve('docs'); 54 | const mdFiles = findMarkdownFiles(docsDir); 55 | 56 | console.log(`Found ${mdFiles.length} Markdown files`); 57 | 58 | let fixedCount = 0; 59 | for (const file of mdFiles) { 60 | if (fixImagePaths(file)) { 61 | fixedCount++; 62 | } 63 | } 64 | 65 | console.log(`Fixed image paths in ${fixedCount} files`); 66 | } 67 | 68 | main(); -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "go-mft-docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc", 16 | "deploy-gh-pages": "GIT_USER=StarFleetCPTN USE_SSH=true yarn deploy", 17 | "prepare-screenshots": "node -e \"const fs = require('fs'); const paths = ['./static/screenshots', './static/img/screenshots', './static/img']; paths.forEach(path => { if (!fs.existsSync(path)) { fs.mkdirSync(path, { recursive: true }); } }); fs.readdirSync('../screenshots').forEach(file => { paths.forEach(path => { const targetFile = path + '/' + file; if (!fs.existsSync(targetFile)) { fs.copyFileSync('../screenshots/' + file, targetFile); } }); });\"", 18 | "fix-image-paths": "node fix-image-paths.js" 19 | }, 20 | "dependencies": { 21 | "@docusaurus/core": "3.7.0", 22 | "@docusaurus/preset-classic": "3.7.0", 23 | "@easyops-cn/docusaurus-search-local": "^0.49.2", 24 | "@mdx-js/react": "^3.0.0", 25 | "clsx": "^2.0.0", 26 | "prism-react-renderer": "^2.3.0", 27 | "react": "^19.0.0", 28 | "react-dom": "^19.0.0" 29 | }, 30 | "devDependencies": { 31 | "@docusaurus/module-type-aliases": "3.7.0", 32 | "@docusaurus/tsconfig": "3.7.0", 33 | "@docusaurus/types": "3.7.0", 34 | "typescript": "~5.6.2" 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.5%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 3 chrome version", 44 | "last 3 firefox version", 45 | "last 5 safari version" 46 | ] 47 | }, 48 | "engines": { 49 | "node": ">=18.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; 2 | 3 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) 4 | 5 | /** 6 | * Creating a sidebar enables you to: 7 | - create an ordered group of docs 8 | - render a sidebar for each doc of that group 9 | - provide next/previous navigation 10 | 11 | The sidebars can be generated from the filesystem, or explicitly defined here. 12 | 13 | Create as many sidebars as you want. 14 | */ 15 | const sidebars: SidebarsConfig = { 16 | docsSidebar: [ 17 | { 18 | type: 'category', 19 | label: 'Introduction', 20 | items: ['introduction/overview', 'introduction/features'], 21 | }, 22 | { 23 | type: 'category', 24 | label: 'Getting Started', 25 | items: ['getting-started/installation', 'getting-started/configuration', 'getting-started/quick-start', 'getting-started/docker', 'getting-started/traditional'], 26 | }, 27 | { 28 | type: 'category', 29 | label: 'Core Concepts', 30 | items: ['core-concepts/transfers', 'core-concepts/connections', 'core-concepts/schedules', 'core-concepts/monitoring'], 31 | }, 32 | { 33 | type: 'category', 34 | label: 'Advanced Features', 35 | items: [ 36 | 'advanced/notifications-overview', 37 | 'advanced/gotify-notifications', 38 | 'advanced/ntfy-notifications', 39 | 'advanced/pushbullet-notifications', 40 | 'advanced/pushover-notifications', 41 | 'advanced/webhook-notifications', 42 | 'advanced/admin-tools' 43 | ], 44 | }, 45 | { 46 | type: 'category', 47 | label: 'Security', 48 | items: ['security/best-practices', 'security/authentication', 'security/non-root'], 49 | }, 50 | { 51 | type: 'category', 52 | label: 'Development', 53 | items: ['development/project-structure', 'development/contributing'], 54 | }, 55 | ], 56 | }; 57 | 58 | export default sidebars; 59 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import type {ReactNode} from 'react'; 2 | import clsx from 'clsx'; 3 | import Heading from '@theme/Heading'; 4 | import styles from './styles.module.css'; 5 | 6 | type FeatureItem = { 7 | title: string; 8 | Svg: React.ComponentType>; 9 | description: ReactNode; 10 | }; 11 | 12 | const FeatureList: FeatureItem[] = [ 13 | { 14 | title: 'Easy to Use', 15 | Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default, 16 | description: ( 17 | <> 18 | Docusaurus was designed from the ground up to be easily installed and 19 | used to get your website up and running quickly. 20 | 21 | ), 22 | }, 23 | { 24 | title: 'Focus on What Matters', 25 | Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default, 26 | description: ( 27 | <> 28 | Docusaurus lets you focus on your docs, and we'll do the chores. Go 29 | ahead and move your docs into the docs directory. 30 | 31 | ), 32 | }, 33 | { 34 | title: 'Powered by React', 35 | Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, 36 | description: ( 37 | <> 38 | Extend or customize your website layout by reusing React. Docusaurus can 39 | be extended while reusing the same header and footer. 40 | 41 | ), 42 | }, 43 | ]; 44 | 45 | function Feature({title, Svg, description}: FeatureItem) { 46 | return ( 47 |
48 |
49 | 50 |
51 |
52 | {title} 53 |

{description}

54 |
55 |
56 | ); 57 | } 58 | 59 | export default function HomepageFeatures(): ReactNode { 60 | return ( 61 |
62 |
63 |
64 | {FeatureList.map((props, idx) => ( 65 | 66 | ))} 67 |
68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | gap: 1rem; 24 | } 25 | 26 | .features { 27 | display: flex; 28 | align-items: center; 29 | padding: 2rem 0; 30 | width: 100%; 31 | } 32 | 33 | .section { 34 | padding: 4rem 0; 35 | } 36 | 37 | .sectionAlt { 38 | background-color: var(--ifm-color-emphasis-100); 39 | } 40 | -------------------------------------------------------------------------------- /docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /docs/src/theme/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import React, {type ReactNode} from 'react'; 2 | import SearchBar from '@theme-original/SearchBar'; 3 | import type SearchBarType from '@theme/SearchBar'; 4 | import type {WrapperProps} from '@docusaurus/types'; 5 | 6 | type Props = WrapperProps; 7 | 8 | export default function SearchBarWrapper(props: Props): ReactNode { 9 | return ( 10 | <> 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/favicon-16x16.png -------------------------------------------------------------------------------- /docs/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/favicon-32x32.png -------------------------------------------------------------------------------- /docs/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/favicon.ico -------------------------------------------------------------------------------- /docs/static/images/dashboard.gomft-038de3445e0d9228c5621ea0b5577c42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/images/dashboard.gomft-038de3445e0d9228c5621ea0b5577c42.png -------------------------------------------------------------------------------- /docs/static/images/transfer.config.gomft-7583731408b086ca9c7bf3127aca9952.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/images/transfer.config.gomft-7583731408b086ca9c7bf3127aca9952.png -------------------------------------------------------------------------------- /docs/static/img/README.md: -------------------------------------------------------------------------------- 1 | # Images directory 2 | 3 | This directory contains various images used throughout the documentation, including: 4 | 5 | 1. Logo files 6 | 2. Screenshots 7 | 3. Icons and other assets 8 | 9 | Some screenshots are automatically copied from the main repository's `/screenshots` directory during the build process. 10 | 11 | The automated copy is handled by the `prepare-screenshots` script in package.json. 12 | 13 | ## Required Images 14 | 15 | The following images are used in the documentation: 16 | 17 | - `logo.svg` - The GoMFT logo for the header 18 | - `favicon.ico` - The website favicon 19 | - `docusaurus-social-card.jpg` - Social media preview image 20 | - `dashboard.gomft.png` - Screenshot of the GoMFT dashboard 21 | - `dashboard.dark.gomft.png` - Screenshot of the GoMFT dashboard in dark mode 22 | - `transfer.config.gomft.png` - Screenshot of the transfer configuration 23 | - `scheduled.jobs.gomft.png` - Screenshot of the scheduled jobs 24 | - `transfer.history.gomft.png` - Screenshot of the transfer history 25 | - `user.management.gomft.png` - Screenshot of the user management 26 | - `role.management.gomft.png` - Screenshot of the role management 27 | - `authentication.providers.gomft.png` - Screenshot of the authentication providers 28 | - `notifications.gomft.png` - Screenshot of the notifications 29 | - `notification.service.gomft.png` - Screenshot of the notification service 30 | - `audit.logs.gomft.png` - Screenshot of the audit logs 31 | - `file.details.gomft.png` - Screenshot of the file details 32 | - `file.metadata.gomft.png` - Screenshot of the file metadata 33 | - `database.tools.gomft.png` - Screenshot of the database tools 34 | - `job.run.details.gomft.png` - Screenshot of the job run details 35 | 36 | ## Symlinks for Backward Compatibility 37 | 38 | - `dashboard.png` → `dashboard.gomft.png` 39 | - `create-transfer.png` → `transfer.config.gomft.png` 40 | - `docker.png` - Placeholder for Docker-related screenshots 41 | 42 | ## Adding New Images 43 | 44 | When adding images to this directory: 45 | 46 | 1. Use descriptive filenames 47 | 2. Optimize image size when possible 48 | 3. Use PNG for screenshots and SVG for vector graphics 49 | 4. Include alt text when referencing images in documentation -------------------------------------------------------------------------------- /docs/static/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/static/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/static/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/static/img/audit.logs.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/audit.logs.gomft.png -------------------------------------------------------------------------------- /docs/static/img/authentication.providers.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/authentication.providers.gomft.png -------------------------------------------------------------------------------- /docs/static/img/create-transfer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/create-transfer.png -------------------------------------------------------------------------------- /docs/static/img/dashboard.dark.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/dashboard.dark.gomft.png -------------------------------------------------------------------------------- /docs/static/img/dashboard.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/dashboard.gomft.png -------------------------------------------------------------------------------- /docs/static/img/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/dashboard.png -------------------------------------------------------------------------------- /docs/static/img/database.tools.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/database.tools.gomft.png -------------------------------------------------------------------------------- /docs/static/img/discord.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/static/img/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/docker.png -------------------------------------------------------------------------------- /docs/static/img/docusaurus-social-card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/docusaurus-social-card.jpg -------------------------------------------------------------------------------- /docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /docs/static/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/favicon-16x16.png -------------------------------------------------------------------------------- /docs/static/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/favicon-32x32.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/file.details.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/file.details.gomft.png -------------------------------------------------------------------------------- /docs/static/img/file.metadata.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/file.metadata.gomft.png -------------------------------------------------------------------------------- /docs/static/img/gomft-social-card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/gomft-social-card.jpg -------------------------------------------------------------------------------- /docs/static/img/job.run.details.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/job.run.details.gomft.png -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/notification.service.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/notification.service.gomft.png -------------------------------------------------------------------------------- /docs/static/img/notifications.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/notifications.gomft.png -------------------------------------------------------------------------------- /docs/static/img/role.management.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/role.management.gomft.png -------------------------------------------------------------------------------- /docs/static/img/scheduled.jobs.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/scheduled.jobs.gomft.png -------------------------------------------------------------------------------- /docs/static/img/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /docs/static/img/transfer.config.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/transfer.config.gomft.png -------------------------------------------------------------------------------- /docs/static/img/transfer.history.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/transfer.history.gomft.png -------------------------------------------------------------------------------- /docs/static/img/user.management.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/docs/static/img/user.management.gomft.png -------------------------------------------------------------------------------- /docs/static/screenshots/README.md: -------------------------------------------------------------------------------- 1 | # Screenshots directory 2 | 3 | This directory contains copies of the screenshots from the main repository root. These are used in the documentation site. 4 | 5 | The contents of this directory are automatically updated by the `prepare-screenshots` script in the package.json file. 6 | 7 | Do not manually add files to this directory as they will be overwritten by the scripts. -------------------------------------------------------------------------------- /docs/static/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | }, 7 | "exclude": [".docusaurus", "build"] 8 | } 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/starfleetcptn/gomft 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/a-h/templ v0.3.857 7 | github.com/gin-contrib/sessions v1.0.3 8 | github.com/gin-gonic/gin v1.10.0 9 | github.com/glebarez/sqlite v1.11.0 10 | github.com/go-gormigrate/gormigrate/v2 v2.1.4 11 | github.com/golang-jwt/jwt/v5 v5.2.2 12 | github.com/joho/godotenv v1.5.1 13 | github.com/pquerna/otp v1.4.0 14 | github.com/robfig/cron/v3 v3.0.1 15 | github.com/stretchr/testify v1.10.0 16 | golang.org/x/crypto v0.37.0 17 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 18 | gorm.io/gorm v1.25.12 19 | ) 20 | 21 | require ( 22 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect 23 | github.com/bytedance/sonic v1.13.2 // indirect 24 | github.com/bytedance/sonic/loader v0.2.4 // indirect 25 | github.com/cloudwego/base64x v0.1.5 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/dustin/go-humanize v1.0.1 // indirect 28 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 29 | github.com/gin-contrib/sse v1.0.0 // indirect 30 | github.com/glebarez/go-sqlite v1.21.2 // indirect 31 | github.com/go-playground/locales v0.14.1 // indirect 32 | github.com/go-playground/universal-translator v0.18.1 // indirect 33 | github.com/go-playground/validator/v10 v10.26.0 // indirect 34 | github.com/goccy/go-json v0.10.5 // indirect 35 | github.com/google/uuid v1.3.0 // indirect 36 | github.com/gorilla/context v1.1.2 // indirect 37 | github.com/gorilla/securecookie v1.1.2 // indirect 38 | github.com/gorilla/sessions v1.4.0 // indirect 39 | github.com/gorilla/websocket v1.5.3 // indirect 40 | github.com/jinzhu/inflection v1.0.0 // indirect 41 | github.com/jinzhu/now v1.1.5 // indirect 42 | github.com/json-iterator/go v1.1.12 // indirect 43 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 44 | github.com/leodido/go-urn v1.4.0 // indirect 45 | github.com/mattn/go-isatty v0.0.20 // indirect 46 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 47 | github.com/modern-go/reflect2 v1.0.2 // indirect 48 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 49 | github.com/pmezard/go-difflib v1.0.0 // indirect 50 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 51 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 52 | github.com/ugorji/go/codec v1.2.12 // indirect 53 | golang.org/x/arch v0.16.0 // indirect 54 | golang.org/x/net v0.38.0 // indirect 55 | golang.org/x/sys v0.32.0 // indirect 56 | golang.org/x/text v0.24.0 // indirect 57 | google.golang.org/protobuf v1.36.6 // indirect 58 | gopkg.in/yaml.v3 v3.0.1 // indirect 59 | modernc.org/libc v1.22.5 // indirect 60 | modernc.org/mathutil v1.5.0 // indirect 61 | modernc.org/memory v1.5.0 // indirect 62 | modernc.org/sqlite v1.23.1 // indirect 63 | ) 64 | -------------------------------------------------------------------------------- /internal/auth/jwt.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/golang-jwt/jwt/v5" 9 | ) 10 | 11 | // Claims represents the JWT claims 12 | type Claims struct { 13 | UserID uint `json:"user_id"` 14 | Email string `json:"email"` 15 | jwt.RegisteredClaims 16 | } 17 | 18 | // GenerateToken creates a new JWT token for a user 19 | func GenerateToken(userID uint, email, secret string, expirationTime time.Duration) (string, error) { 20 | // Create claims with user ID and expiration time 21 | claims := &Claims{ 22 | UserID: userID, 23 | Email: email, 24 | RegisteredClaims: jwt.RegisteredClaims{ 25 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(expirationTime)), 26 | IssuedAt: jwt.NewNumericDate(time.Now()), 27 | NotBefore: jwt.NewNumericDate(time.Now()), 28 | Issuer: "gomft", 29 | Subject: fmt.Sprintf("%d", userID), 30 | }, 31 | } 32 | 33 | // Create token with claims 34 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 35 | 36 | // Sign token with secret 37 | tokenString, err := token.SignedString([]byte(secret)) 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | return tokenString, nil 43 | } 44 | 45 | // ValidateToken validates a JWT token and returns the claims 46 | func ValidateToken(tokenString, secret string) (*Claims, error) { 47 | // Parse token 48 | token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { 49 | // Validate signing method 50 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 51 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 52 | } 53 | return []byte(secret), nil 54 | }) 55 | 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | // Extract claims 61 | if claims, ok := token.Claims.(*Claims); ok && token.Valid { 62 | return claims, nil 63 | } 64 | 65 | return nil, errors.New("invalid token") 66 | } 67 | -------------------------------------------------------------------------------- /internal/db/audit_log.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "time" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | // AuditLog represents an audit trail entry in the system 12 | type AuditLog struct { 13 | gorm.Model 14 | Action string `gorm:"size:50;not null;index"` 15 | EntityType string `gorm:"size:50;not null;index"` 16 | EntityID uint `gorm:"not null;index"` 17 | UserID uint `gorm:"not null;index"` 18 | Details AuditLogDetails `gorm:"type:json"` 19 | Timestamp time.Time `gorm:"not null;index;default:CURRENT_TIMESTAMP"` 20 | } 21 | 22 | // AuditLogDetails is a custom type for storing audit log details as JSON 23 | type AuditLogDetails map[string]interface{} 24 | 25 | // Scan implements the sql.Scanner interface 26 | func (d *AuditLogDetails) Scan(value interface{}) error { 27 | if value == nil { 28 | *d = make(AuditLogDetails) 29 | return nil 30 | } 31 | 32 | bytes, ok := value.([]byte) 33 | if !ok { 34 | return nil 35 | } 36 | 37 | return json.Unmarshal(bytes, d) 38 | } 39 | 40 | // Value implements the driver.Valuer interface 41 | func (d AuditLogDetails) Value() (driver.Value, error) { 42 | if d == nil { 43 | return json.Marshal(make(map[string]interface{})) 44 | } 45 | return json.Marshal(d) 46 | } 47 | -------------------------------------------------------------------------------- /internal/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/glebarez/sqlite" 9 | "github.com/starfleetcptn/gomft/internal/db/migrations" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type DB struct { 14 | *gorm.DB 15 | } 16 | 17 | func Initialize(dbPath string) (*DB, error) { 18 | // Create directory if it doesn't exist 19 | dir := filepath.Dir(dbPath) 20 | if err := os.MkdirAll(dir, 0755); err != nil { 21 | return nil, fmt.Errorf("failed to create database directory: %v", err) 22 | } 23 | 24 | // Open database connection with modernc.org/sqlite driver 25 | db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to connect to database: %v", err) 28 | } 29 | 30 | // Initialize and run migrations 31 | m := migrations.GetMigrations(db) 32 | if err := m.Migrate(); err != nil { 33 | return nil, fmt.Errorf("failed to run migrations: %v", err) 34 | } 35 | 36 | // Close the database connection after migrations 37 | sqlDB, err := db.DB() 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to get underlying database: %v", err) 40 | } 41 | if err := sqlDB.Close(); err != nil { 42 | return nil, fmt.Errorf("failed to close database after migrations: %v", err) 43 | } 44 | 45 | // Reopen the database connection for a clean state 46 | db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) 47 | if err != nil { 48 | return nil, fmt.Errorf("failed to reconnect to database after migrations: %v", err) 49 | } 50 | 51 | return &DB{DB: db}, nil 52 | } 53 | 54 | // ReopenWithoutMigrations reopens the database connection without running migrations 55 | // This should be used when temporarily closing and reopening the database 56 | func ReopenWithoutMigrations(dbPath string) (*DB, error) { 57 | // Open database connection with modernc.org/sqlite driver 58 | db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to connect to database: %v", err) 61 | } 62 | 63 | return &DB{DB: db}, nil 64 | } 65 | 66 | func (db *DB) Close() error { 67 | sqlDB, err := db.DB.DB() 68 | if err != nil { 69 | return err 70 | } 71 | return sqlDB.Close() 72 | } 73 | -------------------------------------------------------------------------------- /internal/db/file_metadata.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // FileMetadata stores information about processed files 8 | type FileMetadata struct { 9 | ID uint `gorm:"primarykey"` 10 | JobID uint `gorm:"not null;index"` 11 | Job Job `gorm:"foreignkey:JobID"` 12 | ConfigID uint `gorm:"default:0"` // The specific config ID this file was processed with 13 | FileName string `gorm:"not null"` 14 | OriginalPath string `gorm:"not null"` 15 | FileSize int64 `gorm:"not null"` 16 | FileHash string `gorm:"index"` // MD5 or other hash for file identity 17 | CreationTime time.Time 18 | ModTime time.Time 19 | ProcessedTime time.Time `gorm:"not null"` 20 | DestinationPath string `gorm:"not null"` 21 | Status string `gorm:"not null"` // processed, archived, deleted, etc. 22 | ErrorMessage string 23 | CreatedAt time.Time 24 | UpdatedAt time.Time 25 | } 26 | -------------------------------------------------------------------------------- /internal/db/file_metadata_store.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | // --- FileMetadata Store Methods --- 4 | 5 | // CreateFileMetadata creates a new file metadata record 6 | func (db *DB) CreateFileMetadata(metadata *FileMetadata) error { 7 | return db.Create(metadata).Error 8 | } 9 | 10 | // GetFileMetadataByJobAndName retrieves file metadata by job ID and filename 11 | func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) { 12 | var metadata FileMetadata 13 | err := db.Where("job_id = ? AND file_name = ?", jobID, fileName).First(&metadata).Error 14 | if err != nil { 15 | return nil, err 16 | } 17 | return &metadata, nil 18 | } 19 | 20 | // GetFileMetadataByHash retrieves file metadata by file hash 21 | func (db *DB) GetFileMetadataByHash(fileHash string) (*FileMetadata, error) { 22 | var metadata FileMetadata 23 | err := db.Where("file_hash = ?", fileHash).First(&metadata).Error 24 | if err != nil { 25 | return nil, err 26 | } 27 | return &metadata, nil 28 | } 29 | 30 | // DeleteFileMetadata deletes file metadata by ID 31 | func (db *DB) DeleteFileMetadata(id uint) error { 32 | return db.Delete(&FileMetadata{}, id).Error 33 | } 34 | -------------------------------------------------------------------------------- /internal/db/migrations/002_update_gdrive_type.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/go-gormigrate/gormigrate/v2" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | // UpdateGDriveType updates the source_type and destination_type from 'google_drive' to 'gdrive' 14 | func UpdateGDriveType() *gormigrate.Migration { 15 | return &gormigrate.Migration{ 16 | ID: "002_update_gdrive_type", 17 | Migrate: func(tx *gorm.DB) error { 18 | // Check if any tables exist (indicating an existing database) 19 | var count int64 20 | if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil { 21 | return fmt.Errorf("failed to check for existing tables: %v", err) 22 | } 23 | 24 | // If tables exist, create a backup 25 | if count > 0 { 26 | // Get the database path 27 | sqlDB, err := tx.DB() 28 | if err != nil { 29 | return fmt.Errorf("failed to get underlying database: %v", err) 30 | } 31 | 32 | var seq int 33 | var name, dbPath string 34 | if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil { 35 | return fmt.Errorf("failed to get database path: %v", err) 36 | } 37 | 38 | // Get backup directory from environment variable or use default 39 | backupDir := os.Getenv("BACKUP_DIR") 40 | if backupDir == "" { 41 | backupDir = "/app/backups" // Default Docker path 42 | // Check if we're not in Docker 43 | if _, err := os.Stat(backupDir); os.IsNotExist(err) { 44 | backupDir = "backups" // Fallback to local directory 45 | } 46 | } 47 | 48 | // Create backup directory if it doesn't exist 49 | if err := os.MkdirAll(backupDir, 0755); err != nil { 50 | return fmt.Errorf("failed to create backup directory: %v", err) 51 | } 52 | 53 | // Create backup file with timestamp in the backup directory 54 | dbFileName := filepath.Base(dbPath) 55 | backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405")) 56 | backupFile := filepath.Join(backupDir, backupFileName) 57 | 58 | // Read original database 59 | data, err := os.ReadFile(dbPath) 60 | if err != nil { 61 | return fmt.Errorf("failed to read database for backup: %v", err) 62 | } 63 | 64 | // Write backup 65 | if err := os.WriteFile(backupFile, data, 0600); err != nil { 66 | return fmt.Errorf("failed to create database backup: %v", err) 67 | } 68 | 69 | fmt.Printf("Created database backup at: %s\n", backupFile) 70 | } 71 | 72 | // Update source_type 73 | if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil { 74 | return err 75 | } 76 | 77 | // Update destination_type 78 | return tx.Exec(`UPDATE transfer_configs SET destination_type = 'gdrive' WHERE destination_type = 'google_drive'`).Error 79 | }, 80 | Rollback: func(tx *gorm.DB) error { 81 | // Revert source_type 82 | if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'google_drive' WHERE source_type = 'gdrive'`).Error; err != nil { 83 | return err 84 | } 85 | 86 | // Revert destination_type 87 | return tx.Exec(`UPDATE transfer_configs SET destination_type = 'google_drive' WHERE destination_type = 'gdrive'`).Error 88 | }, 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /internal/db/migrations/003_add_2fa.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/go-gormigrate/gormigrate/v2" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | // Add2FA creates a migration for adding Two-Factor Authentication fields 14 | func Add2FA() *gormigrate.Migration { 15 | return &gormigrate.Migration{ 16 | ID: "003_add_2fa", 17 | Migrate: func(tx *gorm.DB) error { 18 | // Check if any tables exist (indicating an existing database) 19 | var count int64 20 | if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil { 21 | return fmt.Errorf("failed to check for existing tables: %v", err) 22 | } 23 | 24 | // If tables exist, create a backup 25 | if count > 0 { 26 | // Get the database path 27 | sqlDB, err := tx.DB() 28 | if err != nil { 29 | return fmt.Errorf("failed to get underlying database: %v", err) 30 | } 31 | 32 | var seq int 33 | var name, dbPath string 34 | if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil { 35 | return fmt.Errorf("failed to get database path: %v", err) 36 | } 37 | 38 | // Get backup directory from environment variable or use default 39 | backupDir := os.Getenv("BACKUP_DIR") 40 | if backupDir == "" { 41 | backupDir = "/app/backups" // Default Docker path 42 | // Check if we're not in Docker 43 | if _, err := os.Stat(backupDir); os.IsNotExist(err) { 44 | backupDir = "backups" // Fallback to local directory 45 | } 46 | } 47 | 48 | // Create backup directory if it doesn't exist 49 | if err := os.MkdirAll(backupDir, 0755); err != nil { 50 | return fmt.Errorf("failed to create backup directory: %v", err) 51 | } 52 | 53 | // Create backup file with timestamp in the backup directory 54 | dbFileName := filepath.Base(dbPath) 55 | backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405")) 56 | backupFile := filepath.Join(backupDir, backupFileName) 57 | 58 | // Read original database 59 | data, err := os.ReadFile(dbPath) 60 | if err != nil { 61 | return fmt.Errorf("failed to read database for backup: %v", err) 62 | } 63 | 64 | // Write backup 65 | if err := os.WriteFile(backupFile, data, 0600); err != nil { 66 | return fmt.Errorf("failed to create database backup: %v", err) 67 | } 68 | 69 | fmt.Printf("Created database backup at: %s\n", backupFile) 70 | } 71 | 72 | // Add new columns for 2FA - one at a time for SQLite compatibility 73 | if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(32)`).Error; err != nil { 74 | return err 75 | } 76 | if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE`).Error; err != nil { 77 | return err 78 | } 79 | if err := tx.Exec(`ALTER TABLE users ADD COLUMN backup_codes TEXT`).Error; err != nil { 80 | return err 81 | } 82 | return nil 83 | }, 84 | Rollback: func(tx *gorm.DB) error { 85 | // Remove 2FA columns - one at a time for SQLite compatibility 86 | if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_secret`).Error; err != nil { 87 | return err 88 | } 89 | if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_enabled`).Error; err != nil { 90 | return err 91 | } 92 | if err := tx.Exec(`ALTER TABLE users DROP COLUMN backup_codes`).Error; err != nil { 93 | return err 94 | } 95 | return nil 96 | }, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/db/migrations/008_add_user_notifications.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/go-gormigrate/gormigrate/v2" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | // AddUserNotifications adds the user_notifications table 14 | func AddUserNotifications() *gormigrate.Migration { 15 | return &gormigrate.Migration{ 16 | ID: "008_add_user_notifications", 17 | Migrate: func(tx *gorm.DB) error { 18 | // Check if any tables exist (indicating an existing database) 19 | var count int64 20 | if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil { 21 | return fmt.Errorf("failed to check for existing tables: %v", err) 22 | } 23 | 24 | // If tables exist, create a backup 25 | if count > 0 { 26 | // Get the database path 27 | sqlDB, err := tx.DB() 28 | if err != nil { 29 | return fmt.Errorf("failed to get underlying database: %v", err) 30 | } 31 | 32 | var seq int 33 | var name, dbPath string 34 | if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil { 35 | return fmt.Errorf("failed to get database path: %v", err) 36 | } 37 | 38 | // Get backup directory from environment variable or use default 39 | backupDir := os.Getenv("BACKUP_DIR") 40 | if backupDir == "" { 41 | backupDir = "/app/backups" // Default Docker path 42 | // Check if we're not in Docker 43 | if _, err := os.Stat(backupDir); os.IsNotExist(err) { 44 | backupDir = "backups" // Fallback to local directory 45 | } 46 | } 47 | 48 | // Create backup directory if it doesn't exist 49 | if err := os.MkdirAll(backupDir, 0755); err != nil { 50 | return fmt.Errorf("failed to create backup directory: %v", err) 51 | } 52 | 53 | // Create backup file with timestamp in the backup directory 54 | dbFileName := filepath.Base(dbPath) 55 | backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405")) 56 | backupFile := filepath.Join(backupDir, backupFileName) 57 | 58 | // Read original database 59 | data, err := os.ReadFile(dbPath) 60 | if err != nil { 61 | return fmt.Errorf("failed to read database for backup: %v", err) 62 | } 63 | 64 | // Write backup 65 | if err := os.WriteFile(backupFile, data, 0600); err != nil { 66 | return fmt.Errorf("failed to create database backup: %v", err) 67 | } 68 | 69 | fmt.Printf("Created database backup at: %s\n", backupFile) 70 | } 71 | // Create the user_notifications table 72 | err := tx.Exec(` 73 | CREATE TABLE IF NOT EXISTS user_notifications ( 74 | id INTEGER PRIMARY KEY AUTOINCREMENT, 75 | user_id INTEGER NOT NULL, 76 | type TEXT NOT NULL, 77 | title TEXT NOT NULL, 78 | message TEXT NOT NULL, 79 | link TEXT NOT NULL, 80 | job_id INTEGER, 81 | job_run_id INTEGER, 82 | config_id INTEGER, 83 | is_read BOOLEAN NOT NULL DEFAULT 0, 84 | created_at DATETIME NOT NULL, 85 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 86 | ) 87 | `).Error 88 | if err != nil { 89 | return err 90 | } 91 | 92 | // Create an index on user_id for faster lookups 93 | err = tx.Exec(` 94 | CREATE INDEX IF NOT EXISTS idx_user_notifications_user_id ON user_notifications(user_id) 95 | `).Error 96 | if err != nil { 97 | return err 98 | } 99 | 100 | // Create an index on created_at for faster sorting 101 | err = tx.Exec(` 102 | CREATE INDEX IF NOT EXISTS idx_user_notifications_created_at ON user_notifications(created_at) 103 | `).Error 104 | if err != nil { 105 | return err 106 | } 107 | 108 | // Create an index on is_read for faster filtering of unread notifications 109 | err = tx.Exec(` 110 | CREATE INDEX IF NOT EXISTS idx_user_notifications_is_read ON user_notifications(is_read) 111 | `).Error 112 | if err != nil { 113 | return err 114 | } 115 | 116 | return nil 117 | }, 118 | Rollback: func(tx *gorm.DB) error { 119 | return tx.Exec(`DROP TABLE IF EXISTS user_notifications`).Error 120 | }, 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /internal/db/migrations/010_add_rclone_command_to_config.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/go-gormigrate/gormigrate/v2" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | // AddRcloneCommandToConfig adds rclone command fields to the transfer_configs table 14 | func AddRcloneCommandToConfig() *gormigrate.Migration { 15 | return &gormigrate.Migration{ 16 | ID: "010_add_rclone_command_to_config", 17 | Migrate: func(tx *gorm.DB) error { 18 | // Check if any tables exist (indicating an existing database) 19 | var count int64 20 | if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil { 21 | return fmt.Errorf("failed to check for existing tables: %v", err) 22 | } 23 | 24 | // If tables exist, create a backup 25 | if count > 0 { 26 | // Get the database path 27 | sqlDB, err := tx.DB() 28 | if err != nil { 29 | return fmt.Errorf("failed to get underlying database: %v", err) 30 | } 31 | 32 | var seq int 33 | var name, dbPath string 34 | if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil { 35 | return fmt.Errorf("failed to get database path: %v", err) 36 | } 37 | 38 | // Get backup directory from environment variable or use default 39 | backupDir := os.Getenv("BACKUP_DIR") 40 | if backupDir == "" { 41 | backupDir = "/app/backups" // Default Docker path 42 | // Check if we're not in Docker 43 | if _, err := os.Stat(backupDir); os.IsNotExist(err) { 44 | backupDir = "backups" // Fallback to local directory 45 | } 46 | } 47 | 48 | // Create backup directory if it doesn't exist 49 | if err := os.MkdirAll(backupDir, 0755); err != nil { 50 | return fmt.Errorf("failed to create backup directory: %v", err) 51 | } 52 | 53 | // Create backup file with timestamp in the backup directory 54 | dbFileName := filepath.Base(dbPath) 55 | backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405")) 56 | backupFile := filepath.Join(backupDir, backupFileName) 57 | 58 | // Read original database 59 | data, err := os.ReadFile(dbPath) 60 | if err != nil { 61 | return fmt.Errorf("failed to read database for backup: %v", err) 62 | } 63 | 64 | // Write backup 65 | if err := os.WriteFile(backupFile, data, 0600); err != nil { 66 | return fmt.Errorf("failed to create database backup: %v", err) 67 | } 68 | 69 | fmt.Printf("Created database backup at: %s\n", backupFile) 70 | } 71 | 72 | // Add the command_id and command_flags columns to the transfer_configs table 73 | if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_id INTEGER DEFAULT NULL REFERENCES rclone_commands(id)`).Error; err != nil { 74 | return err 75 | } 76 | 77 | if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_flags TEXT DEFAULT NULL`).Error; err != nil { 78 | return err 79 | } 80 | 81 | if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_flag_values TEXT DEFAULT NULL`).Error; err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | }, 87 | Rollback: func(tx *gorm.DB) error { 88 | // Remove the columns in reverse order 89 | if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_flags`).Error; err != nil { 90 | return err 91 | } 92 | 93 | if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_id`).Error; err != nil { 94 | return err 95 | } 96 | 97 | if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_flag_values`).Error; err != nil { 98 | return err 99 | } 100 | 101 | return nil 102 | }, 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /internal/db/migrations/011_add_auth_providers.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/go-gormigrate/gormigrate/v2" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | // AddAuthProviders adds tables for external authentication providers 14 | func AddAuthProviders() *gormigrate.Migration { 15 | return &gormigrate.Migration{ 16 | ID: "011_add_auth_providers", 17 | Migrate: func(tx *gorm.DB) error { 18 | // Check if any tables exist (indicating an existing database) 19 | var count int64 20 | if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil { 21 | return fmt.Errorf("failed to check for existing tables: %v", err) 22 | } 23 | 24 | // If tables exist, create a backup 25 | if count > 0 { 26 | // Get the database path 27 | sqlDB, err := tx.DB() 28 | if err != nil { 29 | return fmt.Errorf("failed to get underlying database: %v", err) 30 | } 31 | 32 | var seq int 33 | var name, dbPath string 34 | if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil { 35 | return fmt.Errorf("failed to get database path: %v", err) 36 | } 37 | 38 | // Get backup directory from environment variable or use default 39 | backupDir := os.Getenv("BACKUP_DIR") 40 | if backupDir == "" { 41 | backupDir = "/app/backups" // Default Docker path 42 | // Check if we're not in Docker 43 | if _, err := os.Stat(backupDir); os.IsNotExist(err) { 44 | backupDir = "backups" // Fallback to local directory 45 | } 46 | } 47 | 48 | // Create backup directory if it doesn't exist 49 | if err := os.MkdirAll(backupDir, 0755); err != nil { 50 | return fmt.Errorf("failed to create backup directory: %v", err) 51 | } 52 | 53 | // Create backup file with timestamp in the backup directory 54 | dbFileName := filepath.Base(dbPath) 55 | backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405")) 56 | backupFile := filepath.Join(backupDir, backupFileName) 57 | 58 | // Read original database 59 | data, err := os.ReadFile(dbPath) 60 | if err != nil { 61 | return fmt.Errorf("failed to read database for backup: %v", err) 62 | } 63 | 64 | // Write backup 65 | if err := os.WriteFile(backupFile, data, 0644); err != nil { 66 | return fmt.Errorf("failed to write database backup: %v", err) 67 | } 68 | 69 | fmt.Printf("Created database backup at %s\n", backupFile) 70 | } 71 | 72 | // Create auth_providers table 73 | if err := tx.Exec(`CREATE TABLE IF NOT EXISTS auth_providers ( 74 | id INTEGER PRIMARY KEY AUTOINCREMENT, 75 | name VARCHAR(255) NOT NULL, 76 | type VARCHAR(50) NOT NULL, 77 | enabled BOOLEAN DEFAULT TRUE, 78 | description TEXT, 79 | provider_url TEXT, 80 | icon_url TEXT, 81 | client_id VARCHAR(255), 82 | client_secret VARCHAR(255), 83 | redirect_url TEXT, 84 | scopes TEXT, 85 | attribute_mapping TEXT, 86 | config TEXT, 87 | successful_logins INTEGER DEFAULT 0, 88 | last_used DATETIME, 89 | created_at DATETIME, 90 | updated_at DATETIME 91 | )`).Error; err != nil { 92 | return err 93 | } 94 | 95 | // Create external_user_identities table 96 | if err := tx.Exec(`CREATE TABLE IF NOT EXISTS external_user_identities ( 97 | id INTEGER PRIMARY KEY AUTOINCREMENT, 98 | user_id INTEGER NOT NULL, 99 | provider_id INTEGER NOT NULL, 100 | provider_type VARCHAR(50) NOT NULL, 101 | external_id VARCHAR(255) NOT NULL, 102 | email VARCHAR(255) NOT NULL, 103 | username VARCHAR(255), 104 | display_name VARCHAR(255), 105 | groups TEXT, 106 | last_login DATETIME, 107 | provider_data TEXT, 108 | created_at DATETIME, 109 | updated_at DATETIME, 110 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 111 | FOREIGN KEY (provider_id) REFERENCES auth_providers(id) ON DELETE CASCADE 112 | )`).Error; err != nil { 113 | return err 114 | } 115 | 116 | // Create unique index on provider_id and external_id 117 | if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_external_user_identities_provider_external 118 | ON external_user_identities(provider_id, external_id)`).Error; err != nil { 119 | return err 120 | } 121 | 122 | return nil 123 | }, 124 | Rollback: func(tx *gorm.DB) error { 125 | if err := tx.Exec("DROP TABLE IF EXISTS external_user_identities").Error; err != nil { 126 | return err 127 | } 128 | if err := tx.Exec("DROP TABLE IF EXISTS auth_providers").Error; err != nil { 129 | return err 130 | } 131 | return nil 132 | }, 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /internal/db/migrations/012a_recover_transfer_configs_rename.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-gormigrate/gormigrate/v2" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // RecoverTransferConfigsRename checks for and corrects a specific inconsistent state 11 | // left by a potentially failed run of migration 012, where the transfer_configs 12 | // table might have been left renamed as _transfer_configs_old. 13 | func RecoverTransferConfigsRename() *gormigrate.Migration { 14 | return &gormigrate.Migration{ 15 | ID: "012a_recover_transfer_configs_rename", 16 | Migrate: func(tx *gorm.DB) error { 17 | fmt.Println("Running migration 012a: Checking for transfer_configs rename recovery...") 18 | 19 | var oldTableExists int 20 | tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='_transfer_configs_old'").Scan(&oldTableExists) 21 | 22 | var newTableExists int 23 | tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='transfer_configs'").Scan(&newTableExists) 24 | 25 | if oldTableExists > 0 && newTableExists == 0 { 26 | fmt.Println("Found _transfer_configs_old table but not transfer_configs. Attempting recovery rename...") 27 | if err := tx.Exec("ALTER TABLE _transfer_configs_old RENAME TO transfer_configs").Error; err != nil { 28 | return fmt.Errorf("failed to rename _transfer_configs_old back to transfer_configs: %w", err) 29 | } 30 | fmt.Println("Successfully renamed _transfer_configs_old to transfer_configs.") 31 | } else if oldTableExists > 0 && newTableExists > 0 { 32 | // This state shouldn't ideally happen if migration 012 followed its logic, 33 | // but indicates a potential issue. Maybe drop the old one? For now, just log. 34 | fmt.Println("Warning: Both transfer_configs and _transfer_configs_old tables exist. Manual inspection might be needed.") 35 | } else { 36 | fmt.Println("No recovery needed for transfer_configs rename.") 37 | } 38 | 39 | return nil 40 | }, 41 | Rollback: func(tx *gorm.DB) error { 42 | // Rollback doesn't make sense for a recovery step. 43 | fmt.Println("Rollback for migration 012a_recover_transfer_configs_rename is not applicable.") 44 | return nil 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/db/migrations/012b_recover_notification_services_rename.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-gormigrate/gormigrate/v2" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // RecoverNotificationServicesRename checks for and corrects a specific inconsistent state 11 | // left by a potentially failed run of migration 012, where the notification_services 12 | // table might have been left renamed as _notification_services_old. 13 | func RecoverNotificationServicesRename() *gormigrate.Migration { 14 | return &gormigrate.Migration{ 15 | ID: "012b_recover_notification_services_rename", 16 | Migrate: func(tx *gorm.DB) error { 17 | fmt.Println("Running migration 012b: Checking for notification_services rename recovery...") 18 | 19 | var oldTableExists int 20 | tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='_notification_services_old'").Scan(&oldTableExists) 21 | 22 | var newTableExists int 23 | tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='notification_services'").Scan(&newTableExists) 24 | 25 | if oldTableExists > 0 && newTableExists == 0 { 26 | fmt.Println("Found _notification_services_old table but not notification_services. Attempting recovery rename...") 27 | if err := tx.Exec("ALTER TABLE _notification_services_old RENAME TO notification_services").Error; err != nil { 28 | return fmt.Errorf("failed to rename _notification_services_old back to notification_services: %w", err) 29 | } 30 | fmt.Println("Successfully renamed _notification_services_old to notification_services.") 31 | } else if oldTableExists > 0 && newTableExists > 0 { 32 | fmt.Println("Warning: Both notification_services and _notification_services_old tables exist. Manual inspection might be needed.") 33 | } else { 34 | fmt.Println("No recovery needed for notification_services rename.") 35 | } 36 | 37 | return nil 38 | }, 39 | Rollback: func(tx *gorm.DB) error { 40 | // Rollback doesn't make sense for a recovery step. 41 | fmt.Println("Rollback for migration 012b_recover_notification_services_rename is not applicable.") 42 | return nil 43 | }, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/db/migrations/012c_recover_auth_providers_rename.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-gormigrate/gormigrate/v2" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // RecoverAuthProvidersRename checks for and corrects a specific inconsistent state 11 | // left by a potentially failed run of migration 012, where the auth_providers 12 | // table might have been left renamed as _auth_providers_old. 13 | func RecoverAuthProvidersRename() *gormigrate.Migration { 14 | return &gormigrate.Migration{ 15 | ID: "012c_recover_auth_providers_rename", 16 | Migrate: func(tx *gorm.DB) error { 17 | fmt.Println("Running migration 012c: Checking for auth_providers rename recovery...") 18 | 19 | var oldTableExists int 20 | tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='_auth_providers_old'").Scan(&oldTableExists) 21 | 22 | var newTableExists int 23 | tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='auth_providers'").Scan(&newTableExists) 24 | 25 | if oldTableExists > 0 && newTableExists == 0 { 26 | fmt.Println("Found _auth_providers_old table but not auth_providers. Attempting recovery rename...") 27 | if err := tx.Exec("ALTER TABLE _auth_providers_old RENAME TO auth_providers").Error; err != nil { 28 | return fmt.Errorf("failed to rename _auth_providers_old back to auth_providers: %w", err) 29 | } 30 | fmt.Println("Successfully renamed _auth_providers_old to auth_providers.") 31 | } else if oldTableExists > 0 && newTableExists > 0 { 32 | fmt.Println("Warning: Both auth_providers and _auth_providers_old tables exist. Manual inspection might be needed.") 33 | } else { 34 | fmt.Println("No recovery needed for auth_providers rename.") 35 | } 36 | 37 | return nil 38 | }, 39 | Rollback: func(tx *gorm.DB) error { 40 | // Rollback doesn't make sense for a recovery step. 41 | fmt.Println("Rollback for migration 012c_recover_auth_providers_rename is not applicable.") 42 | return nil 43 | }, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/db/migrations/013_cleanup_invalid_booleans.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-gormigrate/gormigrate/v2" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // CleanupInvalidBooleans updates boolean columns represented as integers 11 | // to ensure they only contain valid values (0, 1, or NULL). 12 | func CleanupInvalidBooleans() *gormigrate.Migration { 13 | return &gormigrate.Migration{ 14 | ID: "013_cleanup_invalid_booleans", 15 | Migrate: func(tx *gorm.DB) error { 16 | fmt.Println("Running migration 013: Cleaning up invalid boolean values...") 17 | 18 | // Target: transfer_configs.delete_after_transfer 19 | // Set any non-NULL value that is not 0 or 1 to 0 (false) 20 | sql := `UPDATE transfer_configs 21 | SET delete_after_transfer = 0 22 | WHERE delete_after_transfer IS NOT NULL AND delete_after_transfer NOT IN (0, 1);` 23 | 24 | if err := tx.Exec(sql).Error; err != nil { 25 | return fmt.Errorf("failed to cleanup delete_after_transfer in transfer_configs: %w", err) 26 | } 27 | fmt.Println("Cleaned up invalid values in transfer_configs.delete_after_transfer.") 28 | 29 | // Target: transfer_configs.archive_enabled 30 | sql = `UPDATE transfer_configs 31 | SET archive_enabled = 0 32 | WHERE archive_enabled IS NOT NULL AND archive_enabled NOT IN (0, 1);` 33 | if err := tx.Exec(sql).Error; err != nil { 34 | return fmt.Errorf("failed to cleanup archive_enabled in transfer_configs: %w", err) 35 | } 36 | fmt.Println("Cleaned up invalid values in transfer_configs.archive_enabled.") 37 | 38 | // Target: transfer_configs.skip_processed_files 39 | sql = `UPDATE transfer_configs 40 | SET skip_processed_files = 0 41 | WHERE skip_processed_files IS NOT NULL AND skip_processed_files NOT IN (0, 1);` 42 | if err := tx.Exec(sql).Error; err != nil { 43 | return fmt.Errorf("failed to cleanup skip_processed_files in transfer_configs: %w", err) 44 | } 45 | fmt.Println("Cleaned up invalid values in transfer_configs.skip_processed_files.") 46 | 47 | // Target: notification_services.is_enabled 48 | sql = `UPDATE notification_services 49 | SET is_enabled = 0 50 | WHERE is_enabled IS NOT NULL AND is_enabled NOT IN (0, 1);` 51 | if err := tx.Exec(sql).Error; err != nil { 52 | // Check if the table exists before failing hard 53 | var tableExists int 54 | tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='notification_services'").Scan(&tableExists) 55 | if tableExists == 0 { 56 | fmt.Println("Skipping cleanup for notification_services.is_enabled: table does not exist.") 57 | } else { 58 | return fmt.Errorf("failed to cleanup is_enabled in notification_services: %w", err) 59 | } 60 | } else { 61 | fmt.Println("Cleaned up invalid values in notification_services.is_enabled.") 62 | } 63 | 64 | // Target: auth_providers.enabled 65 | sql = `UPDATE auth_providers 66 | SET enabled = 0 67 | WHERE enabled IS NOT NULL AND enabled NOT IN (0, 1);` 68 | if err := tx.Exec(sql).Error; err != nil { 69 | // Check if the table exists before failing hard 70 | var tableExists int 71 | tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='auth_providers'").Scan(&tableExists) 72 | if tableExists == 0 { 73 | fmt.Println("Skipping cleanup for auth_providers.enabled: table does not exist.") 74 | } else { 75 | return fmt.Errorf("failed to cleanup enabled in auth_providers: %w", err) 76 | } 77 | } else { 78 | fmt.Println("Cleaned up invalid values in auth_providers.enabled.") 79 | } 80 | 81 | fmt.Println("Migration 013 completed successfully.") 82 | return nil 83 | }, 84 | Rollback: func(tx *gorm.DB) error { 85 | // This migration cleans up data. Rolling back doesn't make sense 86 | // as we don't know the original invalid values. 87 | fmt.Println("Rollback for migration 013_cleanup_invalid_booleans is not applicable.") 88 | return nil 89 | }, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /internal/db/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/go-gormigrate/gormigrate/v2" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | var migrations []*gormigrate.Migration 9 | 10 | // GetMigrations returns all migrations 11 | func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate { 12 | // Add all migrations in order 13 | migrations = append(migrations, 14 | InitialSchema(), // 001 15 | UpdateGDriveType(), // 002 16 | Add2FA(), // 003 17 | AddAuditLogs(), // 004 18 | AddDefaultRoles(), // 005 19 | AddTimestampsToJobHistories(), // 006 20 | AddNotificationServices(), // 007 21 | AddUserNotifications(), // 008 22 | AddRcloneTables(), // 009 23 | AddRcloneCommandToConfig(), // 010 24 | AddAuthProviders(), // 011 25 | AlterBooleanDefaults(), // 012 26 | RecoverTransferConfigsRename(), // 012a 27 | RecoverNotificationServicesRename(), // 012b 28 | RecoverAuthProvidersRename(), // 012c 29 | CleanupInvalidBooleans(), // 013 30 | ) 31 | 32 | return gormigrate.New(db, gormigrate.DefaultOptions, migrations) 33 | } 34 | -------------------------------------------------------------------------------- /internal/db/notification.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // NotificationService represents a notification service configuration 11 | type NotificationService struct { 12 | ID uint `json:"id" gorm:"primaryKey"` 13 | Name string `json:"name" gorm:"not null"` 14 | Type string `json:"type" gorm:"not null"` // email, webhook 15 | IsEnabled *bool `json:"is_enabled" gorm:"default:true"` 16 | Config map[string]string `json:"config" gorm:"-"` 17 | ConfigJSON string `json:"-" gorm:"column:config"` 18 | Description string `json:"description"` 19 | EventTriggers []string `json:"event_triggers" gorm:"-"` 20 | EventTriggersJSON string `json:"-" gorm:"column:event_triggers;default:'[]'"` 21 | PayloadTemplate string `json:"payload_template" gorm:"column:payload_template"` 22 | SecretKey string `json:"secret_key" gorm:"column:secret_key"` 23 | RetryPolicy string `json:"retry_policy" gorm:"column:retry_policy;default:'simple'"` 24 | LastUsed time.Time `json:"last_used" gorm:"column:last_used"` 25 | SuccessCount int `json:"success_count" gorm:"column:success_count;default:0"` 26 | FailureCount int `json:"failure_count" gorm:"column:failure_count;default:0"` 27 | CreatedBy uint `json:"created_by"` 28 | CreatedAt time.Time `json:"created_at"` 29 | UpdatedAt time.Time `json:"updated_at"` 30 | } 31 | 32 | // BeforeSave converts Config map and EventTriggers to JSON strings for storage 33 | func (n *NotificationService) BeforeSave(tx *gorm.DB) error { 34 | configJSON, err := json.Marshal(n.Config) 35 | if err != nil { 36 | return err 37 | } 38 | n.ConfigJSON = string(configJSON) 39 | 40 | eventsJSON, err := json.Marshal(n.EventTriggers) 41 | if err != nil { 42 | return err 43 | } 44 | n.EventTriggersJSON = string(eventsJSON) 45 | 46 | return nil 47 | } 48 | 49 | // AfterFind converts JSON strings back to Config map and EventTriggers 50 | func (n *NotificationService) AfterFind(tx *gorm.DB) error { 51 | if n.ConfigJSON != "" { 52 | if err := json.Unmarshal([]byte(n.ConfigJSON), &n.Config); err != nil { 53 | return err 54 | } 55 | } 56 | 57 | if n.EventTriggersJSON != "" { 58 | if err := json.Unmarshal([]byte(n.EventTriggersJSON), &n.EventTriggers); err != nil { 59 | return err 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | 66 | // GetNotificationServices returns notification services, filtered by enabled status if specified 67 | func (db *DB) GetNotificationServices(onlyEnabled bool) ([]NotificationService, error) { 68 | var services []NotificationService 69 | query := db.DB 70 | 71 | if onlyEnabled { 72 | // When using a pointer, we need to explicitly check for true 73 | // GORM handles the underlying SQL correctly for different dialects 74 | query = query.Where("is_enabled = ?", true) 75 | } 76 | 77 | if err := query.Find(&services).Error; err != nil { 78 | return nil, err 79 | } 80 | 81 | return services, nil 82 | } 83 | 84 | // GetNotificationService returns a notification service by ID 85 | func (db *DB) GetNotificationService(id uint) (*NotificationService, error) { 86 | var service NotificationService 87 | if err := db.First(&service, id).Error; err != nil { 88 | return nil, err 89 | } 90 | return &service, nil 91 | } 92 | 93 | // CreateNotificationService creates a new notification service 94 | func (db *DB) CreateNotificationService(service *NotificationService) error { 95 | return db.Create(service).Error 96 | } 97 | 98 | // UpdateNotificationService updates an existing notification service 99 | func (db *DB) UpdateNotificationService(service *NotificationService) error { 100 | return db.Save(service).Error 101 | } 102 | 103 | // DeleteNotificationService deletes a notification service by ID 104 | func (db *DB) DeleteNotificationService(id uint) error { 105 | return db.Delete(&NotificationService{}, id).Error 106 | } 107 | 108 | // --- NotificationService Helper Methods --- 109 | 110 | // GetIsEnabled returns the value of IsEnabled with a default if nil 111 | func (n *NotificationService) GetIsEnabled() bool { 112 | if n.IsEnabled == nil { 113 | // If the pointer is nil, GORM might not have set it, 114 | // or it was explicitly set to nil. We assume the DB default (true) 115 | // if it's nil, aligning with the original gorm tag default. 116 | return true 117 | } 118 | return *n.IsEnabled 119 | } 120 | 121 | // SetIsEnabled sets the IsEnabled field 122 | func (n *NotificationService) SetIsEnabled(value bool) { 123 | n.IsEnabled = &value 124 | } 125 | -------------------------------------------------------------------------------- /internal/db/rclone.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // RcloneCommand represents a command available in rclone 10 | type RcloneCommand struct { 11 | ID uint `gorm:"primarykey"` 12 | Name string `gorm:"not null;uniqueIndex"` 13 | Description string `gorm:"not null"` 14 | Category string `gorm:"not null;index"` 15 | IsAdvanced bool `gorm:"not null;default:false"` 16 | Flags []RcloneCommandFlag `gorm:"foreignKey:CommandID;constraint:OnDelete:CASCADE"` 17 | CreatedAt time.Time `gorm:"not null"` 18 | } 19 | 20 | // RcloneCommandFlag represents a flag that can be used with an rclone command 21 | type RcloneCommandFlag struct { 22 | ID uint `gorm:"primarykey"` 23 | CommandID uint `gorm:"not null;index"` 24 | Command RcloneCommand `gorm:"foreignKey:CommandID"` 25 | Name string `gorm:"not null;index"` 26 | ShortName string 27 | Description string `gorm:"not null"` 28 | DataType string `gorm:"not null"` // string, int, bool, etc. 29 | IsRequired bool `gorm:"not null;default:false"` 30 | DefaultValue string 31 | CreatedAt time.Time `gorm:"not null"` 32 | } 33 | 34 | // --- Rclone Helper Methods --- 35 | 36 | // GetUsageExample returns a human-readable usage example for a flag 37 | func (flag *RcloneCommandFlag) GetUsageExample() string { 38 | switch flag.DataType { 39 | case "bool": 40 | return flag.Name 41 | case "int": 42 | return fmt.Sprintf("%s=", flag.Name) 43 | case "float": 44 | return fmt.Sprintf("%s=", flag.Name) 45 | case "string": 46 | return fmt.Sprintf("%s=", flag.Name) 47 | default: 48 | return fmt.Sprintf("%s=", flag.Name) 49 | } 50 | } 51 | 52 | // ParseRcloneFlags parses a string of rclone flags into a map 53 | // Note: This is a general utility function, not tied to a specific struct instance. 54 | // It might be better placed in a more general utility package if one exists, 55 | // but keeping it here for now as per the original file structure. 56 | func ParseRcloneFlags(flagsStr string) map[string]string { 57 | result := make(map[string]string) 58 | if flagsStr == "" { 59 | return result 60 | } 61 | 62 | // Split the flags string by spaces 63 | parts := strings.Fields(flagsStr) 64 | 65 | for i := 0; i < len(parts); i++ { 66 | part := parts[i] 67 | 68 | // Check if it's a flag (starts with --) 69 | if strings.HasPrefix(part, "--") { 70 | // Remove the -- prefix 71 | flagName := part // Keep the '--' prefix in the map key for consistency? Or remove? Plan used remove. 72 | // flagName := strings.TrimPrefix(part, "--") // Alternative: remove prefix 73 | 74 | // Check if the flag has a value 75 | if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "--") { 76 | // Next part is a value 77 | result[flagName] = parts[i+1] 78 | i++ // Skip the value in the next iteration 79 | } else { 80 | // Flag without value, treat as boolean true 81 | result[flagName] = "true" 82 | } 83 | } 84 | } 85 | 86 | return result 87 | } 88 | -------------------------------------------------------------------------------- /internal/db/role_store.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // --- Role Store Methods --- 11 | 12 | // CreateRole creates a new role record 13 | func (db *DB) CreateRole(role *Role) error { 14 | return db.Create(role).Error 15 | } 16 | 17 | // GetRole retrieves a role by ID, preloading permissions 18 | func (db *DB) GetRole(id uint) (*Role, error) { 19 | var role Role 20 | // Assuming Permissions are handled correctly by GORM or custom type 21 | err := db.First(&role, id).Error 22 | if err != nil { 23 | return nil, err 24 | } 25 | return &role, nil 26 | } 27 | 28 | // GetRoleByName retrieves a role by name, preloading permissions 29 | func (db *DB) GetRoleByName(name string) (*Role, error) { 30 | var role Role 31 | err := db.Where("name = ?", name).First(&role).Error 32 | if err != nil { 33 | return nil, err 34 | } 35 | return &role, nil 36 | } 37 | 38 | // UpdateRole updates an existing role record 39 | func (db *DB) UpdateRole(role *Role) error { 40 | // Use Omit Users to prevent GORM from trying to update the many2many relationship directly here 41 | return db.Omit("Users").Save(role).Error 42 | } 43 | 44 | // DeleteRole deletes a role after checking dependencies and removing assignments 45 | func (db *DB) DeleteRole(id uint) error { 46 | var role Role 47 | if err := db.First(&role, id).Error; err != nil { 48 | return fmt.Errorf("role not found: %w", err) 49 | } 50 | 51 | if role.IsSystemRole() { 52 | return errors.New("cannot delete system role") 53 | } 54 | 55 | // Start transaction 56 | tx := db.Begin() 57 | if err := tx.Error; err != nil { 58 | return err 59 | } 60 | 61 | // Manually delete role assignments from the join table 62 | if err := tx.Exec("DELETE FROM user_roles WHERE role_id = ?", id).Error; err != nil { 63 | tx.Rollback() 64 | return fmt.Errorf("failed to delete role assignments: %w", err) 65 | } 66 | 67 | // Delete the role itself 68 | if err := tx.Delete(&role).Error; err != nil { 69 | tx.Rollback() 70 | return fmt.Errorf("failed to delete role: %w", err) 71 | } 72 | 73 | // Commit transaction 74 | return tx.Commit().Error 75 | } 76 | 77 | // ListRoles retrieves all roles 78 | func (db *DB) ListRoles() ([]Role, error) { 79 | var roles []Role 80 | err := db.Find(&roles).Error 81 | return roles, err 82 | } 83 | 84 | // GetUserRoles retrieves all roles assigned to a specific user ID 85 | func (db *DB) GetUserRoles(userID uint) ([]Role, error) { 86 | var user User 87 | // Preload the Roles association 88 | if err := db.Preload("Roles").First(&user, userID).Error; err != nil { 89 | // Handle case where user might not be found vs. other errors 90 | if errors.Is(err, gorm.ErrRecordNotFound) { 91 | return nil, fmt.Errorf("user with ID %d not found", userID) 92 | } 93 | return nil, fmt.Errorf("failed to get user %d roles: %w", userID, err) 94 | } 95 | return user.Roles, nil 96 | } 97 | 98 | // AssignRoleToUser assigns a role to a user, handling the join table 99 | func (db *DB) AssignRoleToUser(roleID, userID, assignedByID uint) error { 100 | var role Role 101 | if err := db.First(&role, roleID).Error; err != nil { 102 | return fmt.Errorf("role with ID %d not found: %w", roleID, err) 103 | } 104 | var user User 105 | if err := db.First(&user, userID).Error; err != nil { 106 | return fmt.Errorf("user with ID %d not found: %w", userID, err) 107 | } 108 | 109 | // Use GORM's Association API for many2many 110 | err := db.Model(&user).Association("Roles").Append(&role) 111 | if err != nil { 112 | return fmt.Errorf("failed to assign role %d to user %d: %w", roleID, userID, err) 113 | } 114 | 115 | // Optionally, log the assignment (consider moving audit logging to a dedicated service/hook) 116 | // db.Create(&AuditLog{...}) 117 | 118 | return nil 119 | } 120 | 121 | // UnassignRoleFromUser removes a role from a user, handling the join table 122 | func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error { 123 | var role Role 124 | if err := db.First(&role, roleID).Error; err != nil { 125 | return fmt.Errorf("role with ID %d not found: %w", roleID, err) 126 | } 127 | var user User 128 | // Need to preload roles to check if the association exists before deleting 129 | if err := db.Preload("Roles").First(&user, userID).Error; err != nil { 130 | return fmt.Errorf("user with ID %d not found: %w", userID, err) 131 | } 132 | 133 | // Use GORM's Association API for many2many deletion 134 | err := db.Model(&user).Association("Roles").Delete(&role) 135 | if err != nil { 136 | return fmt.Errorf("failed to unassign role %d from user %d: %w", roleID, userID, err) 137 | } 138 | 139 | // Optionally, log the unassignment 140 | // db.Create(&AuditLog{...}) 141 | 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/db/user_store.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // --- User Store Methods --- 8 | 9 | // CreateUser creates a new user record 10 | func (db *DB) CreateUser(user *User) error { 11 | return db.Create(user).Error 12 | } 13 | 14 | // GetUserByEmail retrieves a user by their email address 15 | func (db *DB) GetUserByEmail(email string) (*User, error) { 16 | var user User 17 | // Preload Roles to ensure they are available for permission checks 18 | err := db.Preload("Roles").Where("email = ?", email).First(&user).Error 19 | if err != nil { 20 | return nil, err 21 | } 22 | return &user, nil 23 | } 24 | 25 | // GetUserByID retrieves a user by their ID 26 | func (db *DB) GetUserByID(id uint) (*User, error) { 27 | var user User 28 | // Preload Roles 29 | err := db.Preload("Roles").First(&user, id).Error 30 | if err != nil { 31 | return nil, err 32 | } 33 | return &user, nil 34 | } 35 | 36 | // UpdateUser updates an existing user record 37 | func (db *DB) UpdateUser(user *User) error { 38 | // Use Omit to prevent accidentally changing Roles association directly 39 | // Role assignments should use AssignRole/UnassignRole methods 40 | return db.Omit("Roles").Save(user).Error 41 | } 42 | 43 | // --- PasswordResetToken Store Methods --- 44 | 45 | // CreatePasswordResetToken creates a new password reset token record 46 | func (db *DB) CreatePasswordResetToken(token *PasswordResetToken) error { 47 | return db.Create(token).Error 48 | } 49 | 50 | // GetPasswordResetToken retrieves a valid, unused password reset token 51 | func (db *DB) GetPasswordResetToken(token string) (*PasswordResetToken, error) { 52 | var resetToken PasswordResetToken 53 | err := db.Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&resetToken).Error 54 | if err != nil { 55 | return nil, err 56 | } 57 | return &resetToken, nil 58 | } 59 | 60 | // MarkPasswordResetTokenAsUsed marks a password reset token as used 61 | func (db *DB) MarkPasswordResetTokenAsUsed(tokenID uint) error { 62 | return db.Model(&PasswordResetToken{}).Where("id = ?", tokenID).Update("used", true).Error 63 | } 64 | -------------------------------------------------------------------------------- /internal/email/mock_email.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/starfleetcptn/gomft/internal/config" 7 | ) 8 | 9 | // MockService implements the email Service for testing purposes 10 | type MockService struct { 11 | SendEmailCalls int 12 | SendPasswordResetEmailCalls int 13 | ReturnError error 14 | } 15 | 16 | // NewMockService creates a new mock email service 17 | func NewMockService() *Service { 18 | // Create minimal config 19 | cfg := &config.Config{ 20 | Email: config.EmailConfig{ 21 | Enabled: false, 22 | }, 23 | BaseURL: "http://localhost:8080", 24 | } 25 | 26 | return &Service{ 27 | Config: cfg, 28 | } 29 | } 30 | 31 | // SendPasswordResetEmail mocks sending a password reset email 32 | func (s *MockService) SendPasswordResetEmail(toEmail, username, resetToken string) error { 33 | return fmt.Errorf("email service is disabled, reset link would be: %s/reset-password?token=%s", 34 | "http://localhost:8080", resetToken) 35 | } 36 | -------------------------------------------------------------------------------- /internal/scheduler/metadata.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/starfleetcptn/gomft/internal/db" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | // MetadataDB defines the database methods needed by MetadataHandler. 12 | // This allows for easier mocking during testing. 13 | type MetadataDB interface { 14 | GetFileMetadataByHash(hash string) (*db.FileMetadata, error) 15 | GetFileMetadataByJobAndName(jobID uint, fileName string) (*db.FileMetadata, error) 16 | } 17 | 18 | // MetadataHandler handles checking file processing history. 19 | type MetadataHandler struct { 20 | db MetadataDB // Use the interface type 21 | logger *Logger // Added logger dependency 22 | } 23 | 24 | // NewMetadataHandler creates a new MetadataHandler. 25 | func NewMetadataHandler(database MetadataDB, logger *Logger) *MetadataHandler { // Accept the interface type 26 | return &MetadataHandler{ 27 | db: database, 28 | logger: logger, 29 | } 30 | } 31 | 32 | // hasFileBeenProcessed checks if a file with the same hash has been processed before. 33 | func (mh *MetadataHandler) hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error) { 34 | if fileHash == "" { 35 | return false, nil, nil 36 | } 37 | 38 | // First try to find by hash (most reliable) 39 | metadata, err := mh.db.GetFileMetadataByHash(fileHash) // Calls the interface method 40 | if err == nil && metadata != nil { 41 | // Optional: Add logging here if needed 42 | mh.logger.LogDebug("Found existing metadata by hash for job %d, hash %s", jobID, fileHash) 43 | return true, metadata, nil 44 | } 45 | // Handle DB errors 46 | if err != nil { 47 | // If the error is specifically "record not found", it means not processed, which is not an error for this function. 48 | if errors.Is(err, gorm.ErrRecordNotFound) { 49 | return false, nil, nil // Not found, no error to return 50 | } 51 | // For any other DB error, log it and return it. 52 | mh.logger.LogError("Error checking metadata by hash for job %d, hash %s: %v", jobID, fileHash, err) 53 | return false, nil, err // Return the actual DB error 54 | } 55 | 56 | // Should not be reached if err is nil and metadata is nil, but return false just in case. 57 | return false, nil, nil 58 | } 59 | 60 | // checkFileProcessingHistory checks processing history for a given file name within a specific job. 61 | func (mh *MetadataHandler) checkFileProcessingHistory(jobID uint, fileName string) (*db.FileMetadata, error) { 62 | // Try to find by job and filename 63 | metadata, err := mh.db.GetFileMetadataByJobAndName(jobID, fileName) // Calls the interface method 64 | if err == nil && metadata != nil { 65 | mh.logger.LogDebug("Found existing metadata by name for job %d, file %s", jobID, fileName) 66 | return metadata, nil 67 | } 68 | 69 | if err != nil { 70 | mh.logger.LogError("Error checking metadata by name for job %d, file %s: %v", jobID, fileName, err) 71 | // Don't return error here, just indicate not found 72 | } 73 | 74 | return nil, fmt.Errorf("no history found for file %s in job %d", fileName, jobID) 75 | } 76 | -------------------------------------------------------------------------------- /internal/scheduler/mock_scheduler.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "github.com/starfleetcptn/gomft/internal/db" 5 | ) 6 | 7 | // MockScheduler is a mock implementation of a scheduler for testing 8 | type MockScheduler struct { 9 | ScheduledJobs map[uint]bool 10 | UnscheduledJobs map[uint]bool 11 | RunJobsNow map[uint]bool 12 | ScheduleJobErr error 13 | RunJobNowErr error 14 | UnscheduleJobCalls int 15 | MultiConfigJobs map[uint][]uint // Track jobs with multiple configs (job ID -> config IDs) 16 | } 17 | 18 | // NewMockScheduler creates a new mock scheduler 19 | func NewMockScheduler() *MockScheduler { 20 | return &MockScheduler{ 21 | ScheduledJobs: make(map[uint]bool), 22 | UnscheduledJobs: make(map[uint]bool), 23 | RunJobsNow: make(map[uint]bool), 24 | MultiConfigJobs: make(map[uint][]uint), 25 | } 26 | } 27 | 28 | // ScheduleJob mocks scheduling a job 29 | func (m *MockScheduler) ScheduleJob(job *db.Job) error { 30 | if m.ScheduleJobErr != nil { 31 | return m.ScheduleJobErr 32 | } 33 | 34 | if job.GetEnabled() { 35 | m.ScheduledJobs[job.ID] = true 36 | delete(m.UnscheduledJobs, job.ID) 37 | } else { 38 | m.UnscheduledJobs[job.ID] = true 39 | delete(m.ScheduledJobs, job.ID) 40 | } 41 | 42 | // Track jobs with multiple configurations 43 | if job.ConfigIDs != "" { 44 | m.MultiConfigJobs[job.ID] = job.GetConfigIDsList() 45 | } 46 | 47 | return nil 48 | } 49 | 50 | // RunJobNow mocks running a job immediately 51 | func (m *MockScheduler) RunJobNow(jobID uint) error { 52 | if m.RunJobNowErr != nil { 53 | return m.RunJobNowErr 54 | } 55 | 56 | m.RunJobsNow[jobID] = true 57 | 58 | // In a real implementation, this would execute the job 59 | // But for testing, we just record that it was called 60 | return nil 61 | } 62 | 63 | // UnscheduleJob mocks unscheduling a job 64 | func (m *MockScheduler) UnscheduleJob(jobID uint) { 65 | m.UnscheduleJobCalls++ 66 | m.UnscheduledJobs[jobID] = true 67 | delete(m.ScheduledJobs, jobID) 68 | delete(m.MultiConfigJobs, jobID) 69 | } 70 | 71 | // Stop mocks stopping the scheduler 72 | func (m *MockScheduler) Stop() { 73 | // Nothing to do 74 | } 75 | 76 | // RotateLogs mocks log rotation 77 | func (m *MockScheduler) RotateLogs() error { 78 | return nil 79 | } 80 | 81 | // IsJobWithMultipleConfigs checks if a job is scheduled with multiple configs 82 | func (m *MockScheduler) IsJobWithMultipleConfigs(jobID uint) bool { 83 | configs, exists := m.MultiConfigJobs[jobID] 84 | return exists && len(configs) > 1 85 | } 86 | 87 | // GetConfigsForJob returns the configs for a job 88 | func (m *MockScheduler) GetConfigsForJob(jobID uint) []uint { 89 | return m.MultiConfigJobs[jobID] 90 | } 91 | -------------------------------------------------------------------------------- /internal/scheduler/scheduler_interface.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "github.com/starfleetcptn/gomft/internal/db" 5 | ) 6 | 7 | // SchedulerInterface defines the interface for job scheduling operations 8 | type SchedulerInterface interface { 9 | // ScheduleJob schedules a job based on its cron expression 10 | ScheduleJob(job *db.Job) error 11 | 12 | // RunJobNow runs a job immediately 13 | RunJobNow(jobID uint) error 14 | 15 | // UnscheduleJob removes a job from the scheduler 16 | UnscheduleJob(jobID uint) 17 | 18 | // Stop stops the scheduler 19 | Stop() 20 | } 21 | -------------------------------------------------------------------------------- /internal/scheduler/utils.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // ProcessOutputPattern processes an output pattern with variables and returns the result 13 | // This function is useful for testing pattern processing in isolation 14 | func ProcessOutputPattern(pattern string, originalFilename string) string { 15 | // Process date variables 16 | dateRegex := regexp.MustCompile(`\${date:([^}]+)}`) 17 | processedPattern := dateRegex.ReplaceAllStringFunc(pattern, func(match string) string { 18 | format := dateRegex.FindStringSubmatch(match)[1] 19 | return time.Now().Format(format) 20 | }) 21 | 22 | // Split the filename and extension 23 | ext := filepath.Ext(originalFilename) 24 | filename := strings.TrimSuffix(originalFilename, ext) 25 | 26 | // Replace filename and extension variables 27 | processedPattern = strings.ReplaceAll(processedPattern, "${filename}", filename) 28 | // Remove leading dot from ext before replacing 29 | processedPattern = strings.ReplaceAll(processedPattern, "${ext}", strings.TrimPrefix(ext, ".")) 30 | 31 | return processedPattern 32 | } 33 | 34 | // createRcloneFilterFile creates a temporary filter file for rclone with rename rules 35 | func createRcloneFilterFile(pattern string) (string, error) { 36 | // Create a temporary file 37 | tmpFile, err := ioutil.TempFile("", "rclone-filter-*.txt") 38 | if err != nil { 39 | return "", fmt.Errorf("failed to create temporary filter file: %v", err) 40 | } 41 | defer tmpFile.Close() 42 | 43 | // Process the pattern to create a rclone filter rule 44 | // First, replace date variables with current date in the specified format 45 | dateRegex := regexp.MustCompile(`\${date:([^}]+)}`) 46 | processedPattern := dateRegex.ReplaceAllStringFunc(pattern, func(match string) string { 47 | format := dateRegex.FindStringSubmatch(match)[1] 48 | return time.Now().Format(format) 49 | }) 50 | 51 | // Replace filename and extension variables with rclone's capture group references 52 | // For rclone rename filters, we need to use {1} for the first capture group, not $1 53 | // See: https://rclone.org/filtering/#rename 54 | 55 | // Extract filename without extension 56 | processedPattern = strings.ReplaceAll(processedPattern, "${filename}", "{1}") 57 | 58 | // Extract extension (with the dot) 59 | processedPattern = strings.ReplaceAll(processedPattern, "${ext}", "{2}") 60 | // Create a rename rule for rclone using the correct syntax: 61 | // - The format for rename filters is: "-- SourceRegexp ReplacementPattern" 62 | // - For files with extension: capture the name and extension separately 63 | rule := fmt.Sprintf("-- (.*)(\\..+)$ %s\n", processedPattern) // Correct escaping for dot 64 | 65 | // Add a fallback rule for files without extension 66 | // Keep [^.] as it correctly excludes literal dot in character class 67 | fallbackRule := fmt.Sprintf("-- ([^.]+)$ %s\n", 68 | strings.ReplaceAll(processedPattern, "{2}", "")) 69 | // Removed duplicate declaration below 70 | 71 | // Write the rules to the file 72 | if _, err := tmpFile.WriteString(rule + fallbackRule); err != nil { 73 | return "", fmt.Errorf("failed to write to filter file: %v", err) 74 | } 75 | 76 | return tmpFile.Name(), nil 77 | } 78 | -------------------------------------------------------------------------------- /internal/testutils/testutils.go: -------------------------------------------------------------------------------- 1 | // Package testutils provides utilities for testing the application 2 | package testutils 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | "time" 9 | 10 | "github.com/glebarez/sqlite" 11 | "github.com/starfleetcptn/gomft/internal/auth" 12 | "github.com/starfleetcptn/gomft/internal/config" 13 | "github.com/starfleetcptn/gomft/internal/db" 14 | "github.com/starfleetcptn/gomft/internal/email" 15 | "github.com/starfleetcptn/gomft/internal/scheduler" 16 | "golang.org/x/crypto/bcrypt" 17 | "gorm.io/gorm" 18 | ) 19 | 20 | // SetupTestDB creates an in-memory SQLite database for testing 21 | func SetupTestDB(t *testing.T) *db.DB { 22 | gormDB, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) 23 | if err != nil { 24 | t.Fatalf("Failed to open in-memory database: %v", err) 25 | } 26 | 27 | // Drop all tables to ensure a clean database 28 | err = gormDB.Migrator().DropTable( 29 | &db.User{}, 30 | &db.PasswordHistory{}, 31 | &db.PasswordResetToken{}, 32 | &db.TransferConfig{}, 33 | &db.Job{}, 34 | &db.JobHistory{}, 35 | &db.FileMetadata{}, 36 | ) 37 | if err != nil { 38 | t.Logf("Warning: Failed to drop tables: %v", err) 39 | } 40 | 41 | // Initialize the database schema 42 | err = gormDB.AutoMigrate( 43 | &db.User{}, 44 | &db.PasswordHistory{}, 45 | &db.PasswordResetToken{}, 46 | &db.TransferConfig{}, 47 | &db.Job{}, 48 | &db.JobHistory{}, 49 | &db.FileMetadata{}, 50 | ) 51 | if err != nil { 52 | t.Fatalf("Failed to migrate database: %v", err) 53 | } 54 | 55 | return &db.DB{DB: gormDB} 56 | } 57 | 58 | // CreateTestUser creates a test user in the database 59 | func CreateTestUser(t *testing.T, database *db.DB, email string, isAdmin bool) *db.User { 60 | // Generate hashed password using bcrypt directly 61 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte("testpassword"), bcrypt.DefaultCost) 62 | if err != nil { 63 | t.Fatalf("Failed to hash password: %v", err) 64 | } 65 | 66 | user := &db.User{ 67 | Email: email, 68 | PasswordHash: string(hashedPassword), 69 | LastPasswordChange: time.Now(), 70 | } 71 | user.SetIsAdmin(isAdmin) 72 | 73 | if err := database.CreateUser(user); err != nil { 74 | t.Fatalf("Failed to create test user: %v", err) 75 | } 76 | 77 | return user 78 | } 79 | 80 | // SetupTestConfig creates a test configuration 81 | func SetupTestConfig(t *testing.T) *config.Config { 82 | tempDir, err := os.MkdirTemp("", "gomft-test-*") 83 | if err != nil { 84 | t.Fatalf("Failed to create temp directory: %v", err) 85 | } 86 | t.Cleanup(func() { 87 | os.RemoveAll(tempDir) 88 | }) 89 | 90 | return &config.Config{ 91 | ServerAddress: ":9090", 92 | DataDir: filepath.Join(tempDir, "data"), 93 | BackupDir: filepath.Join(tempDir, "backups"), 94 | JWTSecret: "test-jwt-secret", 95 | BaseURL: "http://test.example.com", 96 | Email: config.EmailConfig{ 97 | Enabled: false, 98 | Host: "smtp.test.com", 99 | Port: 587, 100 | Username: "test@example.com", 101 | Password: "test-password", 102 | FromEmail: "test@example.com", 103 | FromName: "Test", 104 | EnableTLS: true, 105 | RequireAuth: true, 106 | }, 107 | } 108 | } 109 | 110 | // SetupTestScheduler creates a mock scheduler for testing 111 | func SetupTestScheduler(t *testing.T) *scheduler.Scheduler { 112 | // In a real test, we would create a proper mock scheduler 113 | // For now, we return an empty scheduler 114 | return &scheduler.Scheduler{} 115 | } 116 | 117 | // SetupTestEmailService creates a mock email service for testing 118 | func SetupTestEmailService(t *testing.T) *email.Service { 119 | // In a real test, we would create a proper mock email service 120 | // For now, we return an empty email service 121 | return &email.Service{} 122 | } 123 | 124 | // GenerateTestToken generates a JWT token for testing 125 | func GenerateTestToken(userID uint, isAdmin bool, jwtSecret string) (string, error) { 126 | // In a real application, we would include email, but for testing purposes we can create a fake email 127 | email := "test@example.com" 128 | if isAdmin { 129 | email = "admin@example.com" 130 | } 131 | 132 | // Create token with 1 hour expiry 133 | expirationTime := 1 * time.Hour 134 | return auth.GenerateToken(userID, email, jwtSecret, expirationTime) 135 | } 136 | -------------------------------------------------------------------------------- /internal/web/handlers.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/starfleetcptn/gomft/internal/config" 8 | "github.com/starfleetcptn/gomft/internal/db" 9 | "github.com/starfleetcptn/gomft/internal/email" 10 | "github.com/starfleetcptn/gomft/internal/scheduler" 11 | "github.com/starfleetcptn/gomft/internal/web/handlers" 12 | ) 13 | 14 | // Handler is a wrapper around the handlers package 15 | type Handler struct { 16 | handlers *handlers.Handlers 17 | } 18 | 19 | // Global handlers instance for access from other packages 20 | var globalHandlersInstance *handlers.Handlers 21 | 22 | // GetHandlersInstance returns the global handlers instance and a boolean indicating if it's initialized 23 | func GetHandlersInstance() (*handlers.Handlers, bool) { 24 | return globalHandlersInstance, globalHandlersInstance != nil 25 | } 26 | 27 | // NewHandler creates a new Handler instance that delegates to the handlers package 28 | func NewHandler(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string, dbPath string, backupDir string, cfg *config.Config) (*Handler, error) { 29 | // Create email service instance 30 | emailService := email.NewService(cfg) 31 | 32 | // Use logs directory from config 33 | logsDir := filepath.Join(cfg.DataDir, "logs") 34 | 35 | // Create handlers instance 36 | handlersInstance := handlers.NewHandlers(database, scheduler, jwtSecret, dbPath, backupDir, logsDir, emailService) 37 | 38 | // Store the handlers instance globally 39 | globalHandlersInstance = handlersInstance 40 | 41 | return &Handler{ 42 | handlers: handlersInstance, 43 | }, nil 44 | } 45 | 46 | // InitializeRoutes delegates route registration to the handlers package 47 | func (h *Handler) InitializeRoutes(router *gin.Engine) { 48 | // Register all routes through the handlers package 49 | h.handlers.RegisterRoutes(router) 50 | } 51 | -------------------------------------------------------------------------------- /internal/web/handlers/basic_handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/starfleetcptn/gomft/components" 8 | "github.com/starfleetcptn/gomft/internal/auth" 9 | ) 10 | 11 | // HandleHome handles the GET / route 12 | func (h *Handlers) HandleHome(c *gin.Context) { 13 | // Check for JWT token in cookie 14 | tokenCookie, err := c.Cookie("jwt_token") 15 | if err == nil && tokenCookie != "" { 16 | // Token exists, validate it 17 | claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret) 18 | if err == nil && claims != nil { 19 | // Valid token, redirect to dashboard 20 | c.Redirect(http.StatusFound, "/dashboard") 21 | return 22 | } 23 | } 24 | 25 | // User is not logged in, show home page 26 | components.Home(c.Request.Context()).Render(c, c.Writer) 27 | } -------------------------------------------------------------------------------- /internal/web/handlers/error_handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/starfleetcptn/gomft/components" 8 | ) 9 | 10 | // HandleNotFound handles 404 Not Found errors 11 | func (h *Handlers) HandleNotFound(c *gin.Context, title string, message string) { 12 | if title == "" { 13 | title = "Page Not Found" 14 | } 15 | if message == "" { 16 | message = "The page you are looking for does not exist." 17 | } 18 | h.HandleError(c, 404, title, message, nil) 19 | } 20 | 21 | // HandleBadRequest handles 400 Bad Request errors 22 | func (h *Handlers) HandleBadRequest(c *gin.Context, title, message string) { 23 | h.HandleError(c, 400, title, message, nil) 24 | } 25 | 26 | // HandleUnauthorized handles 401 Unauthorized errors 27 | func (h *Handlers) HandleUnauthorized(c *gin.Context) { 28 | ctx := components.CreateTemplateContext(c) 29 | _ = components.UnauthorizedError(ctx).Render(ctx, c.Writer) 30 | } 31 | 32 | // HandleServerError handles 500 Internal Server errors 33 | func (h *Handlers) HandleServerError(c *gin.Context, err error) { 34 | var details string 35 | if err != nil { 36 | details = err.Error() 37 | } 38 | ctx := components.CreateTemplateContext(c) 39 | _ = components.ServerError(ctx, details).Render(ctx, c.Writer) 40 | } 41 | 42 | // HandleError handles generic errors with custom title and message 43 | func (h *Handlers) HandleError(c *gin.Context, code int, title, message string, err error) { 44 | var details string 45 | if err != nil { 46 | details = err.Error() 47 | } 48 | ctx := components.CreateTemplateContext(c) 49 | _ = components.ErrorPage(ctx, code, title, message, details).Render(ctx, c.Writer) 50 | } 51 | 52 | // RegisterErrorHandlers sets up custom error handlers for the router 53 | func (h *Handlers) RegisterErrorHandlers(router *gin.Engine) { 54 | // Register 404 handler 55 | router.NoRoute(func(c *gin.Context) { 56 | h.HandleNotFound(c, "", "") 57 | }) 58 | 59 | // Register 405 handler (Method Not Allowed) 60 | router.NoMethod(func(c *gin.Context) { 61 | h.HandleError(c, 405, "Method Not Allowed", 62 | fmt.Sprintf("The %s method is not supported for this resource.", c.Request.Method), nil) 63 | }) 64 | 65 | // Register function to recover from panics 66 | router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { 67 | var err error 68 | if rec, ok := recovered.(error); ok { 69 | err = rec 70 | } else { 71 | err = fmt.Errorf("%v", recovered) 72 | } 73 | h.HandleServerError(c, err) 74 | c.Abort() 75 | })) 76 | } 77 | -------------------------------------------------------------------------------- /internal/web/handlers/path_handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | // HandleCheckPath validates if a given path exists and is accessible 12 | func (h *Handlers) HandleCheckPath(c *gin.Context) { 13 | path := c.Query("path") 14 | if path == "" { 15 | c.JSON(http.StatusBadRequest, gin.H{ 16 | "valid": false, 17 | "error": "No path provided", 18 | }) 19 | return 20 | } 21 | 22 | // Clean and resolve the path 23 | path = filepath.Clean(path) 24 | absPath, err := filepath.Abs(path) 25 | if err != nil { 26 | c.JSON(http.StatusOK, gin.H{ 27 | "valid": false, 28 | "error": "Invalid path format", 29 | }) 30 | return 31 | } 32 | 33 | // Check if path exists 34 | info, err := os.Stat(absPath) 35 | if err != nil { 36 | if os.IsNotExist(err) { 37 | c.JSON(http.StatusOK, gin.H{ 38 | "valid": false, 39 | "error": "Path does not exist", 40 | }) 41 | return 42 | } 43 | c.JSON(http.StatusOK, gin.H{ 44 | "valid": false, 45 | "error": "Error accessing path: " + err.Error(), 46 | }) 47 | return 48 | } 49 | 50 | // Check if it's a directory 51 | if !info.IsDir() { 52 | c.JSON(http.StatusOK, gin.H{ 53 | "valid": false, 54 | "error": "Path exists but is not a directory", 55 | }) 56 | return 57 | } 58 | 59 | // Check if we have read access 60 | testFile := filepath.Join(absPath, ".gomft_test") 61 | f, err := os.OpenFile(testFile, os.O_CREATE|os.O_WRONLY, 0666) 62 | if err != nil { 63 | c.JSON(http.StatusOK, gin.H{ 64 | "valid": false, 65 | "error": "Directory exists but is not writable", 66 | }) 67 | return 68 | } 69 | f.Close() 70 | os.Remove(testFile) 71 | 72 | c.JSON(http.StatusOK, gin.H{ 73 | "valid": true, 74 | "error": "", 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /internal/web/handlers/profile_handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/starfleetcptn/gomft/components" 8 | "github.com/starfleetcptn/gomft/internal/db" 9 | ) 10 | 11 | // HandleProfile handles the GET /profile route 12 | func (h *Handlers) HandleProfile(c *gin.Context) { 13 | userID := c.GetUint("userID") 14 | var user db.User 15 | if err := h.DB.First(&user, userID).Error; err != nil { 16 | c.String(http.StatusInternalServerError, "Failed to retrieve user profile") 17 | return 18 | } 19 | components.Profile(c.Request.Context(), user).Render(c, c.Writer) 20 | } 21 | 22 | // HandleUpdateTheme handles the POST /profile/theme route 23 | func (h *Handlers) HandleUpdateTheme(c *gin.Context) { 24 | userID := c.GetUint("userID") 25 | theme := c.PostForm("theme") 26 | 27 | // Validate theme value 28 | validThemes := map[string]bool{ 29 | "light": true, 30 | "dark": true, 31 | "system": true, 32 | } 33 | 34 | if !validThemes[theme] { 35 | c.Status(http.StatusBadRequest) 36 | return 37 | } 38 | 39 | // Update user theme preference 40 | var user db.User 41 | if err := h.DB.First(&user, userID).Error; err != nil { 42 | c.Status(http.StatusInternalServerError) 43 | return 44 | } 45 | 46 | user.Theme = theme 47 | if err := h.DB.Save(&user).Error; err != nil { 48 | c.Status(http.StatusInternalServerError) 49 | return 50 | } 51 | 52 | // Set theme cookie for client-side theme switching 53 | c.SetCookie("theme", theme, 60*60*24*365, "/", "", false, false) 54 | 55 | c.Status(http.StatusOK) 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gomft", 3 | "version": "1.0.0", 4 | "description": "GoMFT static assets bundling", 5 | "type": "module", 6 | "scripts": { 7 | "build": "node build.js", 8 | "watch": "node build.js --watch", 9 | "postinstall": "npm run build" 10 | }, 11 | "dependencies": { 12 | "@fortawesome/fontawesome-free": "^6.4.0", 13 | "alpinejs": "^3.13.5", 14 | "esbuild": "^0.20.1", 15 | "flowbite": "^2.2.1", 16 | "htmx.org": "^1.9.10", 17 | "tailwindcss": "^3.4.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /screenshots/audit.logs.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/audit.logs.gomft.png -------------------------------------------------------------------------------- /screenshots/authentication.providers.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/authentication.providers.gomft.png -------------------------------------------------------------------------------- /screenshots/dashboard.dark.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/dashboard.dark.gomft.png -------------------------------------------------------------------------------- /screenshots/dashboard.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/dashboard.gomft.png -------------------------------------------------------------------------------- /screenshots/database.tools.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/database.tools.gomft.png -------------------------------------------------------------------------------- /screenshots/file.details.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/file.details.gomft.png -------------------------------------------------------------------------------- /screenshots/file.metadata.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/file.metadata.gomft.png -------------------------------------------------------------------------------- /screenshots/job.run.details.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/job.run.details.gomft.png -------------------------------------------------------------------------------- /screenshots/notification.service.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/notification.service.gomft.png -------------------------------------------------------------------------------- /screenshots/notifications.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/notifications.gomft.png -------------------------------------------------------------------------------- /screenshots/role.management.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/role.management.gomft.png -------------------------------------------------------------------------------- /screenshots/scheduled.jobs.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/scheduled.jobs.gomft.png -------------------------------------------------------------------------------- /screenshots/transfer.config.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/transfer.config.gomft.png -------------------------------------------------------------------------------- /screenshots/transfer.history.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/transfer.history.gomft.png -------------------------------------------------------------------------------- /screenshots/user.management.gomft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/screenshots/user.management.gomft.png -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/static/favicon.ico -------------------------------------------------------------------------------- /static/img/authentik.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 23 | 24 | -------------------------------------------------------------------------------- /static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarFleetCPTN/GoMFT/88d0ac815a5c9ab89cd41bd1b85721ac7bc4c55a/static/img/logo.png -------------------------------------------------------------------------------- /static/img/oidc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /static/img/pocket-id.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /static/img/saml.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /static/js/init.js: -------------------------------------------------------------------------------- 1 | // Add script to ensure dark mode is properly applied 2 | document.addEventListener('DOMContentLoaded', function() { 3 | // Apply body dark class when theme changes 4 | const isDark = document.documentElement.classList.contains('dark'); 5 | if (isDark) { 6 | document.body.classList.add('dark'); 7 | 8 | // Also apply to containers 9 | const jobsContainer = document.getElementById('jobs-container'); 10 | const configsContainer = document.getElementById('configs-container'); 11 | 12 | if (jobsContainer) jobsContainer.classList.add('dark'); 13 | if (configsContainer) configsContainer.classList.add('dark'); 14 | } 15 | 16 | // Initialize admin dropdown toggle if available 17 | const adminDropdownToggle = document.querySelector('[data-collapse-toggle="dropdown-settings"]'); 18 | const adminDropdown = document.getElementById('dropdown-settings'); 19 | 20 | if (adminDropdownToggle && adminDropdown) { 21 | // Check if we should show the dropdown (if current page is under admin section) 22 | const currentPath = window.location.pathname; 23 | if (currentPath.startsWith('/admin')) { 24 | adminDropdown.classList.remove('hidden'); 25 | } 26 | 27 | // Add click event listener 28 | adminDropdownToggle.addEventListener('click', function() { 29 | adminDropdown.classList.toggle('hidden'); 30 | }); 31 | } 32 | }); -------------------------------------------------------------------------------- /static/js/vendor.js: -------------------------------------------------------------------------------- 1 | // Import HTMX 2 | import 'htmx.org'; 3 | 4 | // Import Alpine.js 5 | import Alpine from 'alpinejs'; 6 | window.Alpine = Alpine; 7 | 8 | // Import Flowbite 9 | import 'flowbite'; 10 | import 'flowbite/dist/flowbite.css'; 11 | 12 | // Initialize Flowbite components 13 | document.addEventListener('DOMContentLoaded', () => { 14 | // Initialize Alpine.js 15 | Alpine.start(); 16 | 17 | // Initialize Flowbite modals 18 | const modalTriggers = document.querySelectorAll('[data-modal-target]'); 19 | modalTriggers.forEach(trigger => { 20 | const targetId = trigger.getAttribute('data-modal-target'); 21 | const targetModal = document.getElementById(targetId); 22 | if (targetModal) { 23 | // Show modal 24 | trigger.addEventListener('click', () => { 25 | targetModal.classList.remove('hidden'); 26 | targetModal.classList.add('flex'); 27 | // Add backdrop 28 | document.body.style.overflow = 'hidden'; 29 | }); 30 | 31 | // Handle modal hide buttons 32 | const hideButtons = targetModal.querySelectorAll('[data-modal-hide]'); 33 | hideButtons.forEach(button => { 34 | button.addEventListener('click', () => { 35 | targetModal.classList.add('hidden'); 36 | targetModal.classList.remove('flex'); 37 | // Remove backdrop 38 | document.body.style.overflow = ''; 39 | }); 40 | }); 41 | 42 | // Handle clicking outside the modal 43 | targetModal.addEventListener('click', (event) => { 44 | if (event.target === targetModal || event.target.classList.contains('fixed')) { 45 | targetModal.classList.add('hidden'); 46 | targetModal.classList.remove('flex'); 47 | // Remove backdrop 48 | document.body.style.overflow = ''; 49 | } 50 | }); 51 | } 52 | }); 53 | }); -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./components/**/*.{html,js,templ,go}", 5 | "./node_modules/flowbite/**/*.js" 6 | ], 7 | darkMode: 'class', 8 | theme: { 9 | extend: { 10 | colors: { 11 | primary: { 12 | 50: '#f0f9ff', 13 | 100: '#e0f2fe', 14 | 200: '#bae6fd', 15 | 300: '#7dd3fc', 16 | 400: '#38bdf8', 17 | 500: '#0ea5e9', 18 | 600: '#0284c7', 19 | 700: '#0369a1', 20 | 800: '#075985', 21 | 900: '#0c4a6e', 22 | 950: '#082f49', 23 | } 24 | }, 25 | fontFamily: { 26 | sans: ['Inter var', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], 27 | } 28 | } 29 | }, 30 | plugins: [ 31 | require('flowbite/plugin') 32 | ], 33 | } --------------------------------------------------------------------------------