├── .github ├── CODEOWNERS ├── FUNDING.yml ├── codecov.yml ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── go.yml │ ├── release-drafter.yml │ └── release.yml ├── .gitignore ├── .golangci.yaml ├── .mockery.yaml ├── LICENSE ├── README.md ├── SECURITY.md ├── assets ├── http_handler.go ├── misc │ └── systemd │ │ ├── README.md │ │ ├── duckcloud.service │ │ └── var_file.example └── public │ ├── browserconfig.xml │ ├── css │ ├── fontawesome.min.css │ ├── mdb.min.css │ ├── mdb.min.css.map │ ├── sidenav.css │ └── uppy.min.css │ ├── images │ └── favicons │ │ ├── android-chrome-192x192.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg │ ├── js │ ├── file-upload.mjs │ ├── libs │ │ ├── clipboard.min.js │ │ ├── clipboard.min.js.map │ │ ├── htmx.min.js │ │ ├── mdb.es.min.js │ │ ├── mdb.es.min.js.map │ │ ├── response-targets.js │ │ ├── uppy.min.mjs │ │ └── uppy.min.mjs.map │ └── setup.mjs │ ├── site.webmanifest │ └── webfonts │ ├── fa-brands-400.ttf │ ├── fa-brands-400.woff2 │ ├── fa-regular-400.ttf │ ├── fa-regular-400.woff2 │ ├── fa-solid-900.ttf │ ├── fa-solid-900.woff2 │ ├── fa-v4compatibility.ttf │ └── fa-v4compatibility.woff2 ├── cmd └── duckcloud │ ├── commands │ ├── config.go │ ├── run.go │ └── run_test.go │ └── main.go ├── docs └── index.md ├── go.mod ├── go.sum └── internal ├── migrations ├── 000001_init-users-table.down.sql ├── 000001_init-users-table.up.sql ├── 000002_init-clients-table.down.sql ├── 000002_init-clients-table.up.sql ├── 000003_init-codes-table.down.sql ├── 000003_init-codes-table.up.sql ├── 000004_init-oauth_sessions-table.down.sql ├── 000004_init-oauth_sessions-table.up.sql ├── 000005_init-web_sessions-table.down.sql ├── 000005_init-web_sessions-table.up.sql ├── 000006_init-oauth_consents-table.down.sql ├── 000006_init-oauth_consents-table.up.sql ├── 000007_init-fs_inodes-table.down.sql ├── 000007_init-fs_inodes-table.up.sql ├── 000008_init-dav_sessions-table.down.sql ├── 000008_init-dav_sessions-table.up.sql ├── 000009_init-spaces-table.down.sql ├── 000009_init-spaces-table.up.sql ├── 000010_init-config-table.down.sql ├── 000010_init-config-table.up.sql ├── 000011_init-tasks-table.down.sql ├── 000011_init-tasks-table.up.sql ├── 000012_init-files-table.down.sql ├── 000012_init-files-table.up.sql ├── 000013_init-stats-table.down.sql ├── 000013_init-stats-table.up.sql ├── migrations.go └── migrations_test.go ├── server ├── bootstrap.go ├── bootstrap_test.go ├── run.go ├── start.go └── start_test.go ├── service ├── config │ ├── init.go │ ├── integration_test.go │ ├── model.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ ├── storage_sql_test.go │ └── testdata │ │ ├── cert.pem │ │ └── key.pem ├── dav │ ├── http_handler.go │ └── webdav │ │ ├── files.go │ │ ├── internal │ │ └── xml │ │ │ ├── README │ │ │ ├── marshal.go │ │ │ ├── read.go │ │ │ ├── typeinfo.go │ │ │ └── xml.go │ │ ├── litmus_test.go │ │ ├── prop.go │ │ ├── prop_test.go │ │ ├── utils_test.go │ │ ├── webdav.go │ │ ├── webdav_test.go │ │ ├── xml.go │ │ └── xml_test.go ├── davsessions │ ├── init.go │ ├── model.go │ ├── model_examples.go │ ├── model_helper.go │ ├── model_test.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ └── storage_sql_test.go ├── dfs │ ├── init.go │ ├── integration_test.go │ ├── model.go │ ├── model_examples.go │ ├── model_helper.go │ ├── model_test.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ ├── storage_sql_test.go │ ├── task_fsgc.go │ ├── task_fsgc_test.go │ ├── task_fsmove.go │ ├── task_fsmove_test.go │ ├── task_fsrefreshsize.go │ ├── task_fsrefreshsize_test.go │ ├── task_remove_duplicate_file.go │ ├── task_remove_duplicate_file_test.go │ ├── utils.go │ └── utils_test.go ├── files │ ├── init.go │ ├── model.go │ ├── model_examples.go │ ├── model_helper.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ └── storage_sql_test.go ├── masterkey │ ├── http_middleware.go │ ├── http_middleware_test.go │ ├── init.go │ ├── integration_test.go │ ├── service.go │ ├── service_mock.go │ └── service_test.go ├── oauth2 │ ├── client_storage.go │ ├── http_handler.go │ ├── init.go │ ├── model.go │ ├── service.go │ ├── service_mock.go │ └── token_storage.go ├── oauthclients │ ├── init.go │ ├── model.go │ ├── model_example.go │ ├── model_helper.go │ ├── model_test.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ └── storage_sql_test.go ├── oauthcodes │ ├── init.go │ ├── model.go │ ├── model_helper.go │ ├── model_test.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ └── storage_sql_test.go ├── oauthconsents │ ├── init.go │ ├── model.go │ ├── model_examples.go │ ├── model_helper.go │ ├── model_test.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ └── storage_sql_test.go ├── oauthsessions │ ├── init.go │ ├── model.go │ ├── model_example.go │ ├── model_helper.go │ ├── model_test.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage.go │ ├── storage_mock.go │ └── storage_test.go ├── spaces │ ├── init.go │ ├── model.go │ ├── model_example.go │ ├── model_helper.go │ ├── model_test.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ └── storage_sql_test.go ├── stats │ ├── init.go │ ├── model.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ └── storage_sql_test.go ├── tasks │ ├── internal │ │ ├── model │ │ │ └── model.go │ │ └── taskstorage │ │ │ ├── storage_mock.go │ │ │ ├── storage_sql.go │ │ │ └── storage_sql_test.go │ ├── runner │ │ ├── init.go │ │ ├── service.go │ │ ├── service_mock.go │ │ └── task_runner_mock.go │ └── scheduler │ │ ├── init.go │ │ ├── model.go │ │ ├── model_test.go │ │ ├── service.go │ │ ├── service_mock.go │ │ └── service_test.go ├── users │ ├── init.go │ ├── model.go │ ├── model_examples.go │ ├── model_helper.go │ ├── model_test.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ └── storage_sql_test.go ├── utilities │ ├── http.go │ └── http_test.go └── websessions │ ├── init.go │ ├── model.go │ ├── model_example.go │ ├── model_helper.go │ ├── model_test.go │ ├── service.go │ ├── service_mock.go │ ├── service_test.go │ ├── storage_mock.go │ ├── storage_sql.go │ └── storage_sql_test.go ├── tasks ├── init.go ├── spacecreate.go ├── spacecreate_test.go ├── usercreate.go ├── usercreate_test.go ├── userdelete.go └── userdelete_test.go ├── tools ├── buildinfos │ └── buildinfos.go ├── clock │ ├── clock_mock.go │ ├── init.go │ ├── init_test.go │ ├── stub.go │ ├── stub_test.go │ ├── time.go │ └── time_test.go ├── cron │ ├── cron.go │ ├── cron_runner_mock.go │ └── cron_test.go ├── default.go ├── default_test.go ├── errs │ ├── errors.go │ └── errors_test.go ├── init.go ├── init_test.go ├── logger │ ├── fx.go │ ├── fx_test.go │ ├── init.go │ ├── router.go │ └── router_test.go ├── mock.go ├── mock_test.go ├── password │ ├── default.go │ ├── default_test.go │ ├── init.go │ └── password_mock.go ├── ptr │ └── ptr.go ├── response │ ├── default.go │ ├── default_test.go │ ├── init.go │ ├── init_test.go │ └── writer_mock.go ├── router │ ├── http_router.go │ └── middlewares.go ├── secret │ ├── key.go │ ├── key_sealed.go │ ├── key_sealed_test.go │ ├── key_test.go │ ├── text.go │ └── text_test.go ├── sqlstorage │ ├── client.go │ ├── client_test.go │ ├── init.go │ ├── paginate.go │ ├── paginate_test.go │ ├── test_client.go │ ├── test_client_test.go │ ├── time.go │ └── time_test.go ├── startutils │ ├── get-free-port.go │ └── start_test_server.go └── uuid │ ├── default.go │ ├── default_test.go │ ├── init.go │ ├── init_test.go │ ├── service_mock.go │ └── stub.go └── web ├── auth ├── page_consent.go ├── page_consent_test.go ├── page_login.go ├── page_login_test.go ├── page_masterpassword_ask.go ├── page_masterpassword_ask_test.go ├── page_masterpassword_register.go ├── page_masterpassword_register_test.go ├── utils.go └── utils_test.go ├── browser ├── modal_create_dir.go ├── modal_create_dir_test.go ├── modal_move.go ├── modal_move_test.go ├── modal_rename.go ├── modal_rename_test.go ├── page_browser.go ├── page_browser_test.go └── utils.go ├── home.go ├── home_test.go ├── html ├── templates │ ├── auth │ │ ├── layout.html │ │ ├── page_consent.html │ │ ├── page_error.html │ │ ├── page_login.html │ │ ├── page_masterpassword_ask.html │ │ ├── page_masterpassword_register.html │ │ ├── templates.go │ │ └── templates_test.go │ ├── browser │ │ ├── breadcrumb.html │ │ ├── layout.html │ │ ├── modal_create_dir.html │ │ ├── modal_move.html │ │ ├── modal_move_rows.html │ │ ├── modal_rename.html │ │ ├── page.html │ │ ├── rows.html │ │ ├── templates.go │ │ └── templates_test.go │ ├── header.html │ ├── home │ │ ├── 401.html │ │ ├── 500.html │ │ ├── layout.html │ │ ├── page.html │ │ ├── templates.go │ │ └── templates_test.go │ └── settings │ │ ├── layout.html │ │ ├── security │ │ ├── page.html │ │ ├── password-form.html │ │ ├── templates.go │ │ ├── templates_test.go │ │ ├── webdav-form.html │ │ └── webdav-result.html │ │ ├── spaces │ │ ├── modal_create_space.html │ │ ├── page.html │ │ ├── templates.go │ │ ├── templates_test.go │ │ └── user_selection.html │ │ └── users │ │ ├── page.html │ │ ├── registration-form.html │ │ ├── templates.go │ │ └── templates_test.go ├── writer.go └── writer_mock.go └── settings ├── page_security.go ├── page_security_test.go ├── page_spaces.go ├── page_spaces_test.go ├── page_users.go ├── page_users_test.go ├── redirections.go └── redirections_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in the repo. 5 | * Peltoche 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: @Peltoche 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | github_checks: 2 | annotations: false 3 | ignore: 4 | - "**/*_mock.go" 5 | - "**/init.go" 6 | - internal/service/dav/webdav/internal 7 | coverage: 8 | status: 9 | project: 10 | default: 11 | informational: true 12 | patch: 13 | default: 14 | informational: true 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'Coming soon' 2 | tag-template: 'latest' 3 | categories: 4 | - title: '🚨 **Breaking Changes**' 5 | labels: 6 | - 'breaking-change' 7 | - title: '🚀 Features' 8 | labels: 9 | - 'feature' 10 | - title: '🐛 Bug Fixes' 11 | labels: 12 | - 'fix' 13 | - 'bug' 14 | - title: '🧰 Maintenance' 15 | labels: 16 | - 'chore' 17 | - 'dependencies' 18 | - title: '📚 Documentation' 19 | labels: 20 | - 'documentation' 21 | change-template: '- $TITLE (#$NUMBER) @$AUTHOR' 22 | sort-by: title 23 | sort-direction: ascending 24 | branches: 25 | - develop 26 | exclude-labels: 27 | - 'skip-changelog' 28 | no-changes-template: 'This release contains minor changes and bugfixes.' 29 | template: | 30 | # Release Notes 31 | 32 | $CHANGES 33 | 34 | 🎉 **Thanks to all contributors helping with this release!** 🎉 35 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - master 8 | # pull_request event is required only for autolabeler 9 | pull_request: 10 | # Only following types are handled by the action, but one can default to all as well 11 | types: [opened, reopened, synchronize] 12 | # pull_request_target event is required for autolabeler to support PRs from forks 13 | # pull_request_target: 14 | # types: [opened, reopened, synchronize] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | update_release_draft: 21 | permissions: 22 | # write permission is required to create a github release 23 | contents: write 24 | # write permission is required for autolabeler 25 | # otherwise, read permission is required at least 26 | pull-requests: write 27 | runs-on: ubuntu-latest 28 | steps: 29 | # Drafts your next Release notes as Pull Requests are merged into "master" 30 | - uses: release-drafter/release-drafter@v5 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 'Release' 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | permissions: 8 | contents: write 9 | packages: write 10 | 11 | jobs: 12 | releases-matrix: 13 | name: Release Go Binary 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | # build and publish in parallel: linux/amd64, linux/arm64, windows/amd64, darwin/amd64, darwin/arm64 18 | goos: [linux, windows, darwin] 19 | goarch: [amd64, arm64] 20 | exclude: 21 | - goarch: arm64 22 | goos: windows 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Get current date 26 | id: date 27 | run: echo "::set-output name=date::$(date --utc -Iminutes)" 28 | - uses: wangyoucao577/go-release-action@v1 29 | with: 30 | github_token: ${{ secrets.GITHUB_TOKEN }} 31 | goos: ${{ matrix.goos }} 32 | goarch: ${{ matrix.goarch }} 33 | goversion: 1.22.0 34 | project_path: "./cmd/duckcloud" 35 | binary_name: "duckcloud" 36 | ldflags: "-X github.com/theduckcompany/duckcloud/internal/tools/buildinfos.version=${{github.ref_name}} \ 37 | -X github.com/theduckcompany/duckcloud/internal/tools/buildinfos.buildTime=${{ steps.date.outputs.date }} \ 38 | -X github.com/theduckcompany/duckcloud/internal/tools/buildinfos.isRelease=true" 39 | extra_files: LICENSE README.md 40 | overwrite: TRUE 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go ### 2 | # If you prefer the allow list template instead of the deny list, see community template: 3 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 4 | # 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | /duckcloud 12 | 13 | *.log 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | -------------------------------------------------------------------------------- /.mockery.yaml: -------------------------------------------------------------------------------- 1 | inpackage: True 2 | recursive: True 3 | case: underscore 4 | inpackage-suffix: True 5 | log-level: error 6 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | This project doesn't use the Semantic Versioning but the CalVer format. There 6 | is not major or minor versions as we don't expect any breaking changes. 7 | 8 | This means that we expect that everybody use the latest version with an 9 | automated update if possible. 10 | 11 | ## Reporting a Vulnerability 12 | 13 | Please send me directly an email to the adress indicated [on my github profile](https://github.com/Peltoche) 14 | -------------------------------------------------------------------------------- /assets/http_handler.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "net/http" 7 | "os" 8 | "path" 9 | "strings" 10 | "time" 11 | 12 | "github.com/go-chi/chi/v5" 13 | "github.com/theduckcompany/duckcloud/internal/tools/router" 14 | ) 15 | 16 | //go:embed public 17 | var staticsFS embed.FS 18 | 19 | type Config struct { 20 | HotReload bool `json:"hotReload"` 21 | } 22 | 23 | type HTTPHandler struct { 24 | cfg Config 25 | assetFS http.FileSystem 26 | startDate time.Time 27 | } 28 | 29 | func NewHTTPHandler(cfg Config) *HTTPHandler { 30 | var assetFS http.FileSystem 31 | 32 | switch cfg.HotReload { 33 | case true: 34 | assetFS = http.FS(os.DirFS("./assets/public")) 35 | case false: 36 | memFS, _ := fs.Sub(staticsFS, "public") 37 | assetFS = http.FS(memFS) 38 | } 39 | 40 | return &HTTPHandler{cfg, assetFS, time.Now()} 41 | } 42 | 43 | // Register the http endpoints into the given mux server. 44 | func (h *HTTPHandler) Register(r chi.Router, _ *router.Middlewares) { 45 | r.Get("/assets/*", h.handleAsset) 46 | } 47 | 48 | func (h *HTTPHandler) handleAsset(w http.ResponseWriter, r *http.Request) { 49 | assetPath := strings.TrimPrefix(r.URL.Path, "/assets") 50 | _, fileName := path.Split(assetPath) 51 | 52 | f, err := h.assetFS.Open(assetPath) 53 | if err != nil { 54 | w.WriteHeader(http.StatusNotFound) 55 | return 56 | } 57 | 58 | var lastModified time.Time 59 | if h.cfg.HotReload { 60 | // For the cache validation in dev mod 61 | w.Header().Add("Cache-Control", "no-cache") 62 | fileInfo, err := f.Stat() 63 | if err != nil { 64 | w.WriteHeader(http.StatusBadRequest) 65 | return 66 | } 67 | 68 | lastModified = fileInfo.ModTime() 69 | } else { 70 | // Expires header is for HTTP/1.1 and Cache-Controle is for the newer HTTP versions. 71 | w.Header().Add("Expires", time.Now().Add(365*24*time.Hour).UTC().Format(http.TimeFormat)) 72 | w.Header().Add("Cache-Control", "max-age=31536000") 73 | lastModified = h.startDate 74 | } 75 | 76 | http.ServeContent(w, r, fileName, lastModified, f) 77 | } 78 | -------------------------------------------------------------------------------- /assets/misc/systemd/README.md: -------------------------------------------------------------------------------- 1 | # Systemd configuration 2 | 3 | This folder contains an example of unit file that can be used both by a user or by a package manager. 4 | 5 | The `duckcloud.service` file contains all the generic configurations, like the sandboxing rules and all the necessary 6 | restrictions to run Duckcloud securely. Its content is susceptible to change with each version so the package managers 7 | should package this file with the binary at each versions and a user should update this file after each update. 8 | 9 | This unit file assumes that: 10 | - A user name `duckcloud` have been created. 11 | - A file `/etc/duckcloud/var_file` exists with the `DATADIR` env variable setup. You can find [an example file here](./var_file.example). 12 | - The `DATADIR` variable content is a path pointing to a folder owned by the `duckcloud` user. 13 | -------------------------------------------------------------------------------- /assets/misc/systemd/var_file.example: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | # Specify the data folder path. 5 | DUCKCLOUD_FOLDER=/usr/share/duckcloud 6 | -------------------------------------------------------------------------------- /assets/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/public/css/sidenav.css: -------------------------------------------------------------------------------- 1 | #main { 2 | padding-left: 240px; 3 | } 4 | 5 | #toggler { 6 | display: none; 7 | 8 | i { 9 | font-size: 2rem; 10 | } 11 | } 12 | 13 | #modal-target { 14 | display: none 15 | } 16 | 17 | /* #navbarDropdownMenuLink i { 18 | font-size: 1.8rem; 19 | } */ 20 | 21 | @media (max-width: 960px) { 22 | #main { 23 | padding-left: 0px; 24 | } 25 | 26 | #toggler { 27 | display: unset; 28 | } 29 | 30 | .sidenav[data-mdb-hidden='false'] { 31 | transform: translateX(-100%); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /assets/public/images/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/images/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/public/images/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/images/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/public/images/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/images/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /assets/public/images/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/images/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /assets/public/images/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/images/favicons/favicon.ico -------------------------------------------------------------------------------- /assets/public/images/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/images/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /assets/public/js/file-upload.mjs: -------------------------------------------------------------------------------- 1 | import { Uppy, XHRUpload, StatusBar } from "/assets/js/libs/uppy.min.mjs" 2 | 3 | export function setupUploadButton() { 4 | 5 | let client = new Uppy().use(XHRUpload, { 6 | endpoint: '/browser/upload', 7 | allowMultipleUploadBatches: true 8 | }) 9 | 10 | const folderPath = document.getElementById("folder-path-meta") 11 | const spaceID = document.getElementById("space-id-meta") 12 | 13 | client.setMeta({ rootPath: folderPath.value }) 14 | client.setMeta({ spaceID: spaceID.value }) 15 | client.use(StatusBar, { target: '#status-bar' }); 16 | 17 | client.on('complete', (result) => { 18 | htmx.trigger("body", "refreshFolder"); 19 | }); 20 | 21 | document.getElementById('upload-file-btn'). 22 | addEventListener("click", (e) => { 23 | var input = document.createElement('input'); 24 | input.type = 'file'; 25 | input.multiple = true 26 | 27 | input.onchange = e => { 28 | for (const file of e.target.files) { 29 | client.addFile(file) 30 | } 31 | 32 | client.upload() 33 | } 34 | 35 | input.click(); 36 | }) 37 | 38 | document.getElementById('upload-folder-btn'). 39 | addEventListener("click", (e) => { 40 | var input = document.createElement('input'); 41 | input.type = 'file'; 42 | input.multiple = true 43 | input.webkitdirectory = true 44 | input.mozdirectory = true 45 | input.directory = true 46 | 47 | input.onchange = e => { 48 | for (const file of e.target.files) { 49 | client.addFile({ 50 | name: file.webkitRelativePath, 51 | data: file, 52 | type: file.type, 53 | source: 'Local', 54 | isRemote: false, 55 | }) 56 | } 57 | 58 | client.upload() 59 | } 60 | 61 | input.click(); 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /assets/public/js/setup.mjs: -------------------------------------------------------------------------------- 1 | 2 | import { Sidenav, Datatable, Dropdown, Select } from "/assets/js/libs/mdb.es.min.js"; 3 | 4 | export function SetupSideNav() { 5 | const sidenav = document.getElementById("main-sidenav"); 6 | 7 | let innerWidth = null; 8 | 9 | const setMode = (e) => { 10 | const sidenavInstance = Sidenav.getOrCreateInstance(sidenav); 11 | // Check necessary for Android devices 12 | if (window.innerWidth === innerWidth) { 13 | return; 14 | } 15 | 16 | innerWidth = window.innerWidth; 17 | 18 | if (window.innerWidth < 960) { 19 | sidenavInstance.changeMode("over"); 20 | sidenavInstance.hide(); 21 | } else { 22 | sidenavInstance.changeMode("side"); 23 | sidenavInstance.show(); 24 | } 25 | }; 26 | 27 | setMode(); 28 | 29 | // Event listeners 30 | window.addEventListener("resize", setMode); 31 | } 32 | 33 | 34 | export function SetupBoostrapElems() { 35 | // Make all the selects pretty even with the dynamic content 36 | document.body.addEventListener("htmx:afterSwap", function(evt) { 37 | document.querySelectorAll('.select').forEach((select) => { 38 | Select.getOrCreateInstance(select); 39 | }); 40 | 41 | document.querySelectorAll('.dropdown').forEach((dropdown) => { 42 | Dropdown.getOrCreateInstance(dropdown); 43 | }); 44 | 45 | document.querySelectorAll('.datatable').forEach((datatable) => { 46 | Datatable.getOrCreateInstance(datatable); 47 | }); 48 | }) 49 | } 50 | 51 | -------------------------------------------------------------------------------- /assets/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DuckCloud", 3 | "short_name": "DuckCloud", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "theme_color": "#ffffff", 12 | "background_color": "#ffffff", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /assets/public/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /assets/public/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /assets/public/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /assets/public/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /assets/public/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /assets/public/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /assets/public/webfonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/webfonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /assets/public/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/assets/public/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /cmd/duckcloud/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/theduckcompany/duckcloud/cmd/duckcloud/commands" 8 | "github.com/theduckcompany/duckcloud/internal/tools/buildinfos" 9 | ) 10 | 11 | const binaryName = "duckcloud" 12 | 13 | type exitCode int 14 | 15 | const ( 16 | exitOK exitCode = 0 17 | exitError exitCode = 1 18 | ) 19 | 20 | func main() { 21 | code := mainRun() 22 | os.Exit(int(code)) 23 | } 24 | 25 | func mainRun() exitCode { 26 | cmd := &cobra.Command{ 27 | Use: binaryName, 28 | Short: "Manage your duckcloud instance in your terminal.", 29 | Version: buildinfos.Version(), 30 | } 31 | 32 | // Subcommands 33 | cmd.AddCommand(commands.NewRunCmd(binaryName)) 34 | 35 | err := cmd.Execute() 36 | if err != nil { 37 | return exitError 38 | } 39 | 40 | return exitOK 41 | } 42 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to the Duckcloud documentation 2 | 3 | This is really empty at the moment :/ 4 | -------------------------------------------------------------------------------- /internal/migrations/000001_init-users-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | 3 | DROP INDEX IF EXISTS idx_users_id; 4 | DROP INDEX IF EXISTS idx_users_username; 5 | -------------------------------------------------------------------------------- /internal/migrations/000001_init-users-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | "id" TEXT NOT NULL, 3 | "username" TEXT NOT NULL, 4 | "admin" INTEGER NOT NULL, 5 | "password" TEXT NOT NULL, 6 | "status" TEXT NOT NULL, 7 | "password_changed_at" TEXT NOT NULL, 8 | "created_at" TEXT NOT NULL, 9 | "created_by" TEXT NOT NULL 10 | ) STRICT; 11 | 12 | CREATE UNIQUE INDEX IF NOT EXISTS idx_users_id ON users(id); 13 | CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username); 14 | -------------------------------------------------------------------------------- /internal/migrations/000002_init-clients-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS oauth_clients; 2 | 3 | DROP INDEX IF EXISTS idx_oauth_clients_id; 4 | -------------------------------------------------------------------------------- /internal/migrations/000002_init-clients-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS oauth_clients ( 2 | "id" TEXT NOT NULL, 3 | "name" TEXT NOT NULL, 4 | "secret" TEXT NOT NULL, 5 | "redirect_uri" TEXT NOT NULL, 6 | "user_id" TEXT, 7 | "created_at" TEXT NOT NULL, 8 | "scopes" TEXT NOT NULL, 9 | "is_public" INTEGER NOT NULL, 10 | "skip_validation" INTEGER NOT NULL, 11 | FOREIGN KEY(user_id) REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT 12 | ) STRICT; 13 | 14 | CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_id ON oauth_clients(id); 15 | -------------------------------------------------------------------------------- /internal/migrations/000003_init-codes-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS oauth_codes; 2 | 3 | DROP INDEX IF EXISTS idx_oauth_codes_code; 4 | -------------------------------------------------------------------------------- /internal/migrations/000003_init-codes-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS oauth_codes ( 2 | "code" TEXT NOT NULL, 3 | "created_at" TEXT NOT NULL, 4 | "expires_at" TEXT NOT NULL, 5 | "user_id" TEXT NOT NULL, 6 | "client_id" TEXT NOT NULL, 7 | "redirect_uri" TEXT NOT NULL, 8 | "challenge" TEXT DEFAULT NULL, 9 | "challenge_method" TEXT DEFAULT NULL, 10 | "scope" TEXT NOT NULL, 11 | FOREIGN KEY(user_id) REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, 12 | FOREIGN KEY(client_id) REFERENCES oauth_clients(id) ON UPDATE RESTRICT ON DELETE RESTRICT 13 | ) STRICT; 14 | 15 | CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_codes_code ON oauth_codes(code); 16 | -------------------------------------------------------------------------------- /internal/migrations/000004_init-oauth_sessions-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS oauth_sessions; 2 | 3 | DROP INDEX IF EXISTS idx_oauth_sessions_access_token; 4 | DROP INDEX IF EXISTS idx_oauth_sessions_refresh_token; 5 | DROP INDEX IF EXISTS idx_oauth_sessions_user_id; 6 | -------------------------------------------------------------------------------- /internal/migrations/000004_init-oauth_sessions-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS oauth_sessions ( 2 | "access_token" TEXT NOT NULL, 3 | "access_created_at" TEXT NOT NULL, 4 | "access_expires_at" TEXT NOT NULL, 5 | "refresh_token" TEXT NOT NULL, 6 | "refresh_created_at" TEXT NOT NULL, 7 | "refresh_expires_at" TEXT NOT NULL, 8 | "user_id" TEXT NOT NULL, 9 | "client_id" TEXT NOT NULL, 10 | "scope" TEXT NOT NULL, 11 | FOREIGN KEY(user_id) REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, 12 | FOREIGN KEY(client_id) REFERENCES oauth_clients(id) ON UPDATE RESTRICT ON DELETE RESTRICT 13 | ) STRICT; 14 | 15 | CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_sessions_access_token ON oauth_sessions(access_token); 16 | CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_sessions_refresh_token ON oauth_sessions(refresh_token); 17 | CREATE INDEX IF NOT EXISTS idx_oauth_sessions_user_id ON oauth_sessions(user_id); 18 | -------------------------------------------------------------------------------- /internal/migrations/000005_init-web_sessions-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS web_sessions; 2 | 3 | DROP INDEX IF EXISTS idx_web_sessions_token; 4 | DROP INDEX IF EXISTS idx_web_sessions_user_id; 5 | -------------------------------------------------------------------------------- /internal/migrations/000005_init-web_sessions-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS web_sessions ( 2 | "token" TEXT NOT NULL, 3 | "user_id" TEXT NOT NULL, 4 | "ip" TEXT NOT NULL, 5 | "device" TEXT NOT NULL, 6 | "created_at" TEXT NOT NULL, 7 | FOREIGN KEY(user_id) REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT 8 | ) STRICT; 9 | 10 | CREATE UNIQUE INDEX IF NOT EXISTS idx_web_sessions_token ON web_sessions(token); 11 | CREATE INDEX IF NOT EXISTS idx_web_sessions_user_id ON web_sessions(user_id); 12 | -------------------------------------------------------------------------------- /internal/migrations/000006_init-oauth_consents-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS oauth_sessions; 2 | 3 | DROP INDEX IF EXISTS idx_oauth_consents_id; 4 | DROP INDEX IF EXISTS idx_oauth_consents_user_id; 5 | -------------------------------------------------------------------------------- /internal/migrations/000006_init-oauth_consents-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS oauth_consents ( 2 | "id" TEXT NOT NULL, 3 | "user_id" TEXT NOT NULL, 4 | "client_id" TEXT NOT NULL, 5 | "scopes" TEXT NOT NULL, 6 | "session_token" TEXT NOT NULL, 7 | "created_at" TEXT NOT NULL, 8 | FOREIGN KEY(user_id) REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, 9 | FOREIGN KEY(client_id) REFERENCES oauth_clients(id) ON UPDATE RESTRICT ON DELETE RESTRICT 10 | ) STRICT; 11 | 12 | CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_consents_id ON oauth_consents(id); 13 | CREATE INDEX IF NOT EXISTS idx_oauth_consents_user_id ON oauth_consents(user_id); 14 | -------------------------------------------------------------------------------- /internal/migrations/000007_init-fs_inodes-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS fs_inodes; 2 | 3 | DROP INDEX IF EXISTS idx_fs_inodes_id; 4 | DROP INDEX IF EXISTS idx_fs_inodes_name; 5 | DROP INDEX IF EXISTS idx_fs_inodes_deleted; 6 | -------------------------------------------------------------------------------- /internal/migrations/000007_init-fs_inodes-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS fs_inodes ( 2 | "id" TEXT NOT NULL, 3 | "parent" TEXT DEFAULT NULL, 4 | "name" TEXT NOT NULL, 5 | "size" INTEGER NOT NULL, 6 | "space_id" TEXT NOT NULL, 7 | "file_id" TEXT DEFAULT NULL, 8 | "last_modified_at" TEXT NOT NULL, 9 | "created_at" TEXT NOT NULL, 10 | "created_by" TEXT NOT NULL, 11 | "deleted_at" TEXT DEFAULT NULL, 12 | FOREIGN KEY(space_id) REFERENCES spaces(id) ON UPDATE RESTRICT ON DELETE RESTRICT, 13 | FOREIGN KEY(file_id) REFERENCES files(id) ON UPDATE RESTRICT ON DELETE RESTRICT 14 | FOREIGN KEY(parent) REFERENCES fs_inodes(id) ON UPDATE RESTRICT ON DELETE RESTRICT 15 | ); 16 | 17 | CREATE UNIQUE INDEX IF NOT EXISTS idx_fs_inodes_id ON fs_inodes(id); 18 | CREATE INDEX IF NOT EXISTS idx_fs_inodes_parent_name ON fs_inodes(parent, name); 19 | CREATE INDEX IF NOT EXISTS idx_fs_inodes_deleted ON fs_inodes(deleted_at); 20 | CREATE INDEX IF NOT EXISTS idx_fs_inodes_file_id ON fs_inodes(file_id); 21 | -------------------------------------------------------------------------------- /internal/migrations/000008_init-dav_sessions-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS dav_sessions; 2 | 3 | DROP INDEX IF NOT EXISTS idx_dav_sessions_id; 4 | DROP INDEX IF NOT EXISTS idx_dav_sessions_user_id; 5 | DROP INDEX IF NOT EXISTS idx_dav_sessions_username_password; 6 | -------------------------------------------------------------------------------- /internal/migrations/000008_init-dav_sessions-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS dav_sessions ( 2 | "id" TEXT NOT NULL, 3 | "username" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "password" TEXT NOT NULL, 6 | "user_id" TEXT NOT NULL, 7 | "space_id" TEXT NOT NULL, 8 | "created_at" TEXT NOT NULL, 9 | FOREIGN KEY(user_id) REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT 10 | FOREIGN KEY(space_id) REFERENCES spaces(id) ON UPDATE RESTRICT ON DELETE RESTRICT 11 | ) STRICT; 12 | 13 | CREATE UNIQUE INDEX IF NOT EXISTS idx_dav_sessions_id ON dav_sessions(id); 14 | CREATE INDEX IF NOT EXISTS idx_dav_sessions_user_id ON dav_sessions(user_id); 15 | CREATE INDEX IF NOT EXISTS idx_dav_sessions_username_password ON dav_sessions(username, password); 16 | -------------------------------------------------------------------------------- /internal/migrations/000009_init-spaces-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS spaces; 2 | 3 | DROP INDEX IF EXISTS idx_spaces_id; 4 | DROP INDEX IF EXISTS idx_spaces_root_fs; 5 | -------------------------------------------------------------------------------- /internal/migrations/000009_init-spaces-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS spaces ( 2 | "id" TEXT NOT NULL, 3 | "name" TEXT NOT NULL, 4 | "owners" TEXT NOT NULL, 5 | "created_at" TEXT NOT NULL, 6 | "created_by" TEXT NOT NULL 7 | ) STRICT; 8 | 9 | CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_id ON spaces(id); 10 | -------------------------------------------------------------------------------- /internal/migrations/000010_init-config-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS config; 2 | 3 | DROP INDEX IF EXISTS idx_config_key; 4 | 5 | -------------------------------------------------------------------------------- /internal/migrations/000010_init-config-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS config ( 2 | "key" TEXT NOT NULL, 3 | "value" TEXT NOT NULL 4 | ) STRICT; 5 | 6 | CREATE UNIQUE INDEX IF NOT EXISTS idx_config_key ON config(key); 7 | -------------------------------------------------------------------------------- /internal/migrations/000011_init-tasks-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS tasks; 2 | 3 | DROP INDEX IF EXISTS idx_tasks_id; 4 | DROP INDEX IF EXISTS idx_tasks_prority_registered; 5 | DROP INDEX IF EXISTS idx_tasks_name_registered; 6 | -------------------------------------------------------------------------------- /internal/migrations/000011_init-tasks-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS tasks ( 2 | "id" TEXT NOT NULL, 3 | "priority" INTEGER NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "status" TEXT NOT NULL, 6 | "retries" INTEGER NOT NULL, 7 | "registered_at" TEXT NOT NULL, 8 | "args" BLOB NOT NULL 9 | ) STRICT; 10 | 11 | CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_id ON tasks(id); 12 | CREATE INDEX IF NOT EXISTS idx_tasks_status_priority_registered ON tasks(status, priority, registered_at); 13 | CREATE INDEX IF NOT EXISTS idx_tasks_name_registered ON tasks(name, registered_at); 14 | -------------------------------------------------------------------------------- /internal/migrations/000012_init-files-table.down.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theduckcompany/duckcloud/46baba5a73270a50663cd3d035aae98f23cfc801/internal/migrations/000012_init-files-table.down.sql -------------------------------------------------------------------------------- /internal/migrations/000012_init-files-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS files ( 2 | "id" TEXT NOT NULL, 3 | "size" INTEGER NOT NULL, 4 | "mimetype" TEXT DEFAULT NULL, 5 | "checksum" TEXT NOT NULL, 6 | "key" BLOB NOT NULL, 7 | "uploaded_at" TEXT NOT NULL 8 | ) STRICT; 9 | 10 | CREATE UNIQUE INDEX IF NOT EXISTS idx_fs_files_id ON files(id); 11 | CREATE UNIQUE INDEX IF NOT EXISTS idx_fs_files_checksum ON files(checksum); 12 | -------------------------------------------------------------------------------- /internal/migrations/000013_init-stats-table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS stats; 2 | 3 | DROP INDEX IF EXISTS idx_stats_key; 4 | 5 | -------------------------------------------------------------------------------- /internal/migrations/000013_init-stats-table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS stats ( 2 | "key" TEXT NOT NULL, 3 | "value" TEXT NOT NULL 4 | ) STRICT; 5 | 6 | CREATE UNIQUE INDEX IF NOT EXISTS idx_stats_key ON stats(key); 7 | -------------------------------------------------------------------------------- /internal/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | 10 | "github.com/golang-migrate/migrate/v4" 11 | "github.com/golang-migrate/migrate/v4/database/sqlite3" 12 | _ "github.com/golang-migrate/migrate/v4/source/file" 13 | "github.com/golang-migrate/migrate/v4/source/iofs" 14 | "github.com/theduckcompany/duckcloud/internal/tools" 15 | ) 16 | 17 | //go:embed *.sql 18 | var fs embed.FS 19 | 20 | func Run(db *sql.DB, tools tools.Tools) error { 21 | // Error not possible 22 | d, _ := iofs.New(fs, ".") 23 | 24 | driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) 25 | if err != nil { 26 | return fmt.Errorf("failed to setup the sqlite3 instance: %w", err) 27 | } 28 | m, err := migrate.NewWithInstance("iofs", d, "sqlite3", driver) 29 | if err != nil { 30 | return fmt.Errorf("failed to create a migrate manager: %w", err) 31 | } 32 | 33 | if tools != nil { 34 | m.Log = &migrateLogger{tools.Logger()} 35 | } 36 | 37 | err = m.Up() 38 | if err != nil && !errors.Is(err, migrate.ErrNoChange) { 39 | return fmt.Errorf("database migration error: %w", err) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | type migrateLogger struct { 46 | Logger *slog.Logger 47 | } 48 | 49 | func (t *migrateLogger) Printf(format string, v ...any) { 50 | t.Logger.Debug(fmt.Sprintf(format, v...)) 51 | } 52 | 53 | func (t *migrateLogger) Verbose() bool { 54 | return true 55 | } 56 | -------------------------------------------------------------------------------- /internal/migrations/migrations_test.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools" 10 | ) 11 | 12 | func newTestStorage(t *testing.T) *sql.DB { 13 | db, err := sql.Open("sqlite3", "file::memory:") 14 | require.NoError(t, err) 15 | 16 | err = db.Ping() 17 | require.NoError(t, err) 18 | 19 | return db 20 | } 21 | 22 | func TestRunMigration(t *testing.T) { 23 | tools := tools.NewMock(t) 24 | db := newTestStorage(t) 25 | 26 | err := Run(db, tools) 27 | require.NoError(t, err) 28 | 29 | row := db.QueryRow(`SELECT COUNT(*) FROM sqlite_schema 30 | where type='table' AND name NOT LIKE 'sqlite_%'`) 31 | 32 | require.NoError(t, row.Err()) 33 | var res int 34 | row.Scan(&res) 35 | 36 | // There is more than 3 tables 37 | assert.Greater(t, res, 3) 38 | } 39 | 40 | func TestRunMigrationTwice(t *testing.T) { 41 | tools := tools.NewMock(t) 42 | db := newTestStorage(t) 43 | 44 | err := Run(db, tools) 45 | require.NoError(t, err) 46 | 47 | err = Run(db, tools) 48 | require.NoError(t, err) 49 | } 50 | -------------------------------------------------------------------------------- /internal/server/bootstrap.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 9 | "github.com/theduckcompany/duckcloud/internal/service/users" 10 | "github.com/theduckcompany/duckcloud/internal/tools/errs" 11 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 12 | ) 13 | 14 | func bootstrap(ctx context.Context, usersSvc users.Service, spacesSvc spaces.Service) error { 15 | res, err := usersSvc.GetAll(ctx, &sqlstorage.PaginateCmd{Limit: 4}) 16 | if err != nil { 17 | return fmt.Errorf("failed to GetAll users: %w", err) 18 | } 19 | 20 | var bootstrapUser *users.User 21 | switch len(res) { 22 | case 0: 23 | bootstrapUser, err = usersSvc.Bootstrap(ctx) 24 | if err != nil { 25 | return fmt.Errorf("failed to create the first user: %w", err) 26 | } 27 | default: 28 | for _, user := range res { 29 | u := user 30 | if user.IsAdmin() { 31 | bootstrapUser = &u 32 | } 33 | } 34 | } 35 | 36 | if bootstrapUser == nil { 37 | return errs.Internal(errors.New("no admin found")) 38 | } 39 | 40 | err = spacesSvc.Bootstrap(ctx, bootstrapUser) 41 | if err != nil { 42 | return fmt.Errorf("failed to create the first space: %w", err) 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/server/run.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/theduckcompany/duckcloud/internal/service/tasks/runner" 9 | "github.com/theduckcompany/duckcloud/internal/tools/router" 10 | "go.uber.org/fx" 11 | ) 12 | 13 | func Run(ctx context.Context, cfg Config) (os.Signal, error) { 14 | // Start server with the HTTP server. 15 | app := start(ctx, cfg, fx.Invoke(func(*router.API, runner.Service) {})) 16 | 17 | if err := app.Err(); err != nil { 18 | return nil, err 19 | } 20 | 21 | startCtx, startCancel := context.WithTimeout(ctx, app.StartTimeout()) 22 | defer startCancel() 23 | 24 | err := app.Start(startCtx) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to start: %w", err) 27 | } 28 | 29 | var signal os.Signal 30 | select { 31 | case shutdown := <-app.Wait(): 32 | signal = shutdown.Signal 33 | 34 | case <-ctx.Done(): 35 | signal = os.Interrupt 36 | } 37 | 38 | stopCtx, stopCancel := context.WithTimeout(context.WithoutCancel(ctx), app.StopTimeout()) 39 | defer stopCancel() 40 | 41 | err = app.Stop(stopCtx) 42 | if err != nil { 43 | return signal, fmt.Errorf("failed to stop: %w", err) 44 | } 45 | 46 | return signal, nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/service/config/init.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 7 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 8 | ) 9 | 10 | //go:generate mockery --name Service 11 | type Service interface { 12 | SetMasterKey(ctx context.Context, key *secret.SealedKey) error 13 | GetMasterKey(ctx context.Context) (*secret.SealedKey, error) 14 | } 15 | 16 | func Init(db sqlstorage.Querier) Service { 17 | storage := newSqlStorage(db) 18 | 19 | return newService(storage) 20 | } 21 | -------------------------------------------------------------------------------- /internal/service/config/integration_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 11 | ) 12 | 13 | func Test_Integration_Config(t *testing.T) { 14 | ctx := context.Background() 15 | 16 | db := sqlstorage.NewTestStorage(t) 17 | svc := Init(db) 18 | 19 | mk, err := secret.NewKey() 20 | require.NoError(t, err) 21 | 22 | key, err := secret.NewKey() 23 | require.NoError(t, err) 24 | 25 | sealedKey, err := secret.SealKey(mk, key) 26 | require.NoError(t, err) 27 | 28 | t.Run("SetMasterKey success", func(t *testing.T) { 29 | err := svc.SetMasterKey(ctx, sealedKey) 30 | 31 | require.NoError(t, err) 32 | }) 33 | 34 | t.Run("GetMasterKey success", func(t *testing.T) { 35 | res, err := svc.GetMasterKey(ctx) 36 | 37 | require.NoError(t, err) 38 | assert.True(t, sealedKey.Equals(res)) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /internal/service/config/model.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type ConfigKey string 4 | 5 | const ( 6 | masterKey ConfigKey = "key.master" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/service/config/service.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/theduckcompany/duckcloud/internal/tools/errs" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | ) 11 | 12 | //go:generate mockery --name storage 13 | type storage interface { 14 | Save(ctx context.Context, key ConfigKey, value string) error 15 | Get(ctx context.Context, key ConfigKey) (string, error) 16 | } 17 | 18 | type service struct { 19 | storage storage 20 | } 21 | 22 | func newService(storage storage) *service { 23 | return &service{storage} 24 | } 25 | 26 | func (s *service) SetMasterKey(ctx context.Context, key *secret.SealedKey) error { 27 | err := s.storage.Save(ctx, masterKey, key.Base64()) 28 | if err != nil { 29 | return fmt.Errorf("failed to Save: %w", err) 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func (s *service) GetMasterKey(ctx context.Context) (*secret.SealedKey, error) { 36 | keyStr, err := s.storage.Get(ctx, masterKey) 37 | if errors.Is(err, errNotfound) { 38 | return nil, errs.ErrNotFound 39 | } 40 | 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to Get: %w", err) 43 | } 44 | 45 | res, err := secret.SealedKeyFromBase64(keyStr) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to decode the key: %w", err) 48 | } 49 | 50 | return res, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/service/config/service_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package config 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | secret "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | ) 11 | 12 | // MockService is an autogenerated mock type for the Service type 13 | type MockService struct { 14 | mock.Mock 15 | } 16 | 17 | // GetMasterKey provides a mock function with given fields: ctx 18 | func (_m *MockService) GetMasterKey(ctx context.Context) (*secret.SealedKey, error) { 19 | ret := _m.Called(ctx) 20 | 21 | var r0 *secret.SealedKey 22 | var r1 error 23 | if rf, ok := ret.Get(0).(func(context.Context) (*secret.SealedKey, error)); ok { 24 | return rf(ctx) 25 | } 26 | if rf, ok := ret.Get(0).(func(context.Context) *secret.SealedKey); ok { 27 | r0 = rf(ctx) 28 | } else { 29 | if ret.Get(0) != nil { 30 | r0 = ret.Get(0).(*secret.SealedKey) 31 | } 32 | } 33 | 34 | if rf, ok := ret.Get(1).(func(context.Context) error); ok { 35 | r1 = rf(ctx) 36 | } else { 37 | r1 = ret.Error(1) 38 | } 39 | 40 | return r0, r1 41 | } 42 | 43 | // SetMasterKey provides a mock function with given fields: ctx, key 44 | func (_m *MockService) SetMasterKey(ctx context.Context, key *secret.SealedKey) error { 45 | ret := _m.Called(ctx, key) 46 | 47 | var r0 error 48 | if rf, ok := ret.Get(0).(func(context.Context, *secret.SealedKey) error); ok { 49 | r0 = rf(ctx, key) 50 | } else { 51 | r0 = ret.Error(0) 52 | } 53 | 54 | return r0 55 | } 56 | 57 | // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 58 | // The first argument is typically a *testing.T value. 59 | func NewMockService(t interface { 60 | mock.TestingT 61 | Cleanup(func()) 62 | }) *MockService { 63 | mock := &MockService{} 64 | mock.Mock.Test(t) 65 | 66 | t.Cleanup(func() { mock.AssertExpectations(t) }) 67 | 68 | return mock 69 | } 70 | -------------------------------------------------------------------------------- /internal/service/config/service_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 11 | ) 12 | 13 | func TestConfig(t *testing.T) { 14 | ctx := context.Background() 15 | 16 | db := sqlstorage.NewTestStorage(t) 17 | store := newSqlStorage(db) 18 | svc := newService(store) 19 | 20 | masterKey, err := secret.NewKey() 21 | require.NoError(t, err) 22 | 23 | key, err := secret.NewKey() 24 | require.NoError(t, err) 25 | 26 | sealedKey, err := secret.SealKey(masterKey, key) 27 | require.NoError(t, err) 28 | 29 | t.Run("SetMasterKey success", func(t *testing.T) { 30 | err := svc.SetMasterKey(ctx, sealedKey) 31 | require.NoError(t, err) 32 | }) 33 | 34 | t.Run("GetMasterKey success", func(t *testing.T) { 35 | res, err := svc.GetMasterKey(ctx) 36 | require.NoError(t, err) 37 | 38 | assert.True(t, res.Equals(sealedKey)) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /internal/service/config/storage_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package config 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // mockStorage is an autogenerated mock type for the storage type 12 | type mockStorage struct { 13 | mock.Mock 14 | } 15 | 16 | // Get provides a mock function with given fields: ctx, key 17 | func (_m *mockStorage) Get(ctx context.Context, key ConfigKey) (string, error) { 18 | ret := _m.Called(ctx, key) 19 | 20 | var r0 string 21 | var r1 error 22 | if rf, ok := ret.Get(0).(func(context.Context, ConfigKey) (string, error)); ok { 23 | return rf(ctx, key) 24 | } 25 | if rf, ok := ret.Get(0).(func(context.Context, ConfigKey) string); ok { 26 | r0 = rf(ctx, key) 27 | } else { 28 | r0 = ret.Get(0).(string) 29 | } 30 | 31 | if rf, ok := ret.Get(1).(func(context.Context, ConfigKey) error); ok { 32 | r1 = rf(ctx, key) 33 | } else { 34 | r1 = ret.Error(1) 35 | } 36 | 37 | return r0, r1 38 | } 39 | 40 | // Save provides a mock function with given fields: ctx, key, value 41 | func (_m *mockStorage) Save(ctx context.Context, key ConfigKey, value string) error { 42 | ret := _m.Called(ctx, key, value) 43 | 44 | var r0 error 45 | if rf, ok := ret.Get(0).(func(context.Context, ConfigKey, string) error); ok { 46 | r0 = rf(ctx, key, value) 47 | } else { 48 | r0 = ret.Error(0) 49 | } 50 | 51 | return r0 52 | } 53 | 54 | // newMockStorage creates a new instance of mockStorage. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 55 | // The first argument is typically a *testing.T value. 56 | func newMockStorage(t interface { 57 | mock.TestingT 58 | Cleanup(func()) 59 | }) *mockStorage { 60 | mock := &mockStorage{} 61 | mock.Mock.Test(t) 62 | 63 | t.Cleanup(func() { mock.AssertExpectations(t) }) 64 | 65 | return mock 66 | } 67 | -------------------------------------------------------------------------------- /internal/service/config/storage_sql.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | 9 | sq "github.com/Masterminds/squirrel" 10 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 11 | ) 12 | 13 | const tableName = "config" 14 | 15 | var errNotfound = errors.New("not found") 16 | 17 | type sqlStorage struct { 18 | db sqlstorage.Querier 19 | } 20 | 21 | func newSqlStorage(db sqlstorage.Querier) *sqlStorage { 22 | return &sqlStorage{db} 23 | } 24 | 25 | func (s *sqlStorage) Save(ctx context.Context, key ConfigKey, value string) error { 26 | _, err := sq. 27 | Insert(tableName). 28 | Columns("key", "value"). 29 | Values(key, value). 30 | Suffix("ON CONFLICT DO UPDATE SET value = ?", value). 31 | RunWith(s.db). 32 | ExecContext(ctx) 33 | if err != nil { 34 | return fmt.Errorf("sql error: %w", err) 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func (s *sqlStorage) Get(ctx context.Context, key ConfigKey) (string, error) { 41 | var res string 42 | 43 | err := sq. 44 | Select("value"). 45 | From(tableName). 46 | Where(sq.Eq{"key": key}). 47 | RunWith(s.db). 48 | ScanContext(ctx, &res) 49 | if errors.Is(err, sql.ErrNoRows) { 50 | return res, errNotfound 51 | } 52 | 53 | if err != nil { 54 | return res, fmt.Errorf("sql error: %w", err) 55 | } 56 | 57 | return res, nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/service/config/storage_sql_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 10 | ) 11 | 12 | func TestSQLStorage(t *testing.T) { 13 | ctx := context.Background() 14 | 15 | db := sqlstorage.NewTestStorage(t) 16 | store := newSqlStorage(db) 17 | 18 | t.Run("Save success", func(t *testing.T) { 19 | err := store.Save(ctx, masterKey, "some-content") 20 | require.NoError(t, err) 21 | }) 22 | 23 | t.Run("Get success", func(t *testing.T) { 24 | res, err := store.Get(ctx, masterKey) 25 | require.NoError(t, err) 26 | assert.Equal(t, "some-content", res) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /internal/service/config/testdata/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIBbDCCARGgAwIBAgIRAO2bw16Scij82OreXtGvEAEwCgYIKoZIzj0EAwIwFDES 3 | MBAGA1UEChMJRHVjayBDb3JwMCAXDTIzMTAxMDE0MzMxMVoYDzAwMDEwMTAxMDAw 4 | MDAwWjAUMRIwEAYDVQQKEwlEdWNrIENvcnAwWTATBgcqhkjOPQIBBggqhkjOPQMB 5 | BwNCAARafeE1jMvF9kyvS1OczEuWwqBtCDtVpCKd79bZG8kPEEmhzUtz1CoSP4Ud 6 | qbez02q3B8/LFl1R5OpefAwACo/no0IwQDAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0l 7 | BAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADALBgNVHREEBDACggAwCgYIKoZI 8 | zj0EAwIDSQAwRgIhAKRnbp4PKNjsKmc4jW7jalo8I4TqDrTxk0V1OuNDMgpLAiEA 9 | 95gpwU/nBzMHgOiuw8scHOVaGr2ktt5RDpm/+s3NppE= 10 | -----END CERTIFICATE----- 11 | -------------------------------------------------------------------------------- /internal/service/config/testdata/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevAzq0XEJMYq9BmL 3 | Eg3XeSzEa2ZPLKi8wbw6ZXoSlKShRANCAARafeE1jMvF9kyvS1OczEuWwqBtCDtV 4 | pCKd79bZG8kPEEmhzUtz1CoSP4Udqbez02q3B8/LFl1R5OpefAwACo/n 5 | -----END PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /internal/service/dav/http_handler.go: -------------------------------------------------------------------------------- 1 | package dav 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | "github.com/theduckcompany/duckcloud/internal/service/dav/webdav" 8 | "github.com/theduckcompany/duckcloud/internal/service/davsessions" 9 | "github.com/theduckcompany/duckcloud/internal/service/dfs" 10 | "github.com/theduckcompany/duckcloud/internal/service/files" 11 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 12 | "github.com/theduckcompany/duckcloud/internal/service/users" 13 | "github.com/theduckcompany/duckcloud/internal/tools" 14 | "github.com/theduckcompany/duckcloud/internal/tools/logger" 15 | "github.com/theduckcompany/duckcloud/internal/tools/router" 16 | ) 17 | 18 | // HTTPHandler serve files via the Webdav protocol over http. 19 | type HTTPHandler struct { 20 | webdavHandler *webdav.Handler 21 | } 22 | 23 | // NewHTTPHandler builds a new EchoHandler. 24 | func NewHTTPHandler(tools tools.Tools, fs dfs.Service, files files.Service, spaces spaces.Service, davSessions davsessions.Service, users users.Service) *HTTPHandler { 25 | return &HTTPHandler{ 26 | webdavHandler: &webdav.Handler{ 27 | Prefix: "/webdav", 28 | FileSystem: fs, 29 | Spaces: spaces, 30 | Users: users, 31 | Files: files, 32 | Sessions: davSessions, 33 | Logger: func(r *http.Request, err error) { 34 | if err != nil { 35 | logger.LogEntrySetError(r.Context(), err) 36 | } 37 | }, 38 | }, 39 | } 40 | } 41 | 42 | func (h *HTTPHandler) Register(r chi.Router, mids *router.Middlewares) { 43 | if mids != nil { 44 | r = r.With(mids.StripSlashed, mids.Logger) 45 | } 46 | 47 | r.HandleFunc("/webdav", h.handleWebdavCollections) 48 | r.Handle("/webdav/*", h.webdavHandler) 49 | } 50 | 51 | func (h *HTTPHandler) handleWebdavCollections(w http.ResponseWriter, r *http.Request) { 52 | switch r.Method { 53 | case "OPTIONS": 54 | h.webdavHandler.ServeHTTP(w, r) 55 | default: 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/service/dav/webdav/internal/xml/README: -------------------------------------------------------------------------------- 1 | This is a fork of the encoding/xml package at ca1d6c4, the last commit before 2 | https://go.googlesource.com/go/+/c0d6d33 "encoding/xml: restore Go 1.4 name 3 | space behavior" made late in the lead-up to the Go 1.5 release. 4 | 5 | The list of encoding/xml changes is at 6 | https://go.googlesource.com/go/+log/master/src/encoding/xml 7 | 8 | This fork is temporary, and I (nigeltao) expect to revert it after Go 1.6 is 9 | released. 10 | 11 | See http://golang.org/issue/11841 12 | -------------------------------------------------------------------------------- /internal/service/davsessions/init.go: -------------------------------------------------------------------------------- 1 | package davsessions 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 7 | "github.com/theduckcompany/duckcloud/internal/tools" 8 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 9 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | //go:generate mockery --name Service 14 | type Service interface { 15 | GetAllForUser(ctx context.Context, userID uuid.UUID, paginateCmd *sqlstorage.PaginateCmd) ([]DavSession, error) 16 | Create(ctx context.Context, cmd *CreateCmd) (*DavSession, string, error) 17 | Authenticate(ctx context.Context, username string, password secret.Text) (*DavSession, error) 18 | Delete(ctx context.Context, cmd *DeleteCmd) error 19 | DeleteAll(ctx context.Context, userID uuid.UUID) error 20 | } 21 | 22 | func Init(db sqlstorage.Querier, spaces spaces.Service, tools tools.Tools) Service { 23 | storage := newSqlStorage(db) 24 | 25 | return newService(storage, spaces, tools) 26 | } 27 | -------------------------------------------------------------------------------- /internal/service/davsessions/model.go: -------------------------------------------------------------------------------- 1 | package davsessions 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | 7 | v "github.com/go-ozzo/ozzo-validation" 8 | "github.com/go-ozzo/ozzo-validation/v4/is" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | var DavSessionRegexp = regexp.MustCompile("^[0-9a-zA-Z- ]+$") 14 | 15 | type DavSession struct { 16 | createdAt time.Time 17 | id uuid.UUID 18 | userID uuid.UUID 19 | name string 20 | username string 21 | password secret.Text 22 | spaceID uuid.UUID 23 | } 24 | 25 | func (u *DavSession) ID() uuid.UUID { return u.id } 26 | func (u *DavSession) UserID() uuid.UUID { return u.userID } 27 | func (u DavSession) Name() string { return u.name } 28 | func (u *DavSession) Username() string { return u.username } 29 | func (u *DavSession) SpaceID() uuid.UUID { return u.spaceID } 30 | func (u *DavSession) CreatedAt() time.Time { return u.createdAt } 31 | 32 | type CreateCmd struct { 33 | Name string 34 | Username string 35 | UserID uuid.UUID 36 | SpaceID uuid.UUID 37 | } 38 | 39 | func (t CreateCmd) Validate() error { 40 | return v.ValidateStruct(&t, 41 | v.Field(&t.Name, v.Required, v.Match(DavSessionRegexp)), 42 | v.Field(&t.Username, v.Required, v.Length(1, 30)), 43 | v.Field(&t.UserID, v.Required, is.UUIDv4), 44 | v.Field(&t.SpaceID, v.Required, is.UUIDv4), 45 | ) 46 | } 47 | 48 | type DeleteCmd struct { 49 | UserID uuid.UUID 50 | SessionID uuid.UUID 51 | } 52 | 53 | func (t DeleteCmd) Validate() error { 54 | return v.ValidateStruct(&t, 55 | v.Field(&t.UserID, v.Required, is.UUIDv4), 56 | v.Field(&t.SessionID, v.Required, is.UUIDv4), 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /internal/service/davsessions/model_examples.go: -------------------------------------------------------------------------------- 1 | package davsessions 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 7 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 8 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 9 | ) 10 | 11 | var now time.Time = time.Now().UTC() 12 | 13 | var ExampleAliceSession = DavSession{ 14 | id: uuid.UUID("d43afe5b-5c3c-4ba4-a08c-031d701f2aef"), 15 | name: "My Computer", 16 | userID: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 17 | username: "Alice", 18 | password: secret.NewText("736f6d652d70617373776f7264"), // hex-encoding of "some-password" 19 | spaceID: spaces.ExampleAlicePersonalSpace.ID(), 20 | createdAt: now, 21 | } 22 | 23 | var ExampleAliceSession2 = DavSession{ 24 | id: uuid.UUID("0c2f3980-3ee4-42dc-8c9e-17249a99203d"), 25 | name: "My Computer", 26 | userID: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 27 | username: "Alice", 28 | password: secret.NewText("736f6d652d70617373776f7264"), // hex-encoding of "some-password" 29 | spaceID: spaces.ExampleAlicePersonalSpace.ID(), 30 | createdAt: now, 31 | } 32 | -------------------------------------------------------------------------------- /internal/service/davsessions/model_test.go: -------------------------------------------------------------------------------- 1 | package davsessions 2 | 3 | import ( 4 | "testing" 5 | 6 | validation "github.com/go-ozzo/ozzo-validation" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | func TestDavSession_Getters(t *testing.T) { 14 | assert.Equal(t, ExampleAliceSession.id, ExampleAliceSession.ID()) 15 | assert.Equal(t, ExampleAliceSession.userID, ExampleAliceSession.UserID()) 16 | assert.Equal(t, ExampleAliceSession.name, ExampleAliceSession.Name()) 17 | assert.Equal(t, ExampleAliceSession.spaceID, ExampleAliceSession.SpaceID()) 18 | assert.Equal(t, ExampleAliceSession.username, ExampleAliceSession.Username()) 19 | assert.Equal(t, ExampleAliceSession.createdAt, ExampleAliceSession.CreatedAt()) 20 | } 21 | 22 | func Test_CreateUserRequest_is_validatable(t *testing.T) { 23 | assert.Implements(t, (*validation.Validatable)(nil), new(CreateCmd)) 24 | } 25 | 26 | func Test_CreateRequest_Validate_success(t *testing.T) { 27 | err := CreateCmd{ 28 | Name: ExampleAliceSession.Name(), 29 | UserID: uuid.UUID("2c6b2615-6204-4817-a126-b6c13074afdf"), 30 | Username: "Jane Doe", 31 | SpaceID: spaces.ExampleAlicePersonalSpace.ID(), 32 | }.Validate() 33 | 34 | require.NoError(t, err) 35 | } 36 | 37 | func Test_DeleteRequest_is_validatable(t *testing.T) { 38 | assert.Implements(t, (*validation.Validatable)(nil), new(DeleteCmd)) 39 | } 40 | 41 | func Test_DeleteRequest_Validate_success(t *testing.T) { 42 | err := DeleteCmd{ 43 | UserID: uuid.UUID("2c6b2615-6204-4817-a126-b6c13074afdf"), 44 | SessionID: uuid.UUID("d43afe5b-5c3c-4ba4-a08c-031d701f2aef"), 45 | }.Validate() 46 | 47 | require.NoError(t, err) 48 | } 49 | -------------------------------------------------------------------------------- /internal/service/dfs/utils.go: -------------------------------------------------------------------------------- 1 | package dfs 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "path" 9 | 10 | "github.com/theduckcompany/duckcloud/internal/tools/errs" 11 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 12 | ) 13 | 14 | const WalkBatchSize = 100 15 | 16 | type WalkDirFunc func(ctx context.Context, path string, i *INode) error 17 | 18 | func Walk(ctx context.Context, ffs Service, cmd *PathCmd, fn WalkDirFunc) error { 19 | err := cmd.Validate() 20 | if err != nil { 21 | return errs.Validation(err) 22 | } 23 | 24 | inode, err := ffs.Get(ctx, cmd) 25 | if err != nil { 26 | return fmt.Errorf("failed to Get a file: %w", err) 27 | } 28 | 29 | if !inode.IsDir() { 30 | return fn(ctx, cmd.Path(), inode) 31 | } 32 | 33 | err = fn(ctx, cmd.Path(), inode) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | lastOffset := "" 39 | for { 40 | dirContent, err := ffs.ListDir(ctx, cmd, &sqlstorage.PaginateCmd{ 41 | StartAfter: map[string]string{"name": lastOffset}, 42 | Limit: WalkBatchSize, 43 | }) 44 | if err != nil && !errors.Is(err, io.EOF) { 45 | return fmt.Errorf("failed to ListDir %q: %w", cmd.Path(), err) 46 | } 47 | 48 | for _, elem := range dirContent { 49 | err = Walk(ctx, ffs, NewPathCmd(cmd.space, path.Join(cmd.Path(), elem.Name())), fn) 50 | if err != nil { 51 | return err 52 | } 53 | } 54 | 55 | if len(dirContent) > 0 { 56 | lastOffset = dirContent[len(dirContent)-1].Name() 57 | } 58 | 59 | if len(dirContent) < WalkBatchSize { 60 | break 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | 67 | // CleanPath is equivalent to but slightly more efficient than 68 | // path.Clean("/" + name). 69 | func CleanPath(name string) string { 70 | if name == "" || name[0] != '/' { 71 | name = "/" + name 72 | } 73 | return path.Clean(name) 74 | } 75 | -------------------------------------------------------------------------------- /internal/service/files/model.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 7 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 8 | ) 9 | 10 | type FileMeta struct { 11 | uploadedAt time.Time 12 | key *secret.SealedKey 13 | id uuid.UUID 14 | mimetype string 15 | checksum string 16 | size uint64 17 | } 18 | 19 | func (f *FileMeta) ID() uuid.UUID { return f.id } 20 | func (f *FileMeta) Size() uint64 { return f.size } 21 | func (f *FileMeta) MimeType() string { return f.mimetype } 22 | func (f *FileMeta) Checksum() string { return f.checksum } 23 | func (f *FileMeta) UploadedAt() time.Time { return f.uploadedAt } 24 | -------------------------------------------------------------------------------- /internal/service/files/model_examples.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 7 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 8 | ) 9 | 10 | var now = time.Now().UTC() 11 | 12 | var ExampleSealedKey secret.SealedKey 13 | 14 | //nolint:gochecknoinits // Don't have much choice here 15 | func init() { 16 | masterKey, _ := secret.NewKey() 17 | key, _ := secret.NewKey() 18 | 19 | res, _ := secret.SealKey(masterKey, key) 20 | 21 | ExampleSealedKey = *res 22 | } 23 | 24 | var ExampleFile1 = FileMeta{ 25 | id: uuid.UUID("abf05a02-8af9-4184-a46d-847f7d951c6b"), 26 | size: 42, 27 | mimetype: "text/plain; charset=utf-8", 28 | checksum: "wGKmdG7y2opGyALNvIp9pmFCJXgoaQ2-3EMdM03ADKQ=", 29 | key: &ExampleSealedKey, 30 | uploadedAt: now, 31 | } 32 | 33 | var ExampleFile2 = FileMeta{ 34 | id: uuid.UUID("66278d2b-7a4f-4764-ac8a-fc08f224eb66"), 35 | size: 22, 36 | mimetype: "text/plain; charset=utf-8", 37 | key: &ExampleSealedKey, 38 | checksum: "SDoHdxhNbtfFu9ZN9PGKKc6wW1Dk1P3YJbU3LK-gehY=", 39 | uploadedAt: now, 40 | } 41 | -------------------------------------------------------------------------------- /internal/service/files/model_helper.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "testing" 8 | "time" 9 | 10 | "github.com/brianvoe/gofakeit/v7" 11 | "github.com/stretchr/testify/require" 12 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 13 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 14 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 15 | ) 16 | 17 | type FakeFileBuilder struct { 18 | t *testing.T 19 | file *FileMeta 20 | } 21 | 22 | func NewFakeFile(t *testing.T) *FakeFileBuilder { 23 | t.Helper() 24 | 25 | // uuidProvider := uuid.NewProvider() 26 | uploadedAt := gofakeit.DateRange(time.Now().Add(-time.Hour*1000), time.Now()) 27 | masterKey, err := secret.NewKey() 28 | require.NoError(t, err) 29 | fileKey, err := secret.NewKey() 30 | require.NoError(t, err) 31 | key, err := secret.SealKey(masterKey, fileKey) 32 | require.NoError(t, err) 33 | 34 | content := []byte(gofakeit.Phrase()) 35 | 36 | return &FakeFileBuilder{ 37 | t: t, 38 | file: &FileMeta{ 39 | uploadedAt: uploadedAt, 40 | id: uuid.NewProvider().New(), 41 | key: key, 42 | mimetype: "text/plain; charset=utf-8", 43 | checksum: base64.RawStdEncoding.Strict().EncodeToString(sha256.New().Sum(content)), 44 | size: uint64(len(content)), 45 | }, 46 | } 47 | } 48 | 49 | func (f *FakeFileBuilder) WithContent(content []byte) *FakeFileBuilder { 50 | f.file.checksum = base64.RawStdEncoding.Strict().EncodeToString(sha256.New().Sum(content)) 51 | f.file.size = uint64(len(content)) 52 | 53 | return f 54 | } 55 | 56 | func (f *FakeFileBuilder) Build() *FileMeta { 57 | return f.file 58 | } 59 | 60 | func (f *FakeFileBuilder) BuildAndStore(ctx context.Context, db sqlstorage.Querier) *FileMeta { 61 | f.t.Helper() 62 | 63 | storage := newSqlStorage(db) 64 | 65 | err := storage.Save(ctx, f.file) 66 | require.NoError(f.t, err) 67 | 68 | return f.file 69 | } 70 | -------------------------------------------------------------------------------- /internal/service/files/storage_sql_test.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 10 | ) 11 | 12 | func TestUserSqlStorage(t *testing.T) { 13 | t.Parallel() 14 | 15 | ctx := context.Background() 16 | db := sqlstorage.NewTestStorage(t) 17 | store := newSqlStorage(db) 18 | 19 | // Data 20 | file := NewFakeFile(t).Build() 21 | 22 | t.Run("Save success", func(t *testing.T) { 23 | // Run 24 | err := store.Save(ctx, file) 25 | 26 | // Asserts 27 | require.NoError(t, err) 28 | }) 29 | 30 | t.Run("GetByID success", func(t *testing.T) { 31 | // Run 32 | res, err := store.GetByID(ctx, file.ID()) 33 | 34 | // Asserts 35 | require.NoError(t, err) 36 | assert.Equal(t, file, res) 37 | }) 38 | 39 | t.Run("GetByID not found", func(t *testing.T) { 40 | // Run 41 | res, err := store.GetByID(ctx, "some-invalid-id") 42 | 43 | // Asserts 44 | assert.Nil(t, res) 45 | require.ErrorIs(t, err, errNotFound) 46 | }) 47 | 48 | t.Run("Delete success", func(t *testing.T) { 49 | // Run 50 | err := store.Delete(ctx, file.ID()) 51 | 52 | // Asserts 53 | require.NoError(t, err) 54 | }) 55 | 56 | t.Run("GetByID a deleted file", func(t *testing.T) { 57 | // Run 58 | res, err := store.GetByID(ctx, file.ID()) 59 | 60 | // Asserts 61 | assert.Nil(t, res) 62 | require.ErrorIs(t, err, errNotFound) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /internal/service/masterkey/http_middleware.go: -------------------------------------------------------------------------------- 1 | package masterkey 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/theduckcompany/duckcloud/internal/web/html" 8 | ) 9 | 10 | type HTTPMiddleware struct { 11 | masterkey Service 12 | html html.Writer 13 | } 14 | 15 | func NewHTTPMiddleware(masterkey Service, html html.Writer) *HTTPMiddleware { 16 | return &HTTPMiddleware{ 17 | masterkey: masterkey, 18 | html: html, 19 | } 20 | } 21 | 22 | func (m *HTTPMiddleware) Handle(next http.Handler) http.Handler { 23 | fn := func(w http.ResponseWriter, r *http.Request) { 24 | if !m.masterkey.IsMasterKeyLoaded() { 25 | // IsRegistered is call only if ww see that the key is not loaded because 26 | // it should append only for the first calls and it's way more costly. 27 | isRegistered, err := m.masterkey.IsMasterKeyRegistered(r.Context()) 28 | if err != nil { 29 | m.html.WriteHTMLErrorPage(w, r, fmt.Errorf("failed to check if the master key is registered: %w", err)) 30 | return 31 | } 32 | 33 | switch { 34 | case isRegistered && r.URL.Path != "/master-password/ask": // Registered but not loaded -> ask for the password. 35 | http.Redirect(w, r, "/master-password/ask", http.StatusSeeOther) 36 | case !isRegistered && r.URL.Path != "/master-password/register": // Not registered -> ask for a new password and generate the key. 37 | http.Redirect(w, r, "/master-password/register", http.StatusSeeOther) 38 | default: 39 | next.ServeHTTP(w, r) 40 | } 41 | 42 | return 43 | } 44 | 45 | next.ServeHTTP(w, r) 46 | } 47 | 48 | return http.HandlerFunc(fn) 49 | } 50 | -------------------------------------------------------------------------------- /internal/service/masterkey/init.go: -------------------------------------------------------------------------------- 1 | package masterkey 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/spf13/afero" 9 | "github.com/theduckcompany/duckcloud/internal/service/config" 10 | "github.com/theduckcompany/duckcloud/internal/tools" 11 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 12 | ) 13 | 14 | //go:generate mockery --name Service 15 | type Service interface { 16 | GenerateMasterKey(ctx context.Context, password *secret.Text) error 17 | LoadMasterKeyFromPassword(ctx context.Context, password *secret.Text) error 18 | IsMasterKeyLoaded() bool 19 | IsMasterKeyRegistered(ctx context.Context) (bool, error) 20 | 21 | SealKey(key *secret.Key) (*secret.SealedKey, error) 22 | Open(key *secret.SealedKey) (*secret.Key, error) 23 | } 24 | 25 | func Init(ctx context.Context, config config.Service, fs afero.Fs, tools tools.Tools) (Service, error) { 26 | svc := newService(config, fs) 27 | 28 | err := svc.loadOrRegisterMasterKeyFromSystemdCreds(ctx) 29 | switch { 30 | case err == nil: 31 | return svc, nil 32 | case errors.Is(err, ErrCredsDirNotSet): 33 | tools.Logger().Warn("systemd-creds password not detected, needs to manually set the password.") 34 | return svc, nil 35 | default: 36 | return nil, fmt.Errorf("master key error: %w", err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/service/oauth2/client_storage.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/go-oauth2/oauth2/v4" 8 | oautherrors "github.com/go-oauth2/oauth2/v4/errors" 9 | "github.com/theduckcompany/duckcloud/internal/service/oauthclients" 10 | "github.com/theduckcompany/duckcloud/internal/tools/errs" 11 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 12 | ) 13 | 14 | type clientStorage struct { 15 | client oauthclients.Service 16 | uuid uuid.Service 17 | } 18 | 19 | func (t *clientStorage) GetByID(ctx context.Context, id string) (oauth2.ClientInfo, error) { 20 | uuid, err := t.uuid.Parse(id) 21 | if err != nil { 22 | return nil, nil 23 | } 24 | res, err := t.client.GetByID(ctx, uuid) 25 | if errors.Is(err, errs.ErrNotFound) { 26 | return nil, oautherrors.ErrInvalidClient 27 | } 28 | 29 | return res, nil 30 | } 31 | -------------------------------------------------------------------------------- /internal/service/oauth2/init.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-oauth2/oauth2/v4/manage" 7 | "github.com/theduckcompany/duckcloud/internal/service/oauthclients" 8 | "github.com/theduckcompany/duckcloud/internal/service/oauthcodes" 9 | "github.com/theduckcompany/duckcloud/internal/service/oauthsessions" 10 | "github.com/theduckcompany/duckcloud/internal/tools" 11 | ) 12 | 13 | //go:generate mockery --name Service 14 | type Service interface { 15 | GetFromReq(r *http.Request) (*Token, error) 16 | manager() *manage.Manager 17 | } 18 | 19 | func Init( 20 | tools tools.Tools, 21 | code oauthcodes.Service, 22 | oauthSession oauthsessions.Service, 23 | clients oauthclients.Service, 24 | ) *service { 25 | return newService(tools, code, oauthSession, clients) 26 | } 27 | -------------------------------------------------------------------------------- /internal/service/oauth2/model.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | import "github.com/theduckcompany/duckcloud/internal/tools/uuid" 4 | 5 | type Token struct { 6 | UserID uuid.UUID 7 | } 8 | -------------------------------------------------------------------------------- /internal/service/oauth2/service.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | oautherrors "github.com/go-oauth2/oauth2/v4/errors" 9 | "github.com/go-oauth2/oauth2/v4/manage" 10 | "github.com/theduckcompany/duckcloud/internal/service/oauthclients" 11 | "github.com/theduckcompany/duckcloud/internal/service/oauthcodes" 12 | "github.com/theduckcompany/duckcloud/internal/service/oauthsessions" 13 | "github.com/theduckcompany/duckcloud/internal/tools" 14 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 15 | ) 16 | 17 | type service struct { 18 | m *manage.Manager 19 | } 20 | 21 | func newService( 22 | tools tools.Tools, 23 | code oauthcodes.Service, 24 | oauthSession oauthsessions.Service, 25 | clients oauthclients.Service, 26 | ) *service { 27 | manager := manage.NewDefaultManager() 28 | manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) 29 | manager.MapTokenStorage(&tokenStorage{tools.UUID(), code, oauthSession}) 30 | manager.MapClientStorage(&clientStorage{client: clients}) 31 | 32 | return &service{} 33 | } 34 | 35 | func (s *service) manager() *manage.Manager { 36 | return s.m 37 | } 38 | 39 | func (s *service) GetFromReq(r *http.Request) (*Token, error) { 40 | accessToken, ok := s.bearerAuth(r) 41 | if !ok { 42 | return nil, oautherrors.ErrInvalidAccessToken 43 | } 44 | 45 | token, err := s.manager().LoadAccessToken(r.Context(), accessToken) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to load the access token: %w", err) 48 | } 49 | 50 | if token == nil { 51 | return nil, oautherrors.ErrInvalidAccessToken 52 | } 53 | 54 | return &Token{ 55 | UserID: uuid.UUID(token.GetUserID()), 56 | }, nil 57 | } 58 | 59 | func (s *service) bearerAuth(r *http.Request) (string, bool) { 60 | auth := r.Header.Get("Authorization") 61 | prefix := "Bearer " 62 | token := "" 63 | 64 | if auth != "" && strings.HasPrefix(auth, prefix) { 65 | token = auth[len(prefix):] 66 | } else { 67 | token = r.FormValue("access_token") 68 | } 69 | 70 | return token, token != "" 71 | } 72 | -------------------------------------------------------------------------------- /internal/service/oauth2/service_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package oauth2 4 | 5 | import ( 6 | http "net/http" 7 | 8 | manage "github.com/go-oauth2/oauth2/v4/manage" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // MockService is an autogenerated mock type for the Service type 13 | type MockService struct { 14 | mock.Mock 15 | } 16 | 17 | // GetFromReq provides a mock function with given fields: r 18 | func (_m *MockService) GetFromReq(r *http.Request) (*Token, error) { 19 | ret := _m.Called(r) 20 | 21 | var r0 *Token 22 | var r1 error 23 | if rf, ok := ret.Get(0).(func(*http.Request) (*Token, error)); ok { 24 | return rf(r) 25 | } 26 | if rf, ok := ret.Get(0).(func(*http.Request) *Token); ok { 27 | r0 = rf(r) 28 | } else { 29 | if ret.Get(0) != nil { 30 | r0 = ret.Get(0).(*Token) 31 | } 32 | } 33 | 34 | if rf, ok := ret.Get(1).(func(*http.Request) error); ok { 35 | r1 = rf(r) 36 | } else { 37 | r1 = ret.Error(1) 38 | } 39 | 40 | return r0, r1 41 | } 42 | 43 | // manager provides a mock function with given fields: 44 | func (_m *MockService) manager() *manage.Manager { 45 | ret := _m.Called() 46 | 47 | var r0 *manage.Manager 48 | if rf, ok := ret.Get(0).(func() *manage.Manager); ok { 49 | r0 = rf() 50 | } else { 51 | if ret.Get(0) != nil { 52 | r0 = ret.Get(0).(*manage.Manager) 53 | } 54 | } 55 | 56 | return r0 57 | } 58 | 59 | // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 60 | // The first argument is typically a *testing.T value. 61 | func NewMockService(t interface { 62 | mock.TestingT 63 | Cleanup(func()) 64 | }) *MockService { 65 | mock := &MockService{} 66 | mock.Mock.Test(t) 67 | 68 | t.Cleanup(func() { mock.AssertExpectations(t) }) 69 | 70 | return mock 71 | } 72 | -------------------------------------------------------------------------------- /internal/service/oauthclients/init.go: -------------------------------------------------------------------------------- 1 | package oauthclients 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools" 7 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 8 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 9 | ) 10 | 11 | //go:generate mockery --name Service 12 | type Service interface { 13 | Create(ctx context.Context, cmd *CreateCmd) (*Client, error) 14 | GetByID(ctx context.Context, clientID uuid.UUID) (*Client, error) 15 | } 16 | 17 | func Init(tools tools.Tools, db sqlstorage.Querier) Service { 18 | storage := newSqlStorage(db) 19 | 20 | return newService(tools, storage) 21 | } 22 | -------------------------------------------------------------------------------- /internal/service/oauthclients/model_example.go: -------------------------------------------------------------------------------- 1 | package oauthclients 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 7 | ) 8 | 9 | var now = time.Now() 10 | 11 | var ExampleAliceClient = Client{ 12 | id: "alice-oauth-client", 13 | name: "some-name", 14 | secret: "some-secret-uuid", 15 | redirectURI: "http://some-url", 16 | userID: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 17 | createdAt: now, 18 | scopes: Scopes{"scopeA", "scopeB"}, 19 | public: true, 20 | skipValidation: true, 21 | } 22 | 23 | var ExampleBobClient = Client{ 24 | id: "bob-oauth-client", 25 | name: "some-name", 26 | secret: "some-secret-uuid", 27 | redirectURI: "http://some-url", 28 | userID: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 29 | createdAt: now, 30 | scopes: Scopes{"scopeA", "scopeB"}, 31 | public: true, 32 | skipValidation: false, 33 | } 34 | -------------------------------------------------------------------------------- /internal/service/oauthclients/model_helper.go: -------------------------------------------------------------------------------- 1 | package oauthclients 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/brianvoe/gofakeit/v7" 9 | "github.com/stretchr/testify/require" 10 | "github.com/theduckcompany/duckcloud/internal/service/users" 11 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 12 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 13 | ) 14 | 15 | type FakeClientBuilder struct { 16 | t testing.TB 17 | client *Client 18 | } 19 | 20 | func NewFakeClient(t testing.TB) *FakeClientBuilder { 21 | t.Helper() 22 | 23 | uuidProvider := uuid.NewProvider() 24 | createdAt := gofakeit.DateRange(time.Now().Add(-time.Hour*1000), time.Now()) 25 | 26 | return &FakeClientBuilder{ 27 | t: t, 28 | client: &Client{ 29 | id: uuidProvider.New(), 30 | name: gofakeit.Name(), 31 | secret: gofakeit.Password(true, true, true, false, false, 8), 32 | redirectURI: gofakeit.URL(), 33 | userID: uuidProvider.New(), 34 | createdAt: createdAt, 35 | scopes: Scopes{"scope-a", "scope-b"}, 36 | public: false, 37 | skipValidation: false, 38 | }, 39 | } 40 | } 41 | 42 | func (f *FakeClientBuilder) SkipValidation() *FakeClientBuilder { 43 | f.client.skipValidation = true 44 | 45 | return f 46 | } 47 | 48 | func (f *FakeClientBuilder) CreatedBy(user *users.User) *FakeClientBuilder { 49 | f.client.userID = user.ID() 50 | 51 | return f 52 | } 53 | 54 | func (f *FakeClientBuilder) Build() *Client { 55 | return f.client 56 | } 57 | 58 | func (f *FakeClientBuilder) BuildAndStore(ctx context.Context, db sqlstorage.Querier) *Client { 59 | f.t.Helper() 60 | 61 | storage := newSqlStorage(db) 62 | 63 | err := storage.Save(ctx, f.client) 64 | require.NoError(f.t, err) 65 | 66 | return f.client 67 | } 68 | -------------------------------------------------------------------------------- /internal/service/oauthclients/model_test.go: -------------------------------------------------------------------------------- 1 | package oauthclients 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 8 | ) 9 | 10 | func Test_CreateCmd_Validate_success(t *testing.T) { 11 | err := CreateCmd{ 12 | ID: "some-ID", 13 | Name: "some-name", 14 | RedirectURI: "http://some-url", 15 | UserID: uuid.UUID("fe424b54-17ec-4830-bdd8-0e3a49de7179"), 16 | Scopes: Scopes{"foo", "bar"}, 17 | Public: true, 18 | SkipValidation: true, 19 | }.Validate() 20 | 21 | require.NoError(t, err) 22 | } 23 | -------------------------------------------------------------------------------- /internal/service/oauthclients/storage_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package oauthclients 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | uuid "github.com/theduckcompany/duckcloud/internal/tools/uuid" 10 | ) 11 | 12 | // mockStorage is an autogenerated mock type for the storage type 13 | type mockStorage struct { 14 | mock.Mock 15 | } 16 | 17 | // GetByID provides a mock function with given fields: ctx, id 18 | func (_m *mockStorage) GetByID(ctx context.Context, id uuid.UUID) (*Client, error) { 19 | ret := _m.Called(ctx, id) 20 | 21 | var r0 *Client 22 | var r1 error 23 | if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (*Client, error)); ok { 24 | return rf(ctx, id) 25 | } 26 | if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) *Client); ok { 27 | r0 = rf(ctx, id) 28 | } else { 29 | if ret.Get(0) != nil { 30 | r0 = ret.Get(0).(*Client) 31 | } 32 | } 33 | 34 | if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { 35 | r1 = rf(ctx, id) 36 | } else { 37 | r1 = ret.Error(1) 38 | } 39 | 40 | return r0, r1 41 | } 42 | 43 | // Save provides a mock function with given fields: ctx, client 44 | func (_m *mockStorage) Save(ctx context.Context, client *Client) error { 45 | ret := _m.Called(ctx, client) 46 | 47 | var r0 error 48 | if rf, ok := ret.Get(0).(func(context.Context, *Client) error); ok { 49 | r0 = rf(ctx, client) 50 | } else { 51 | r0 = ret.Error(0) 52 | } 53 | 54 | return r0 55 | } 56 | 57 | // newMockStorage creates a new instance of mockStorage. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 58 | // The first argument is typically a *testing.T value. 59 | func newMockStorage(t interface { 60 | mock.TestingT 61 | Cleanup(func()) 62 | }) *mockStorage { 63 | mock := &mockStorage{} 64 | mock.Mock.Test(t) 65 | 66 | t.Cleanup(func() { mock.AssertExpectations(t) }) 67 | 68 | return mock 69 | } 70 | -------------------------------------------------------------------------------- /internal/service/oauthclients/storage_sql.go: -------------------------------------------------------------------------------- 1 | package oauthclients 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | 9 | sq "github.com/Masterminds/squirrel" 10 | "github.com/theduckcompany/duckcloud/internal/tools/ptr" 11 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 12 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 13 | ) 14 | 15 | const tableName = "oauth_clients" 16 | 17 | var errNotFound = errors.New("not found") 18 | 19 | var allFields = []string{"id", "name", "secret", "redirect_uri", "user_id", "scopes", "is_public", "skip_validation", "created_at"} 20 | 21 | type sqlStorage struct { 22 | db sqlstorage.Querier 23 | } 24 | 25 | func newSqlStorage(db sqlstorage.Querier) *sqlStorage { 26 | return &sqlStorage{db} 27 | } 28 | 29 | func (t *sqlStorage) Save(ctx context.Context, client *Client) error { 30 | _, err := sq. 31 | Insert(tableName). 32 | Columns(allFields...). 33 | Values(client.id, 34 | client.name, 35 | client.secret, 36 | client.redirectURI, 37 | client.userID, 38 | client.scopes, 39 | client.public, 40 | client.skipValidation, 41 | ptr.To(sqlstorage.SQLTime(client.createdAt))). 42 | RunWith(t.db). 43 | ExecContext(ctx) 44 | if err != nil { 45 | return fmt.Errorf("sql error: %w", err) 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func (t *sqlStorage) GetByID(ctx context.Context, id uuid.UUID) (*Client, error) { 52 | var res Client 53 | var sqlCreatedAt sqlstorage.SQLTime 54 | 55 | err := sq. 56 | Select(allFields...). 57 | From(tableName). 58 | Where(sq.Eq{"id": id}). 59 | RunWith(t.db). 60 | ScanContext(ctx, &res.id, 61 | &res.name, 62 | &res.secret, 63 | &res.redirectURI, 64 | &res.userID, 65 | &res.scopes, 66 | &res.public, 67 | &res.skipValidation, 68 | &sqlCreatedAt) 69 | if errors.Is(err, sql.ErrNoRows) { 70 | return nil, errNotFound 71 | } 72 | 73 | if err != nil { 74 | return nil, fmt.Errorf("sql error: %w", err) 75 | } 76 | 77 | res.createdAt = sqlCreatedAt.Time() 78 | 79 | return &res, nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/service/oauthclients/storage_sql_test.go: -------------------------------------------------------------------------------- 1 | package oauthclients 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/service/users" 10 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 11 | ) 12 | 13 | func TestOauthClientsSQLStorage(t *testing.T) { 14 | ctx := context.Background() 15 | 16 | db := sqlstorage.NewTestStorage(t) 17 | storage := newSqlStorage(db) 18 | 19 | // Data 20 | user := users.NewFakeUser(t).BuildAndStore(ctx, db) 21 | client := NewFakeClient(t). 22 | CreatedBy(user). 23 | Build() 24 | 25 | t.Run("GetByID not found", func(t *testing.T) { 26 | res, err := storage.GetByID(ctx, "some-invalid-id") 27 | 28 | assert.Nil(t, res) 29 | require.ErrorIs(t, err, errNotFound) 30 | }) 31 | 32 | t.Run("Create", func(t *testing.T) { 33 | err := storage.Save(ctx, client) 34 | 35 | require.NoError(t, err) 36 | }) 37 | 38 | t.Run("GetByID success", func(t *testing.T) { 39 | res, err := storage.GetByID(ctx, client.id) 40 | 41 | require.NoError(t, err) 42 | assert.EqualValues(t, client, res) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /internal/service/oauthcodes/init.go: -------------------------------------------------------------------------------- 1 | package oauthcodes 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools" 7 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 8 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 9 | ) 10 | 11 | //go:generate mockery --name Service 12 | type Service interface { 13 | Create(ctx context.Context, input *CreateCmd) error 14 | RemoveByCode(ctx context.Context, code secret.Text) error 15 | GetByCode(ctx context.Context, code secret.Text) (*Code, error) 16 | } 17 | 18 | func Init(tools tools.Tools, db sqlstorage.Querier) Service { 19 | storage := newSqlStorage(db) 20 | 21 | return newService(tools, storage) 22 | } 23 | -------------------------------------------------------------------------------- /internal/service/oauthcodes/model.go: -------------------------------------------------------------------------------- 1 | package oauthcodes 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | 7 | v "github.com/go-ozzo/ozzo-validation" 8 | "github.com/go-ozzo/ozzo-validation/is" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | ) 11 | 12 | type Code struct { 13 | code secret.Text 14 | createdAt time.Time 15 | expiresAt time.Time 16 | clientID string 17 | userID string 18 | redirectURI string 19 | scope string 20 | challenge secret.Text 21 | challengeMethod string 22 | } 23 | 24 | func (c *Code) Code() secret.Text { return c.code } 25 | func (c *Code) CreatedAt() time.Time { return c.createdAt } 26 | func (c *Code) ExpiresAt() time.Time { return c.expiresAt } 27 | func (c *Code) ClientID() string { return c.clientID } 28 | func (c *Code) UserID() string { return c.userID } 29 | func (c *Code) RedirectURI() string { return c.redirectURI } 30 | func (c *Code) Scope() string { return c.scope } 31 | func (c *Code) Challenge() secret.Text { return c.challenge } 32 | func (c *Code) ChallengeMethod() string { return c.challengeMethod } 33 | 34 | type CreateCmd struct { 35 | Code secret.Text 36 | ExpiresAt time.Time 37 | ClientID string 38 | UserID string 39 | RedirectURI string 40 | Scope string 41 | Challenge secret.Text 42 | ChallengeMethod string 43 | } 44 | 45 | // Validate the fields. 46 | func (t CreateCmd) Validate() error { 47 | return v.ValidateStruct(&t, 48 | v.Field(&t.Code, v.Required), 49 | v.Field(&t.ExpiresAt, v.Required), 50 | v.Field(&t.ClientID, v.Length(3, 40), v.Match(regexp.MustCompile("^[0-9a-zA-Z-]+$"))), 51 | v.Field(&t.UserID, is.UUIDv4), 52 | v.Field(&t.RedirectURI, is.URL), 53 | v.Field(&t.Scope, v.Required), 54 | v.Field(&t.Challenge), 55 | v.Field(&t.ChallengeMethod, v.In("plain", "S256")), 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /internal/service/oauthcodes/model_helper.go: -------------------------------------------------------------------------------- 1 | package oauthcodes 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/brianvoe/gofakeit/v7" 8 | "github.com/theduckcompany/duckcloud/internal/service/oauthclients" 9 | "github.com/theduckcompany/duckcloud/internal/service/users" 10 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 11 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 12 | ) 13 | 14 | type FakeCodeBuilder struct { 15 | t testing.TB 16 | code *Code 17 | } 18 | 19 | func NewFakeCode(t testing.TB) *FakeCodeBuilder { 20 | t.Helper() 21 | 22 | uuidProvider := uuid.NewProvider() 23 | createdAt := gofakeit.DateRange(time.Now().Add(-time.Hour*1000), time.Now()) 24 | 25 | return &FakeCodeBuilder{ 26 | t: t, 27 | code: &Code{ 28 | code: secret.NewText(gofakeit.Password(true, true, true, false, false, 8)), 29 | createdAt: createdAt, 30 | expiresAt: createdAt.Add(time.Hour), 31 | clientID: string(uuidProvider.New()), 32 | userID: string(uuidProvider.New()), 33 | redirectURI: gofakeit.URL(), 34 | scope: "scope-1,scope-2", 35 | challenge: secret.NewText(gofakeit.Password(true, true, true, false, false, 8)), 36 | challengeMethod: "S256", 37 | }, 38 | } 39 | } 40 | 41 | func (f *FakeCodeBuilder) WithClient(client *oauthclients.Client) *FakeCodeBuilder { 42 | f.code.clientID = client.GetID() 43 | 44 | return f 45 | } 46 | 47 | func (f *FakeCodeBuilder) CreatedBy(user *users.User) *FakeCodeBuilder { 48 | f.code.userID = string(user.ID()) 49 | 50 | return f 51 | } 52 | 53 | func (f *FakeCodeBuilder) Build() *Code { 54 | return f.code 55 | } 56 | 57 | // func (f *FakeCodeBuilder) BuildAndStore(ctx context.Context, db sqlstorage.Querier) *Code { 58 | // f.t.Helper() 59 | // 60 | // tools := tools.NewToolboxForTest(f.t) 61 | // storage := newSqlStorage(db, tools) 62 | // 63 | // err := storage.Save(ctx, f.code) 64 | // require.NoError(f.t, err) 65 | // 66 | // return f.code 67 | // } 68 | -------------------------------------------------------------------------------- /internal/service/oauthcodes/model_test.go: -------------------------------------------------------------------------------- 1 | package oauthcodes 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | validation "github.com/go-ozzo/ozzo-validation" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 11 | ) 12 | 13 | func Test_CreateCodeRequest_is_validatable(t *testing.T) { 14 | assert.Implements(t, (*validation.Validatable)(nil), new(CreateCmd)) 15 | } 16 | 17 | func Test_CreateCodeRequest_Validate_success(t *testing.T) { 18 | err := CreateCmd{ 19 | Code: secret.NewText("some-code"), 20 | ExpiresAt: time.Now(), 21 | UserID: "1b51ce74-2f89-47de-bfb4-ee9e12ca814e", 22 | Scope: "some-scope", 23 | }.Validate() 24 | 25 | require.NoError(t, err) 26 | } 27 | -------------------------------------------------------------------------------- /internal/service/oauthconsents/init.go: -------------------------------------------------------------------------------- 1 | package oauthconsents 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/theduckcompany/duckcloud/internal/service/oauthclients" 8 | "github.com/theduckcompany/duckcloud/internal/service/websessions" 9 | "github.com/theduckcompany/duckcloud/internal/tools" 10 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 11 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 12 | ) 13 | 14 | //go:generate mockery --name Service 15 | type Service interface { 16 | Create(ctx context.Context, cmd *CreateCmd) (*Consent, error) 17 | Check(r *http.Request, client *oauthclients.Client, session *websessions.Session) error 18 | Delete(ctx context.Context, consentID uuid.UUID) error 19 | GetAll(ctx context.Context, userID uuid.UUID, cmd *sqlstorage.PaginateCmd) ([]Consent, error) 20 | DeleteAll(ctx context.Context, userID uuid.UUID) error 21 | } 22 | 23 | func Init(tools tools.Tools, db sqlstorage.Querier) Service { 24 | storage := newSQLStorage(db) 25 | 26 | return NewService(storage, tools) 27 | } 28 | -------------------------------------------------------------------------------- /internal/service/oauthconsents/model.go: -------------------------------------------------------------------------------- 1 | package oauthconsents 2 | 3 | import ( 4 | "time" 5 | 6 | v "github.com/go-ozzo/ozzo-validation" 7 | "github.com/go-ozzo/ozzo-validation/is" 8 | "github.com/theduckcompany/duckcloud/internal/service/oauthclients" 9 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 10 | ) 11 | 12 | type Consent struct { 13 | createdAt time.Time 14 | id uuid.UUID 15 | userID uuid.UUID 16 | sessionToken string 17 | clientID string 18 | scopes []string 19 | } 20 | 21 | func (c *Consent) ID() uuid.UUID { return c.id } 22 | func (c *Consent) UserID() uuid.UUID { return c.userID } 23 | func (c *Consent) SessionToken() string { return c.sessionToken } 24 | func (c *Consent) ClientID() string { return c.clientID } 25 | func (c *Consent) Scopes() []string { return c.scopes } 26 | func (c *Consent) CreatedAt() time.Time { return c.createdAt } 27 | 28 | type CreateCmd struct { 29 | UserID uuid.UUID 30 | SessionToken string 31 | ClientID string 32 | Scopes []string 33 | } 34 | 35 | // Validate the fields. 36 | func (t CreateCmd) Validate() error { 37 | return v.ValidateStruct(&t, 38 | v.Field(&t.SessionToken, v.Required, is.UUIDv4), 39 | v.Field(&t.ClientID, v.Required, v.Match(oauthclients.ClientIDRegexp)), 40 | v.Field(&t.Scopes, v.Required, v.Length(1, 30)), 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /internal/service/oauthconsents/model_examples.go: -------------------------------------------------------------------------------- 1 | package oauthconsents 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 7 | ) 8 | 9 | var now = time.Now().UTC() 10 | 11 | var ExampleAliceConsent = Consent{ 12 | id: uuid.UUID("01ce56b3-5ab9-4265-b1d2-e0347dcd4158"), 13 | userID: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 14 | sessionToken: "3a708fc5-dc10-4655-8fc2-33b08a4b33a5", 15 | clientID: "alice-oauth-client", 16 | scopes: []string{"scopeA", "scopeB"}, 17 | createdAt: now, 18 | } 19 | -------------------------------------------------------------------------------- /internal/service/oauthconsents/model_helper.go: -------------------------------------------------------------------------------- 1 | package oauthconsents 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/brianvoe/gofakeit/v7" 8 | "github.com/theduckcompany/duckcloud/internal/service/oauthclients" 9 | "github.com/theduckcompany/duckcloud/internal/service/users" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | type FakeConsentBuilder struct { 14 | t testing.TB 15 | consent *Consent 16 | } 17 | 18 | func NewFakeConsent(t testing.TB) *FakeConsentBuilder { 19 | t.Helper() 20 | 21 | uuidProvider := uuid.NewProvider() 22 | createdAt := gofakeit.DateRange(time.Now().Add(-time.Hour*1000), time.Now()) 23 | 24 | return &FakeConsentBuilder{ 25 | t: t, 26 | consent: &Consent{ 27 | createdAt: createdAt, 28 | id: uuidProvider.New(), 29 | userID: uuidProvider.New(), 30 | sessionToken: gofakeit.Password(true, true, true, false, false, 8), 31 | clientID: string(uuidProvider.New()), 32 | scopes: []string{"scope-a", "scope-b"}, 33 | }, 34 | } 35 | } 36 | 37 | func (f *FakeConsentBuilder) WithClient(client *oauthclients.Client) *FakeConsentBuilder { 38 | f.consent.clientID = client.GetID() 39 | 40 | return f 41 | } 42 | 43 | func (f *FakeConsentBuilder) CreatedBy(user *users.User) *FakeConsentBuilder { 44 | f.consent.userID = user.ID() 45 | 46 | return f 47 | } 48 | 49 | func (f *FakeConsentBuilder) Build() *Consent { 50 | return f.consent 51 | } 52 | 53 | // func (f *FakeConsentBuilder) BuildAndStore(ctx context.Context, db sqlstorage.Querier) *Consent { 54 | // f.t.Helper() 55 | // 56 | // tools := tools.NewToolboxForTest(f.t) 57 | // storage := newSqlStorage(db, tools) 58 | // 59 | // err := storage.Save(ctx, f.consent) 60 | // require.NoError(f.t, err) 61 | // 62 | // return f.consent 63 | // } 64 | -------------------------------------------------------------------------------- /internal/service/oauthconsents/model_test.go: -------------------------------------------------------------------------------- 1 | package oauthconsents 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 8 | ) 9 | 10 | func TestOauthConsnet_Types(t *testing.T) { 11 | assert.Equal(t, uuid.UUID("01ce56b3-5ab9-4265-b1d2-e0347dcd4158"), ExampleAliceConsent.ID()) 12 | assert.Equal(t, uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), ExampleAliceConsent.UserID()) 13 | assert.Equal(t, "3a708fc5-dc10-4655-8fc2-33b08a4b33a5", ExampleAliceConsent.SessionToken()) 14 | assert.Equal(t, "alice-oauth-client", ExampleAliceConsent.ClientID()) 15 | assert.Equal(t, []string{"scopeA", "scopeB"}, ExampleAliceConsent.Scopes()) 16 | assert.Equal(t, now, ExampleAliceConsent.CreatedAt()) 17 | } 18 | -------------------------------------------------------------------------------- /internal/service/oauthsessions/init.go: -------------------------------------------------------------------------------- 1 | package oauthsessions 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools" 7 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 8 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 9 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 10 | ) 11 | 12 | //go:generate mockery --name Service 13 | type Service interface { 14 | Create(ctx context.Context, input *CreateCmd) (*Session, error) 15 | RemoveByAccessToken(ctx context.Context, access secret.Text) error 16 | RemoveByRefreshToken(ctx context.Context, refresh secret.Text) error 17 | GetByAccessToken(ctx context.Context, access secret.Text) (*Session, error) 18 | GetByRefreshToken(ctx context.Context, refresh secret.Text) (*Session, error) 19 | DeleteAllForUser(ctx context.Context, userID uuid.UUID) error 20 | } 21 | 22 | func Init(tools tools.Tools, db sqlstorage.Querier) Service { 23 | storage := newSqlStorage(db) 24 | 25 | return newService(tools, storage) 26 | } 27 | -------------------------------------------------------------------------------- /internal/service/oauthsessions/model.go: -------------------------------------------------------------------------------- 1 | package oauthsessions 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | 7 | v "github.com/go-ozzo/ozzo-validation" 8 | "github.com/go-ozzo/ozzo-validation/is" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | type Session struct { 14 | accessToken secret.Text 15 | accessCreatedAt time.Time 16 | accessExpiresAt time.Time 17 | refreshToken secret.Text 18 | refreshCreatedAt time.Time 19 | refreshExpiresAt time.Time 20 | clientID string 21 | userID uuid.UUID 22 | scope string 23 | } 24 | 25 | func (s *Session) AccessToken() secret.Text { return s.accessToken } 26 | func (s *Session) AccessCreatedAt() time.Time { return s.accessCreatedAt } 27 | func (s *Session) AccessExpiresAt() time.Time { return s.accessExpiresAt } 28 | func (s *Session) RefreshToken() secret.Text { return s.refreshToken } 29 | func (s *Session) RefreshCreatedAt() time.Time { return s.refreshCreatedAt } 30 | func (s *Session) RefreshExpiresAt() time.Time { return s.refreshExpiresAt } 31 | func (s *Session) ClientID() string { return s.clientID } 32 | func (s *Session) UserID() uuid.UUID { return s.userID } 33 | func (s *Session) Scope() string { return s.scope } 34 | 35 | type CreateCmd struct { 36 | AccessToken secret.Text 37 | AccessExpiresAt time.Time 38 | RefreshToken secret.Text 39 | RefreshExpiresAt time.Time 40 | ClientID string 41 | UserID uuid.UUID 42 | Scope string 43 | } 44 | 45 | // Validate the fields. 46 | func (t CreateCmd) Validate() error { 47 | return v.ValidateStruct(&t, 48 | v.Field(&t.AccessToken, v.Required), 49 | v.Field(&t.AccessExpiresAt, v.Required), 50 | v.Field(&t.RefreshToken, v.Required), 51 | v.Field(&t.RefreshExpiresAt, v.Required), 52 | v.Field(&t.ClientID, v.Length(3, 40), v.Match(regexp.MustCompile("^[0-9a-zA-Z-]+$"))), 53 | v.Field(&t.UserID, is.UUIDv4), 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /internal/service/oauthsessions/model_example.go: -------------------------------------------------------------------------------- 1 | package oauthsessions 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 7 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 8 | ) 9 | 10 | var nowData = time.Now().UTC() 11 | 12 | var ExampleAliceSession = Session{ 13 | accessToken: secret.NewText("some-access-token"), 14 | accessCreatedAt: nowData, 15 | accessExpiresAt: nowData.Add(time.Hour), 16 | refreshToken: secret.NewText("some-refresh-token"), 17 | refreshCreatedAt: nowData, 18 | refreshExpiresAt: nowData.Add(10 * time.Hour), 19 | clientID: "some-client-id", 20 | userID: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 21 | scope: "some-scope", 22 | } 23 | -------------------------------------------------------------------------------- /internal/service/oauthsessions/model_helper.go: -------------------------------------------------------------------------------- 1 | package oauthsessions 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/brianvoe/gofakeit/v7" 8 | "github.com/theduckcompany/duckcloud/internal/service/oauthclients" 9 | "github.com/theduckcompany/duckcloud/internal/service/users" 10 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 11 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 12 | ) 13 | 14 | type FakeSessionBuilder struct { 15 | t testing.TB 16 | session *Session 17 | } 18 | 19 | func NewFakeSession(t testing.TB) *FakeSessionBuilder { 20 | t.Helper() 21 | 22 | uuidProvider := uuid.NewProvider() 23 | createdAt := gofakeit.DateRange(time.Now().Add(-time.Hour*1000), time.Now()) 24 | 25 | return &FakeSessionBuilder{ 26 | t: t, 27 | session: &Session{ 28 | accessToken: secret.NewText(gofakeit.Password(true, true, true, false, false, 8)), 29 | accessCreatedAt: createdAt, 30 | accessExpiresAt: createdAt.Add(time.Hour), 31 | refreshToken: secret.NewText(gofakeit.Password(true, true, true, false, false, 8)), 32 | refreshCreatedAt: createdAt, 33 | refreshExpiresAt: createdAt.Add(time.Hour), 34 | clientID: gofakeit.Name(), 35 | userID: uuidProvider.New(), 36 | scope: "scope-a,scope-b", 37 | }, 38 | } 39 | } 40 | 41 | func (f *FakeSessionBuilder) WithClient(client *oauthclients.Client) *FakeSessionBuilder { 42 | f.session.clientID = client.GetID() 43 | 44 | return f 45 | } 46 | 47 | func (f *FakeSessionBuilder) CreatedBy(user *users.User) *FakeSessionBuilder { 48 | f.session.userID = user.ID() 49 | 50 | return f 51 | } 52 | 53 | func (f *FakeSessionBuilder) Build() *Session { 54 | return f.session 55 | } 56 | 57 | // func (f *FakeSessionBuilder) BuildAndStore(ctx context.Context, db sqlstorage.Querier) *Session { 58 | // f.t.Helper() 59 | // 60 | // storage := newSqlStorage(db) 61 | // 62 | // err := storage.Save(ctx, f.session) 63 | // require.NoError(f.t, err) 64 | // 65 | // return f.session 66 | // } 67 | -------------------------------------------------------------------------------- /internal/service/oauthsessions/model_test.go: -------------------------------------------------------------------------------- 1 | package oauthsessions 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | validation "github.com/go-ozzo/ozzo-validation" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 11 | ) 12 | 13 | func TestSessionGetter(t *testing.T) { 14 | assert.Equal(t, ExampleAliceSession.AccessToken(), ExampleAliceSession.accessToken) 15 | assert.Equal(t, ExampleAliceSession.AccessCreatedAt(), ExampleAliceSession.accessCreatedAt) 16 | assert.Equal(t, ExampleAliceSession.AccessExpiresAt(), ExampleAliceSession.accessExpiresAt) 17 | assert.Equal(t, ExampleAliceSession.RefreshToken(), ExampleAliceSession.refreshToken) 18 | assert.Equal(t, ExampleAliceSession.RefreshCreatedAt(), ExampleAliceSession.refreshCreatedAt) 19 | assert.Equal(t, ExampleAliceSession.RefreshExpiresAt(), ExampleAliceSession.refreshExpiresAt) 20 | 21 | assert.Equal(t, ExampleAliceSession.ClientID(), ExampleAliceSession.clientID) 22 | assert.Equal(t, ExampleAliceSession.UserID(), ExampleAliceSession.userID) 23 | assert.Equal(t, ExampleAliceSession.Scope(), ExampleAliceSession.scope) 24 | } 25 | 26 | func Test_CreateCmd_is_validatable(t *testing.T) { 27 | assert.Implements(t, (*validation.Validatable)(nil), new(CreateCmd)) 28 | } 29 | 30 | func Test_CreateCmd_Validate_success(t *testing.T) { 31 | err := CreateCmd{ 32 | AccessToken: secret.NewText("some-access-session"), 33 | AccessExpiresAt: time.Now(), 34 | RefreshToken: secret.NewText("some-refresh-session"), 35 | RefreshExpiresAt: time.Now(), 36 | UserID: "1b51ce74-2f89-47de-bfb4-ee9e12ca814e", 37 | Scope: "some-scope", 38 | }.Validate() 39 | 40 | require.NoError(t, err) 41 | } 42 | -------------------------------------------------------------------------------- /internal/service/spaces/init.go: -------------------------------------------------------------------------------- 1 | package spaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/service/tasks/scheduler" 7 | "github.com/theduckcompany/duckcloud/internal/service/users" 8 | "github.com/theduckcompany/duckcloud/internal/tools" 9 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | //go:generate mockery --name Service 14 | type Service interface { 15 | Bootstrap(ctx context.Context, user *users.User) error 16 | Create(ctx context.Context, cmd *CreateCmd) (*Space, error) 17 | GetAllUserSpaces(ctx context.Context, userID uuid.UUID, cmd *sqlstorage.PaginateCmd) ([]Space, error) 18 | GetAllSpaces(ctx context.Context, user *users.User, cmd *sqlstorage.PaginateCmd) ([]Space, error) 19 | GetUserSpace(ctx context.Context, userID, spaceID uuid.UUID) (*Space, error) 20 | GetByID(ctx context.Context, spaceID uuid.UUID) (*Space, error) 21 | AddOwner(ctx context.Context, cmd *AddOwnerCmd) (*Space, error) 22 | RemoveOwner(ctx context.Context, cmd *RemoveOwnerCmd) (*Space, error) 23 | Delete(ctx context.Context, user *users.User, spaceID uuid.UUID) error 24 | } 25 | 26 | func Init(tools tools.Tools, db sqlstorage.Querier, scheduler scheduler.Service) Service { 27 | storage := newSqlStorage(db, tools) 28 | 29 | return newService(tools, storage, scheduler) 30 | } 31 | -------------------------------------------------------------------------------- /internal/service/spaces/model_example.go: -------------------------------------------------------------------------------- 1 | package spaces 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/service/users" 7 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 8 | ) 9 | 10 | var now time.Time = time.Now().UTC() 11 | 12 | var ExampleAlicePersonalSpace = Space{ 13 | id: uuid.UUID("e97b60f7-add2-43e1-a9bd-e2dac9ce69ec"), 14 | name: "Alice's Space", 15 | owners: Owners{users.ExampleAlice.ID()}, 16 | createdAt: now, 17 | createdBy: users.ExampleAlice.ID(), 18 | } 19 | 20 | var ExampleBobPersonalSpace = Space{ 21 | id: uuid.UUID("614431ca-2493-41be-85e3-81fb2323f048"), 22 | name: "Bob's Space", 23 | owners: Owners{"0923c86c-24b6-4b9d-9050-e82b8408edf4"}, 24 | createdAt: now, 25 | createdBy: users.ExampleBob.ID(), 26 | } 27 | 28 | var ExampleAliceBobSharedSpace = Space{ 29 | id: uuid.UUID("c8943050-6bc5-4641-a4ba-672c1f03b4cd"), 30 | name: "Alice and Bob Space", 31 | owners: Owners{"86bffce3-3f53-4631-baf8-8530773884f3", "0923c86c-24b6-4b9d-9050-e82b8408edf4"}, 32 | createdAt: now, 33 | createdBy: users.ExampleAlice.ID(), 34 | } 35 | -------------------------------------------------------------------------------- /internal/service/stats/init.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 7 | ) 8 | 9 | //go:generate mockery --name Service 10 | type Service interface { 11 | SetTotalSize(ctx context.Context, totalSize uint64) error 12 | GetTotalSize(ctx context.Context) (uint64, error) 13 | } 14 | 15 | func Init(db sqlstorage.Querier) Service { 16 | storage := newSqlStorage(db) 17 | 18 | return newService(storage) 19 | } 20 | -------------------------------------------------------------------------------- /internal/service/stats/model.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | type statsKey string 4 | 5 | const ( 6 | totalSizeKey statsKey = "size.total" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/service/stats/service.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | ) 8 | 9 | //go:generate mockery --name storage 10 | type storage interface { 11 | Save(ctx context.Context, key statsKey, value any) error 12 | Get(ctx context.Context, key statsKey, val any) error 13 | } 14 | 15 | type service struct { 16 | storage storage 17 | } 18 | 19 | func newService(storage storage) *service { 20 | return &service{storage} 21 | } 22 | 23 | func (s *service) SetTotalSize(ctx context.Context, totalSize uint64) error { 24 | err := s.storage.Save(ctx, totalSizeKey, strconv.FormatUint(totalSize, 10)) 25 | if err != nil { 26 | return fmt.Errorf("failed to Save: %w", err) 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func (s *service) GetTotalSize(ctx context.Context) (uint64, error) { 33 | var resStr string 34 | 35 | err := s.storage.Get(ctx, totalSizeKey, &resStr) 36 | if err != nil { 37 | return 0, fmt.Errorf("failed to Get: %w", err) 38 | } 39 | 40 | return strconv.ParseUint(resStr, 10, 64) 41 | } 42 | -------------------------------------------------------------------------------- /internal/service/stats/service_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package stats 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // MockService is an autogenerated mock type for the Service type 12 | type MockService struct { 13 | mock.Mock 14 | } 15 | 16 | // GetTotalSize provides a mock function with given fields: ctx 17 | func (_m *MockService) GetTotalSize(ctx context.Context) (uint64, error) { 18 | ret := _m.Called(ctx) 19 | 20 | var r0 uint64 21 | var r1 error 22 | if rf, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { 23 | return rf(ctx) 24 | } 25 | if rf, ok := ret.Get(0).(func(context.Context) uint64); ok { 26 | r0 = rf(ctx) 27 | } else { 28 | r0 = ret.Get(0).(uint64) 29 | } 30 | 31 | if rf, ok := ret.Get(1).(func(context.Context) error); ok { 32 | r1 = rf(ctx) 33 | } else { 34 | r1 = ret.Error(1) 35 | } 36 | 37 | return r0, r1 38 | } 39 | 40 | // SetTotalSize provides a mock function with given fields: ctx, totalSize 41 | func (_m *MockService) SetTotalSize(ctx context.Context, totalSize uint64) error { 42 | ret := _m.Called(ctx, totalSize) 43 | 44 | var r0 error 45 | if rf, ok := ret.Get(0).(func(context.Context, uint64) error); ok { 46 | r0 = rf(ctx, totalSize) 47 | } else { 48 | r0 = ret.Error(0) 49 | } 50 | 51 | return r0 52 | } 53 | 54 | // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 55 | // The first argument is typically a *testing.T value. 56 | func NewMockService(t interface { 57 | mock.TestingT 58 | Cleanup(func()) 59 | }) *MockService { 60 | mock := &MockService{} 61 | mock.Mock.Test(t) 62 | 63 | t.Cleanup(func() { mock.AssertExpectations(t) }) 64 | 65 | return mock 66 | } 67 | -------------------------------------------------------------------------------- /internal/service/stats/service_test.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 10 | ) 11 | 12 | func TestConfig(t *testing.T) { 13 | ctx := context.Background() 14 | 15 | db := sqlstorage.NewTestStorage(t) 16 | store := newSqlStorage(db) 17 | svc := newService(store) 18 | 19 | t.Run("SetTotalSize success", func(t *testing.T) { 20 | err := svc.SetTotalSize(ctx, 4096) 21 | require.NoError(t, err) 22 | }) 23 | 24 | t.Run("GetTotalSize success", func(t *testing.T) { 25 | res, err := svc.GetTotalSize(ctx) 26 | require.NoError(t, err) 27 | 28 | assert.Equal(t, uint64(4096), res) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /internal/service/stats/storage_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package stats 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // mockStorage is an autogenerated mock type for the storage type 12 | type mockStorage struct { 13 | mock.Mock 14 | } 15 | 16 | // Get provides a mock function with given fields: ctx, key, val 17 | func (_m *mockStorage) Get(ctx context.Context, key statsKey, val interface{}) error { 18 | ret := _m.Called(ctx, key, val) 19 | 20 | var r0 error 21 | if rf, ok := ret.Get(0).(func(context.Context, statsKey, interface{}) error); ok { 22 | r0 = rf(ctx, key, val) 23 | } else { 24 | r0 = ret.Error(0) 25 | } 26 | 27 | return r0 28 | } 29 | 30 | // Save provides a mock function with given fields: ctx, key, value 31 | func (_m *mockStorage) Save(ctx context.Context, key statsKey, value interface{}) error { 32 | ret := _m.Called(ctx, key, value) 33 | 34 | var r0 error 35 | if rf, ok := ret.Get(0).(func(context.Context, statsKey, interface{}) error); ok { 36 | r0 = rf(ctx, key, value) 37 | } else { 38 | r0 = ret.Error(0) 39 | } 40 | 41 | return r0 42 | } 43 | 44 | // newMockStorage creates a new instance of mockStorage. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 45 | // The first argument is typically a *testing.T value. 46 | func newMockStorage(t interface { 47 | mock.TestingT 48 | Cleanup(func()) 49 | }) *mockStorage { 50 | mock := &mockStorage{} 51 | mock.Mock.Test(t) 52 | 53 | t.Cleanup(func() { mock.AssertExpectations(t) }) 54 | 55 | return mock 56 | } 57 | -------------------------------------------------------------------------------- /internal/service/stats/storage_sql.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | 9 | sq "github.com/Masterminds/squirrel" 10 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 11 | ) 12 | 13 | const tableName = "stats" 14 | 15 | var errNotfound = errors.New("not found") 16 | 17 | type sqlStorage struct { 18 | db sqlstorage.Querier 19 | } 20 | 21 | func newSqlStorage(db sqlstorage.Querier) *sqlStorage { 22 | return &sqlStorage{db} 23 | } 24 | 25 | func (s *sqlStorage) Save(ctx context.Context, key statsKey, value any) error { 26 | _, err := sq. 27 | Insert(tableName). 28 | Columns("key", "value"). 29 | Values(key, value). 30 | Suffix("ON CONFLICT DO UPDATE SET value = ?", value). 31 | RunWith(s.db). 32 | ExecContext(ctx) 33 | if err != nil { 34 | return fmt.Errorf("sql error: %w", err) 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func (s *sqlStorage) Get(ctx context.Context, key statsKey, val any) error { 41 | err := sq. 42 | Select("value"). 43 | From(tableName). 44 | Where(sq.Eq{"key": key}). 45 | RunWith(s.db). 46 | ScanContext(ctx, val) 47 | if errors.Is(err, sql.ErrNoRows) { 48 | return errNotfound 49 | } 50 | 51 | if err != nil { 52 | return fmt.Errorf("sql error: %w", err) 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/service/stats/storage_sql_test.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 10 | ) 11 | 12 | func TestSQLStorage(t *testing.T) { 13 | ctx := context.Background() 14 | 15 | db := sqlstorage.NewTestStorage(t) 16 | store := newSqlStorage(db) 17 | 18 | t.Run("Save success", func(t *testing.T) { 19 | err := store.Save(ctx, totalSizeKey, uint64(4096)) 20 | require.NoError(t, err) 21 | }) 22 | 23 | t.Run("Get success", func(t *testing.T) { 24 | var res uint64 25 | 26 | err := store.Get(ctx, totalSizeKey, &res) 27 | require.NoError(t, err) 28 | assert.Equal(t, uint64(4096), res) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /internal/service/tasks/internal/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 9 | ) 10 | 11 | type Status string 12 | 13 | const ( 14 | Queuing Status = "queuing" 15 | Failed Status = "failed" 16 | ) 17 | 18 | type Task struct { 19 | RegisteredAt time.Time 20 | ID uuid.UUID 21 | Name string 22 | Status Status 23 | Args json.RawMessage 24 | Priority int 25 | Retries int 26 | } 27 | 28 | func (t *Task) LogValue() slog.Value { 29 | if t == nil { 30 | return slog.AnyValue(nil) 31 | } 32 | 33 | return slog.GroupValue( 34 | slog.String("id", string(t.ID)), 35 | slog.String("name", t.Name), 36 | slog.Int("priority", t.Priority), 37 | slog.String("status", string(t.Status)), 38 | slog.Int("retries", t.Retries), 39 | slog.Time("registered_at", t.RegisteredAt), 40 | slog.String("arguments", string(t.Args)), 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /internal/service/tasks/runner/init.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/theduckcompany/duckcloud/internal/service/tasks/internal/taskstorage" 8 | "github.com/theduckcompany/duckcloud/internal/tools" 9 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 10 | ) 11 | 12 | //go:generate mockery --name Service 13 | type Service interface { 14 | Run(ctx context.Context) error 15 | } 16 | 17 | //go:generate mockery --name TaskRunner 18 | type TaskRunner interface { 19 | Run(ctx context.Context, args json.RawMessage) error 20 | Name() string 21 | } 22 | 23 | func Init(runners []TaskRunner, tools tools.Tools, db sqlstorage.Querier) Service { 24 | storage := taskstorage.NewSqlStorage(db) 25 | 26 | return NewService(tools, storage, runners) 27 | } 28 | -------------------------------------------------------------------------------- /internal/service/tasks/runner/service_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package runner 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // MockService is an autogenerated mock type for the Service type 12 | type MockService struct { 13 | mock.Mock 14 | } 15 | 16 | // Run provides a mock function with given fields: ctx 17 | func (_m *MockService) Run(ctx context.Context) error { 18 | ret := _m.Called(ctx) 19 | 20 | var r0 error 21 | if rf, ok := ret.Get(0).(func(context.Context) error); ok { 22 | r0 = rf(ctx) 23 | } else { 24 | r0 = ret.Error(0) 25 | } 26 | 27 | return r0 28 | } 29 | 30 | // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 31 | // The first argument is typically a *testing.T value. 32 | func NewMockService(t interface { 33 | mock.TestingT 34 | Cleanup(func()) 35 | }) *MockService { 36 | mock := &MockService{} 37 | mock.Mock.Test(t) 38 | 39 | t.Cleanup(func() { mock.AssertExpectations(t) }) 40 | 41 | return mock 42 | } 43 | -------------------------------------------------------------------------------- /internal/service/tasks/runner/task_runner_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package runner 4 | 5 | import ( 6 | context "context" 7 | json "encoding/json" 8 | 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // MockTaskRunner is an autogenerated mock type for the TaskRunner type 13 | type MockTaskRunner struct { 14 | mock.Mock 15 | } 16 | 17 | // Name provides a mock function with given fields: 18 | func (_m *MockTaskRunner) Name() string { 19 | ret := _m.Called() 20 | 21 | var r0 string 22 | if rf, ok := ret.Get(0).(func() string); ok { 23 | r0 = rf() 24 | } else { 25 | r0 = ret.Get(0).(string) 26 | } 27 | 28 | return r0 29 | } 30 | 31 | // Run provides a mock function with given fields: ctx, args 32 | func (_m *MockTaskRunner) Run(ctx context.Context, args json.RawMessage) error { 33 | ret := _m.Called(ctx, args) 34 | 35 | var r0 error 36 | if rf, ok := ret.Get(0).(func(context.Context, json.RawMessage) error); ok { 37 | r0 = rf(ctx, args) 38 | } else { 39 | r0 = ret.Error(0) 40 | } 41 | 42 | return r0 43 | } 44 | 45 | // NewMockTaskRunner creates a new instance of MockTaskRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 46 | // The first argument is typically a *testing.T value. 47 | func NewMockTaskRunner(t interface { 48 | mock.TestingT 49 | Cleanup(func()) 50 | }) *MockTaskRunner { 51 | mock := &MockTaskRunner{} 52 | mock.Mock.Test(t) 53 | 54 | t.Cleanup(func() { mock.AssertExpectations(t) }) 55 | 56 | return mock 57 | } 58 | -------------------------------------------------------------------------------- /internal/service/tasks/scheduler/init.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/service/tasks/internal/taskstorage" 7 | "github.com/theduckcompany/duckcloud/internal/tools" 8 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 9 | ) 10 | 11 | //go:generate mockery --name Service 12 | type Service interface { 13 | Run(ctx context.Context) error 14 | RegisterFileUploadTask(ctx context.Context, args *FileUploadArgs) error 15 | RegisterFSMoveTask(ctx context.Context, args *FSMoveArgs) error 16 | RegisterUserCreateTask(ctx context.Context, args *UserCreateArgs) error 17 | RegisterUserDeleteTask(ctx context.Context, args *UserDeleteArgs) error 18 | RegisterFSRefreshSizeTask(ctx context.Context, args *FSRefreshSizeArg) error 19 | RegisterFSRemoveDuplicateFile(ctx context.Context, args *FSRemoveDuplicateFileArgs) error 20 | RegisterSpaceCreateTask(ctx context.Context, args *SpaceCreateArgs) error 21 | } 22 | 23 | func Init(db sqlstorage.Querier, tools tools.Tools) Service { 24 | storage := taskstorage.NewSqlStorage(db) 25 | 26 | return NewService(storage, tools) 27 | } 28 | -------------------------------------------------------------------------------- /internal/service/users/init.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/service/tasks/scheduler" 7 | "github.com/theduckcompany/duckcloud/internal/tools" 8 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 9 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | const ( 14 | BoostrapUsername = "admin" 15 | BoostrapPassword = "duckcloud" 16 | ) 17 | 18 | //go:generate mockery --name Service 19 | type Service interface { 20 | Create(ctx context.Context, user *CreateCmd) (*User, error) 21 | Bootstrap(ctx context.Context) (*User, error) 22 | GetByID(ctx context.Context, userID uuid.UUID) (*User, error) 23 | Authenticate(ctx context.Context, username string, password secret.Text) (*User, error) 24 | GetAll(ctx context.Context, paginateCmd *sqlstorage.PaginateCmd) ([]User, error) 25 | AddToDeletion(ctx context.Context, userID uuid.UUID) error 26 | HardDelete(ctx context.Context, userID uuid.UUID) error 27 | GetAllWithStatus(ctx context.Context, status Status, cmd *sqlstorage.PaginateCmd) ([]User, error) 28 | MarkInitAsFinished(ctx context.Context, userID uuid.UUID) (*User, error) 29 | UpdateUserPassword(ctx context.Context, cmd *UpdatePasswordCmd) error 30 | } 31 | 32 | func Init( 33 | tools tools.Tools, 34 | db sqlstorage.Querier, 35 | scheduler scheduler.Service, 36 | ) Service { 37 | store := newSqlStorage(db) 38 | 39 | return newService(tools, store, scheduler) 40 | } 41 | -------------------------------------------------------------------------------- /internal/service/users/model_examples.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 7 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 8 | ) 9 | 10 | var now = time.Now().UTC() 11 | 12 | var ExampleAlice = User{ 13 | id: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 14 | username: "Alice", 15 | isAdmin: true, 16 | status: Active, 17 | password: secret.NewText("alice-encrypted-password"), 18 | passwordChangedAt: now, 19 | createdAt: now, 20 | createdBy: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 21 | } 22 | 23 | var ExampleBob = User{ 24 | id: uuid.UUID("0923c86c-24b6-4b9d-9050-e82b8408edf4"), 25 | username: "Bob", 26 | isAdmin: false, 27 | status: Active, 28 | password: secret.NewText("bob-encrypted-password"), 29 | passwordChangedAt: now, 30 | createdAt: now, 31 | createdBy: ExampleAlice.id, 32 | } 33 | 34 | var ExampleInitializingBob = User{ 35 | id: uuid.UUID("0923c86c-24b6-4b9d-9050-e82b8408edf4"), 36 | username: "Bob", 37 | isAdmin: false, 38 | status: Initializing, 39 | password: secret.NewText("bob-encrypted-password"), 40 | passwordChangedAt: now, 41 | createdAt: now, 42 | createdBy: ExampleAlice.id, 43 | } 44 | 45 | var ExampleDeletingAlice = User{ 46 | id: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 47 | username: "Alice", 48 | isAdmin: true, 49 | status: Deleting, 50 | password: secret.NewText("alice-encrypted-password"), 51 | passwordChangedAt: now, 52 | createdAt: now, 53 | createdBy: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 54 | } 55 | -------------------------------------------------------------------------------- /internal/service/users/model_helper.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/brianvoe/gofakeit/v7" 9 | "github.com/stretchr/testify/require" 10 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 11 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 12 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 13 | ) 14 | 15 | type FakeUserBuilder struct { 16 | t testing.TB 17 | user *User 18 | } 19 | 20 | func NewFakeUser(t testing.TB) *FakeUserBuilder { 21 | t.Helper() 22 | 23 | uuidProvider := uuid.NewProvider() 24 | createdAt := gofakeit.DateRange(time.Now().Add(-time.Hour*1000), time.Now()) 25 | 26 | return &FakeUserBuilder{ 27 | t: t, 28 | user: &User{ 29 | id: uuidProvider.New(), 30 | createdAt: createdAt, 31 | passwordChangedAt: createdAt, 32 | username: gofakeit.Username(), 33 | password: secret.NewText(gofakeit.Password(true, true, true, false, false, 8)), 34 | status: Active, 35 | createdBy: uuidProvider.New(), 36 | isAdmin: false, 37 | }, 38 | } 39 | } 40 | 41 | func (f *FakeUserBuilder) WithPassword(password string) *FakeUserBuilder { 42 | f.user.password = secret.NewText(password) 43 | 44 | return f 45 | } 46 | 47 | func (f *FakeUserBuilder) WithUsername(username string) *FakeUserBuilder { 48 | f.user.username = username 49 | 50 | return f 51 | } 52 | 53 | func (f *FakeUserBuilder) WithAdminRole() *FakeUserBuilder { 54 | f.user.isAdmin = true 55 | 56 | return f 57 | } 58 | 59 | func (f *FakeUserBuilder) WithStatus(status Status) *FakeUserBuilder { 60 | f.user.status = status 61 | 62 | return f 63 | } 64 | 65 | func (f *FakeUserBuilder) Build() *User { 66 | return f.user 67 | } 68 | 69 | func (f *FakeUserBuilder) BuildAndStore(ctx context.Context, db sqlstorage.Querier) *User { 70 | f.t.Helper() 71 | 72 | storage := newSqlStorage(db) 73 | 74 | err := storage.Save(ctx, f.user) 75 | require.NoError(f.t, err) 76 | 77 | return f.user 78 | } 79 | -------------------------------------------------------------------------------- /internal/service/users/model_test.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "testing" 5 | 6 | validation "github.com/go-ozzo/ozzo-validation/v4" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | func Test_User_Getters(t *testing.T) { 14 | assert.Equal(t, ExampleAlice.id, ExampleAlice.ID()) 15 | assert.Equal(t, ExampleAlice.username, ExampleAlice.Username()) 16 | assert.Equal(t, ExampleAlice.isAdmin, ExampleAlice.IsAdmin()) 17 | assert.Equal(t, ExampleAlice.createdAt, ExampleAlice.CreatedAt()) 18 | assert.Equal(t, ExampleAlice.status, ExampleAlice.Status()) 19 | } 20 | 21 | func Test_CreateUserRequest_is_validatable(t *testing.T) { 22 | assert.Implements(t, (*validation.Validatable)(nil), new(CreateCmd)) 23 | } 24 | 25 | func Test_CreateUserRequest_Validate_success(t *testing.T) { 26 | err := CreateCmd{ 27 | CreatedBy: &ExampleAlice, 28 | Username: "some-username", 29 | Password: secret.NewText("myLittleSecret"), 30 | IsAdmin: true, 31 | }.Validate() 32 | 33 | require.NoError(t, err) 34 | } 35 | 36 | func Test_UpdatePasswordCmd(t *testing.T) { 37 | err := UpdatePasswordCmd{ 38 | UserID: uuid.UUID("some-invalid-id"), 39 | NewPassword: secret.NewText("foobar1234"), 40 | }.Validate() 41 | 42 | require.EqualError(t, err, "UserID: must be a valid UUID v4.") 43 | } 44 | -------------------------------------------------------------------------------- /internal/service/utilities/http.go: -------------------------------------------------------------------------------- 1 | package utilities 2 | 3 | import ( 4 | "net/http" 5 | "net/http/pprof" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/theduckcompany/duckcloud/internal/tools/router" 9 | ) 10 | 11 | type HTTPHandler struct{} 12 | 13 | func NewHTTPHandler() *HTTPHandler { 14 | return &HTTPHandler{} 15 | } 16 | 17 | func (t *HTTPHandler) Register(r chi.Router, _ *router.Middlewares) { 18 | r.HandleFunc("/debug/pprof/", pprof.Index) 19 | r.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) 20 | r.HandleFunc("/debug/pprof/profile", pprof.Profile) 21 | r.HandleFunc("/debug/pprof/symbol", pprof.Symbol) 22 | r.HandleFunc("/debug/pprof/trace", pprof.Trace) 23 | 24 | r.HandleFunc("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 25 | w.Header().Add("Content-Type", "text/plain; charset=UTF-8") 26 | w.Write([]byte("User-agent: *\nDisallow: /")) 27 | })) 28 | } 29 | -------------------------------------------------------------------------------- /internal/service/utilities/http_test.go: -------------------------------------------------------------------------------- 1 | package utilities 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func Test_Robot_txt(t *testing.T) { 13 | w := httptest.NewRecorder() 14 | r := httptest.NewRequest(http.MethodGet, "/robots.txt", nil) 15 | srv := chi.NewRouter() 16 | NewHTTPHandler().Register(srv, nil) 17 | srv.ServeHTTP(w, r) 18 | 19 | res := w.Result() 20 | defer res.Body.Close() 21 | 22 | assert.Equal(t, "User-agent: *\nDisallow: /", w.Body.String()) 23 | } 24 | -------------------------------------------------------------------------------- /internal/service/websessions/init.go: -------------------------------------------------------------------------------- 1 | package websessions 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/theduckcompany/duckcloud/internal/tools" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | "github.com/theduckcompany/duckcloud/internal/tools/sqlstorage" 11 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 12 | ) 13 | 14 | var ( 15 | ErrMissingSessionToken = errors.New("missing session token") 16 | ErrSessionNotFound = errors.New("session not found") 17 | ) 18 | 19 | //go:generate mockery --name Service 20 | type Service interface { 21 | Create(ctx context.Context, cmd *CreateCmd) (*Session, error) 22 | GetByToken(ctx context.Context, token secret.Text) (*Session, error) 23 | GetFromReq(r *http.Request) (*Session, error) 24 | Logout(r *http.Request, w http.ResponseWriter) error 25 | GetAllForUser(ctx context.Context, userID uuid.UUID, cmd *sqlstorage.PaginateCmd) ([]Session, error) 26 | Delete(ctx context.Context, cmd *DeleteCmd) error 27 | DeleteAll(ctx context.Context, userID uuid.UUID) error 28 | } 29 | 30 | func Init(tools tools.Tools, db sqlstorage.Querier) Service { 31 | storage := newSQLStorage(db) 32 | 33 | return newService(storage, tools) 34 | } 35 | -------------------------------------------------------------------------------- /internal/service/websessions/model.go: -------------------------------------------------------------------------------- 1 | package websessions 2 | 3 | import ( 4 | "time" 5 | 6 | v "github.com/go-ozzo/ozzo-validation" 7 | "github.com/go-ozzo/ozzo-validation/is" 8 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 9 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 10 | ) 11 | 12 | type Session struct { 13 | createdAt time.Time 14 | token secret.Text 15 | userID uuid.UUID 16 | ip string 17 | device string 18 | } 19 | 20 | func (s *Session) Token() secret.Text { return s.token } 21 | func (s *Session) UserID() uuid.UUID { return s.userID } 22 | func (s *Session) IP() string { return s.ip } 23 | func (s *Session) Device() string { return s.device } 24 | func (s *Session) CreatedAt() time.Time { return s.createdAt } 25 | 26 | type CreateCmd struct { 27 | UserID uuid.UUID 28 | UserAgent string 29 | RemoteAddr string 30 | } 31 | 32 | func (t CreateCmd) Validate() error { 33 | return v.ValidateStruct(&t, 34 | v.Field(&t.UserID, v.Required, is.UUIDv4), 35 | v.Field(&t.UserAgent, v.Required), 36 | v.Field(&t.RemoteAddr, v.Required), 37 | ) 38 | } 39 | 40 | type DeleteCmd struct { 41 | UserID uuid.UUID 42 | Token secret.Text 43 | } 44 | 45 | func (t DeleteCmd) Validate() error { 46 | return v.ValidateStruct(&t, 47 | v.Field(&t.UserID, v.Required, is.UUIDv4), 48 | v.Field(&t.Token, v.Required), 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /internal/service/websessions/model_example.go: -------------------------------------------------------------------------------- 1 | package websessions 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 7 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 8 | ) 9 | 10 | var ( 11 | now = time.Now() 12 | AliceWebSessionExample = Session{ 13 | token: secret.NewText("3a708fc5-dc10-4655-8fc2-33b08a4b33a5"), 14 | userID: uuid.UUID("86bffce3-3f53-4631-baf8-8530773884f3"), 15 | ip: "192.168.1.1", 16 | device: "Android - Chrome", 17 | createdAt: now, 18 | } 19 | 20 | BobWebSessionExample = Session{ 21 | token: secret.NewText("b9d8fc98-d71f-4f76-a23a-3411a48ef34e"), 22 | userID: uuid.UUID("0923c86c-24b6-4b9d-9050-e82b8408edf4"), 23 | ip: "192.168.1.1", 24 | device: "Android - Chrome", 25 | createdAt: now, 26 | } 27 | ) 28 | -------------------------------------------------------------------------------- /internal/service/websessions/model_helper.go: -------------------------------------------------------------------------------- 1 | package websessions 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/brianvoe/gofakeit/v7" 8 | "github.com/theduckcompany/duckcloud/internal/service/users" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | type FakeSessionBuilder struct { 14 | session *Session 15 | } 16 | 17 | func NewFakeSession(t *testing.T) *FakeSessionBuilder { 18 | t.Helper() 19 | 20 | uuidProvider := uuid.NewProvider() 21 | 22 | createdAt := gofakeit.DateRange(time.Now().Add(-time.Hour*1000), time.Now()) 23 | rawToken := gofakeit.Password(true, true, true, false, false, 8) 24 | 25 | return &FakeSessionBuilder{ 26 | session: &Session{ 27 | createdAt: createdAt, 28 | token: secret.NewText(rawToken), 29 | userID: uuidProvider.New(), 30 | ip: gofakeit.IPv4Address(), 31 | device: gofakeit.AppName(), 32 | }, 33 | } 34 | } 35 | 36 | func (f *FakeSessionBuilder) CreatedAt(at time.Time) *FakeSessionBuilder { 37 | f.session.createdAt = at 38 | 39 | return f 40 | } 41 | 42 | func (f *FakeSessionBuilder) CreatedBy(user *users.User) *FakeSessionBuilder { 43 | f.session.userID = user.ID() 44 | 45 | return f 46 | } 47 | 48 | func (f *FakeSessionBuilder) WithToken(token string) *FakeSessionBuilder { 49 | f.session.token = secret.NewText(token) 50 | 51 | return f 52 | } 53 | 54 | func (f *FakeSessionBuilder) WithIP(ip string) *FakeSessionBuilder { 55 | f.session.ip = ip 56 | 57 | return f 58 | } 59 | 60 | func (f *FakeSessionBuilder) WithDevice(device string) *FakeSessionBuilder { 61 | f.session.device = device 62 | 63 | return f 64 | } 65 | 66 | func (f *FakeSessionBuilder) Build() *Session { 67 | return f.session 68 | } 69 | -------------------------------------------------------------------------------- /internal/service/websessions/model_test.go: -------------------------------------------------------------------------------- 1 | package websessions 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 11 | ) 12 | 13 | func TestSessionTypes(t *testing.T) { 14 | now := time.Now() 15 | session := Session{ 16 | token: secret.NewText("some-token"), 17 | userID: uuid.UUID("3a708fc5-dc10-4655-8fc2-33b08a4b33a5"), 18 | ip: "192.168.1.1", 19 | device: "Android - Chrome", 20 | createdAt: now, 21 | } 22 | 23 | assert.Equal(t, "some-token", session.Token().Raw()) 24 | assert.Equal(t, uuid.UUID("3a708fc5-dc10-4655-8fc2-33b08a4b33a5"), session.UserID()) 25 | assert.Equal(t, "192.168.1.1", session.IP()) 26 | assert.Equal(t, "Android - Chrome", session.Device()) 27 | assert.Equal(t, now, session.CreatedAt()) 28 | } 29 | 30 | func Test_CreateCmd_Validate(t *testing.T) { 31 | t.Run("success", func(t *testing.T) { 32 | cmd := CreateCmd{ 33 | UserID: "3a708fc5-dc10-4655-8fc2-33b08a4b33a5", 34 | UserAgent: "firefox 4.4.4", 35 | RemoteAddr: "192.168.1.1:3927", 36 | } 37 | 38 | require.NoError(t, cmd.Validate()) 39 | }) 40 | 41 | t.Run("with an error", func(t *testing.T) { 42 | cmd := CreateCmd{ 43 | UserID: "some-invalid-id", 44 | UserAgent: "firefox 4.4.4", 45 | RemoteAddr: "192.168.1.1:3927", 46 | } 47 | 48 | require.EqualError(t, cmd.Validate(), "UserID: must be a valid UUID v4.") 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /internal/tasks/init.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "github.com/theduckcompany/duckcloud/internal/service/davsessions" 5 | "github.com/theduckcompany/duckcloud/internal/service/dfs" 6 | "github.com/theduckcompany/duckcloud/internal/service/oauthconsents" 7 | "github.com/theduckcompany/duckcloud/internal/service/oauthsessions" 8 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 9 | "github.com/theduckcompany/duckcloud/internal/service/tasks/runner" 10 | "github.com/theduckcompany/duckcloud/internal/service/users" 11 | "github.com/theduckcompany/duckcloud/internal/service/websessions" 12 | "go.uber.org/fx" 13 | ) 14 | 15 | type Result struct { 16 | fx.Out 17 | UserDeleteTask runner.TaskRunner `group:"tasks"` 18 | UserCreateTask runner.TaskRunner `group:"tasks"` 19 | SpaceCreateTask runner.TaskRunner `group:"tasks"` 20 | } 21 | 22 | func Init( 23 | fs dfs.Service, 24 | spaces spaces.Service, 25 | users users.Service, 26 | webSessions websessions.Service, 27 | davSessions davsessions.Service, 28 | oauthSessions oauthsessions.Service, 29 | oauthConsents oauthconsents.Service, 30 | ) Result { 31 | return Result{ 32 | UserCreateTask: NewUserCreateTaskRunner(users, spaces, fs), 33 | UserDeleteTask: NewUserDeleteTaskRunner(users, webSessions, davSessions, oauthSessions, oauthConsents, spaces, fs), 34 | SpaceCreateTask: NewSpaceCreateTaskRunner(users, spaces, fs), 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/tasks/spacecreate.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/theduckcompany/duckcloud/internal/service/dfs" 9 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 10 | "github.com/theduckcompany/duckcloud/internal/service/tasks/scheduler" 11 | "github.com/theduckcompany/duckcloud/internal/service/users" 12 | ) 13 | 14 | type SpaceCreateTaskRunner struct { 15 | users users.Service 16 | spaces spaces.Service 17 | fs dfs.Service 18 | } 19 | 20 | func NewSpaceCreateTaskRunner(users users.Service, spaces spaces.Service, fs dfs.Service) *SpaceCreateTaskRunner { 21 | return &SpaceCreateTaskRunner{users, spaces, fs} 22 | } 23 | 24 | func (r *SpaceCreateTaskRunner) Name() string { return "space-create" } 25 | 26 | func (r *SpaceCreateTaskRunner) Run(ctx context.Context, rawArgs json.RawMessage) error { 27 | var args scheduler.SpaceCreateArgs 28 | err := json.Unmarshal(rawArgs, &args) 29 | if err != nil { 30 | return fmt.Errorf("failed to unmarshal the args: %w", err) 31 | } 32 | 33 | return r.RunArgs(ctx, &args) 34 | } 35 | 36 | func (r *SpaceCreateTaskRunner) RunArgs(ctx context.Context, args *scheduler.SpaceCreateArgs) error { 37 | user, err := r.users.GetByID(ctx, args.UserID) 38 | if err != nil { 39 | return fmt.Errorf("failed to get the user by id (%q): %w", args.UserID, err) 40 | } 41 | 42 | space, err := r.spaces.Create(ctx, &spaces.CreateCmd{ 43 | User: user, 44 | Name: args.Name, 45 | Owners: args.Owners, 46 | }) 47 | if err != nil { 48 | return fmt.Errorf("failed to create the space: %w", err) 49 | } 50 | 51 | ctx = context.WithoutCancel(ctx) 52 | 53 | _, err = r.fs.CreateFS(ctx, user, space) 54 | if err != nil { 55 | return fmt.Errorf("failed to create the fs for space %q: %w", space.Name(), err) 56 | } 57 | 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /internal/tasks/usercreate.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/theduckcompany/duckcloud/internal/service/dfs" 9 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 10 | "github.com/theduckcompany/duckcloud/internal/service/tasks/scheduler" 11 | "github.com/theduckcompany/duckcloud/internal/service/users" 12 | ) 13 | 14 | type UserCreateTaskRunner struct { 15 | users users.Service 16 | spaces spaces.Service 17 | fs dfs.Service 18 | } 19 | 20 | func NewUserCreateTaskRunner(users users.Service, spaces spaces.Service, fs dfs.Service) *UserCreateTaskRunner { 21 | return &UserCreateTaskRunner{users, spaces, fs} 22 | } 23 | 24 | func (r *UserCreateTaskRunner) Name() string { return "user-create" } 25 | 26 | func (r *UserCreateTaskRunner) Run(ctx context.Context, rawArgs json.RawMessage) error { 27 | var args scheduler.UserCreateArgs 28 | err := json.Unmarshal(rawArgs, &args) 29 | if err != nil { 30 | return fmt.Errorf("failed to unmarshal the args: %w", err) 31 | } 32 | 33 | return r.RunArgs(ctx, &args) 34 | } 35 | 36 | func (r *UserCreateTaskRunner) RunArgs(ctx context.Context, args *scheduler.UserCreateArgs) error { 37 | user, err := r.users.GetByID(ctx, args.UserID) 38 | if err != nil { 39 | return fmt.Errorf("failed to retrieve the user %q: %w", args.UserID, err) 40 | } 41 | 42 | switch user.Status() { 43 | case users.Initializing: 44 | // This is expected, continue 45 | case users.Active, users.Deleting: 46 | // Already initialized or inside the deletion process, do nothing 47 | return nil 48 | default: 49 | return fmt.Errorf("unepected user status: %s", user.Status()) 50 | } 51 | 52 | _, err = r.users.MarkInitAsFinished(ctx, args.UserID) 53 | if err != nil { 54 | return fmt.Errorf("failed to MarkInitAsFinished: %w", err) 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/tools/buildinfos/buildinfos.go: -------------------------------------------------------------------------------- 1 | package buildinfos 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | version string = "unknown" 9 | // buildTime string = "unknown" 10 | isRelease string = "false" 11 | ) 12 | 13 | var ErrNotSet = errors.New("not set") 14 | 15 | // IsRelease is set to true if the binary is an official release. 16 | // All the other builds will return false. 17 | func IsRelease() bool { 18 | return isRelease == "true" 19 | } 20 | 21 | // Version of the release or "unknown" 22 | func Version() string { 23 | return version 24 | } 25 | 26 | // XXX: Unused 27 | // 28 | // // BuildTime is ISO-8601 UTC string representation of the time of 29 | // // the build or "time.Time{}" 30 | // func BuildTime() (time.Time, error) { 31 | // if buildTime == "unknown" { 32 | // return time.Time{}, ErrNotSet 33 | // } 34 | 35 | // raw, err := time.Parse(time.RFC3339, buildTime) 36 | 37 | // return raw, err 38 | // } 39 | -------------------------------------------------------------------------------- /internal/tools/clock/clock_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package clock 4 | 5 | import ( 6 | time "time" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // MockClock is an autogenerated mock type for the Clock type 12 | type MockClock struct { 13 | mock.Mock 14 | } 15 | 16 | // Now provides a mock function with given fields: 17 | func (_m *MockClock) Now() time.Time { 18 | ret := _m.Called() 19 | 20 | var r0 time.Time 21 | if rf, ok := ret.Get(0).(func() time.Time); ok { 22 | r0 = rf() 23 | } else { 24 | r0 = ret.Get(0).(time.Time) 25 | } 26 | 27 | return r0 28 | } 29 | 30 | // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 31 | // The first argument is typically a *testing.T value. 32 | func NewMockClock(t interface { 33 | mock.TestingT 34 | Cleanup(func()) 35 | }) *MockClock { 36 | mock := &MockClock{} 37 | mock.Mock.Test(t) 38 | 39 | t.Cleanup(func() { mock.AssertExpectations(t) }) 40 | 41 | return mock 42 | } 43 | -------------------------------------------------------------------------------- /internal/tools/clock/init.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import "time" 4 | 5 | // Clock is used to give time. 6 | // 7 | //go:generate mockery --name Clock 8 | type Clock interface { 9 | Now() time.Time 10 | } 11 | -------------------------------------------------------------------------------- /internal/tools/clock/init_test.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestClockImplementations(t *testing.T) { 10 | assert.Implements(t, (*Clock)(nil), new(Default)) 11 | assert.Implements(t, (*Clock)(nil), new(Stub)) 12 | assert.Implements(t, (*Clock)(nil), new(MockClock)) 13 | } 14 | -------------------------------------------------------------------------------- /internal/tools/clock/stub.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import "time" 4 | 5 | // Stub is a stub implementation of Clock 6 | type Stub struct { 7 | Time time.Time 8 | } 9 | 10 | // Now stub method. 11 | func (t *Stub) Now() time.Time { 12 | return t.Time 13 | } 14 | -------------------------------------------------------------------------------- /internal/tools/clock/stub_test.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestStub(t *testing.T) { 11 | t.Run("Now return the given date", func(t *testing.T) { 12 | someDate := time.Now().Add(4 * time.Hour) 13 | 14 | stub := &Stub{Time: someDate} 15 | now := stub.Now() 16 | 17 | assert.Equal(t, someDate, now) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /internal/tools/clock/time.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import "time" 4 | 5 | // Default is a Clock implementation base ton time.Now() 6 | type Default struct{} 7 | 8 | // NewDefault create a new Clock. 9 | func NewDefault() *Default { 10 | return &Default{} 11 | } 12 | 13 | // Now return the time for the exact moment. 14 | func (t *Default) Now() time.Time { 15 | return time.Now().UTC() 16 | } 17 | -------------------------------------------------------------------------------- /internal/tools/clock/time_test.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDefaultClock(t *testing.T) { 11 | t.Run("Now returns now", func(t *testing.T) { 12 | now := NewDefault().Now() 13 | assert.WithinDuration(t, time.Now(), now, 2*time.Millisecond) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /internal/tools/cron/cron.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "sync" 7 | "time" 8 | 9 | "github.com/theduckcompany/duckcloud/internal/tools" 10 | "go.uber.org/fx" 11 | ) 12 | 13 | //go:generate mockery --name CronRunner 14 | type CronRunner interface { 15 | Run(ctx context.Context) error 16 | } 17 | 18 | type Cron struct { 19 | job CronRunner 20 | quit chan struct{} 21 | cancel context.CancelFunc 22 | log *slog.Logger 23 | lock *sync.Mutex 24 | pauseDuration time.Duration 25 | } 26 | 27 | func New(name string, pauseDuration time.Duration, tools tools.Tools, job CronRunner) *Cron { 28 | log := tools.Logger().With(slog.String("cron", name)) 29 | 30 | return &Cron{ 31 | pauseDuration: pauseDuration, 32 | quit: make(chan struct{}), 33 | cancel: nil, 34 | log: log, 35 | job: job, 36 | lock: new(sync.Mutex), 37 | } 38 | } 39 | 40 | func (s *Cron) RunLoop() { 41 | ticker := time.NewTicker(s.pauseDuration) 42 | 43 | s.lock.Lock() 44 | ctx, cancel := context.WithCancel(context.Background()) 45 | s.cancel = cancel 46 | s.lock.Unlock() 47 | 48 | for { 49 | select { 50 | case <-ticker.C: 51 | err := s.job.Run(ctx) 52 | if err != nil { 53 | s.log.Error("fs gc error", slog.String("error", err.Error())) 54 | } 55 | case <-s.quit: 56 | ticker.Stop() 57 | } 58 | } 59 | } 60 | 61 | func (s *Cron) Stop() { 62 | close(s.quit) 63 | 64 | // The lock is required to avoid a datarace on `s.cancel` 65 | s.lock.Lock() 66 | defer s.lock.Unlock() 67 | if s.cancel != nil { 68 | s.cancel() 69 | } 70 | } 71 | 72 | func (s *Cron) FXRegister(lc fx.Lifecycle) { 73 | if lc != nil { 74 | lc.Append(fx.Hook{ 75 | OnStart: func(context.Context) error { 76 | //nolint:contextcheck // there is no context 77 | go s.RunLoop() 78 | return nil 79 | }, 80 | OnStop: func(context.Context) error { 81 | s.Stop() 82 | return nil 83 | }, 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /internal/tools/cron/cron_runner_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package cron 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // MockCronRunner is an autogenerated mock type for the CronRunner type 12 | type MockCronRunner struct { 13 | mock.Mock 14 | } 15 | 16 | // Run provides a mock function with given fields: ctx 17 | func (_m *MockCronRunner) Run(ctx context.Context) error { 18 | ret := _m.Called(ctx) 19 | 20 | var r0 error 21 | if rf, ok := ret.Get(0).(func(context.Context) error); ok { 22 | r0 = rf(ctx) 23 | } else { 24 | r0 = ret.Error(0) 25 | } 26 | 27 | return r0 28 | } 29 | 30 | // NewMockCronRunner creates a new instance of MockCronRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 31 | // The first argument is typically a *testing.T value. 32 | func NewMockCronRunner(t interface { 33 | mock.TestingT 34 | Cleanup(func()) 35 | }) *MockCronRunner { 36 | mock := &MockCronRunner{} 37 | mock.Mock.Test(t) 38 | 39 | t.Cleanup(func() { mock.AssertExpectations(t) }) 40 | 41 | return mock 42 | } 43 | -------------------------------------------------------------------------------- /internal/tools/cron/cron_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | mock "github.com/stretchr/testify/mock" 9 | "github.com/theduckcompany/duckcloud/internal/tools" 10 | ) 11 | 12 | func TestCron(t *testing.T) { 13 | t.Run("Start an async job and stop it with Stop", func(t *testing.T) { 14 | tools := tools.NewMock(t) 15 | cronRunner := NewMockCronRunner(t) 16 | 17 | runner := New("test-1", time.Second, tools, cronRunner) 18 | 19 | // Start the async job. The first call is done 1s after the call to Start 20 | go func(cron *Cron) { 21 | cron.RunLoop() 22 | }(runner) 23 | 24 | // Stop will interrupt the job before the second. 25 | runner.Stop() 26 | }) 27 | 28 | t.Run("Stop interrupt the running job", func(t *testing.T) { 29 | tools := tools.NewMock(t) 30 | cronRunner := NewMockCronRunner(t) 31 | 32 | runner := New("test-1", time.Millisecond, tools, cronRunner) 33 | 34 | go runner.RunLoop() 35 | 36 | // First loop to fetch the deleted inodes. Make it take more than a 1s. 37 | cronRunner.On("Run", mock.Anything).WaitUntil(time.After(time.Minute)).Return(nil).Once() 38 | 39 | // Wait some time in order to be just to have the job running and waiting for the end of "GetAllDeleted". 40 | time.Sleep(20 * time.Millisecond) 41 | 42 | // Stop will interrupt the job before the second. 43 | start := time.Now() 44 | runner.Stop() 45 | assert.WithinDuration(t, time.Now(), start, 10*time.Millisecond) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /internal/tools/default_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "log/slog" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/theduckcompany/duckcloud/internal/tools/clock" 9 | "github.com/theduckcompany/duckcloud/internal/tools/password" 10 | "github.com/theduckcompany/duckcloud/internal/tools/response" 11 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 12 | ) 13 | 14 | func TestDefaultToolbox(t *testing.T) { 15 | tools := NewToolbox(Config{}) 16 | 17 | assert.IsType(t, new(clock.Default), tools.Clock()) 18 | assert.IsType(t, new(uuid.Default), tools.UUID()) 19 | assert.IsType(t, new(response.Default), tools.ResWriter()) 20 | assert.IsType(t, new(slog.Logger), tools.Logger()) 21 | assert.IsType(t, new(password.Argon2IDPassword), tools.Password()) 22 | } 23 | 24 | func TestToolboxForTest(t *testing.T) { 25 | tools := NewToolboxForTest(t) 26 | 27 | assert.IsType(t, new(clock.Default), tools.Clock()) 28 | assert.IsType(t, new(uuid.Default), tools.UUID()) 29 | assert.IsType(t, new(response.Default), tools.ResWriter()) 30 | assert.IsType(t, new(slog.Logger), tools.Logger()) 31 | assert.IsType(t, new(password.Argon2IDPassword), tools.Password()) 32 | } 33 | -------------------------------------------------------------------------------- /internal/tools/init.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/clock" 7 | "github.com/theduckcompany/duckcloud/internal/tools/password" 8 | "github.com/theduckcompany/duckcloud/internal/tools/response" 9 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 10 | ) 11 | 12 | // Tools regroup all the utilities required for a working server. 13 | type Tools interface { 14 | Clock() clock.Clock 15 | UUID() uuid.Service 16 | Logger() *slog.Logger 17 | ResWriter() response.Writer 18 | Password() password.Password 19 | } 20 | -------------------------------------------------------------------------------- /internal/tools/init_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestToolsImplementations(t *testing.T) { 10 | assert.Implements(t, (*Tools)(nil), new(Toolbox)) 11 | assert.Implements(t, (*Tools)(nil), new(Mock)) 12 | } 13 | -------------------------------------------------------------------------------- /internal/tools/logger/init.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | ) 7 | 8 | type Config struct { 9 | Output io.Writer 10 | Level slog.Level `mapstructure:"level"` 11 | } 12 | 13 | func NewSLogger(cfg Config) *slog.Logger { 14 | return slog.New(slog.NewJSONHandler(cfg.Output, &slog.HandlerOptions{ 15 | Level: cfg.Level, 16 | // Remove default time slog.Attr. It will be replaced by the one 17 | // from the router middleware. 18 | ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { 19 | if a.Key == slog.TimeKey { 20 | return slog.Attr{} 21 | } 22 | return a 23 | }, 24 | })) 25 | } 26 | -------------------------------------------------------------------------------- /internal/tools/mock.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "log/slog" 5 | "testing" 6 | 7 | "github.com/neilotoole/slogt" 8 | "github.com/theduckcompany/duckcloud/internal/tools/clock" 9 | "github.com/theduckcompany/duckcloud/internal/tools/password" 10 | "github.com/theduckcompany/duckcloud/internal/tools/response" 11 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 12 | ) 13 | 14 | type Mock struct { 15 | ClockMock *clock.MockClock 16 | UUIDMock *uuid.MockService 17 | LogTest *slog.Logger 18 | PasswordMock *password.MockPassword 19 | ResWriterMock *response.MockWriter 20 | } 21 | 22 | func NewMock(t *testing.T) *Mock { 23 | t.Helper() 24 | 25 | return &Mock{ 26 | ClockMock: clock.NewMockClock(t), 27 | UUIDMock: uuid.NewMockService(t), 28 | LogTest: slogt.New(t), 29 | PasswordMock: password.NewMockPassword(t), 30 | ResWriterMock: response.NewMockWriter(t), 31 | } 32 | } 33 | 34 | // Clock implements App. 35 | func (m *Mock) Clock() clock.Clock { 36 | return m.ClockMock 37 | } 38 | 39 | // UUID implements App. 40 | func (m *Mock) UUID() uuid.Service { 41 | return m.UUIDMock 42 | } 43 | 44 | // Logger implements App. 45 | func (m *Mock) Logger() *slog.Logger { 46 | return m.LogTest 47 | } 48 | 49 | func (m *Mock) ResWriter() response.Writer { 50 | return m.ResWriterMock 51 | } 52 | 53 | func (m *Mock) Password() password.Password { 54 | return m.PasswordMock 55 | } 56 | -------------------------------------------------------------------------------- /internal/tools/mock_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "log/slog" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/theduckcompany/duckcloud/internal/tools/clock" 9 | "github.com/theduckcompany/duckcloud/internal/tools/password" 10 | "github.com/theduckcompany/duckcloud/internal/tools/response" 11 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 12 | ) 13 | 14 | func TestMockToolbox(t *testing.T) { 15 | tools := NewMock(t) 16 | 17 | assert.IsType(t, new(clock.MockClock), tools.Clock()) 18 | assert.IsType(t, new(uuid.MockService), tools.UUID()) 19 | assert.IsType(t, new(response.MockWriter), tools.ResWriter()) 20 | 21 | assert.IsType(t, new(slog.Logger), tools.Logger()) 22 | assert.IsType(t, new(password.MockPassword), tools.Password()) 23 | } 24 | -------------------------------------------------------------------------------- /internal/tools/password/default_test.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 10 | ) 11 | 12 | func TestBcryptPassword(t *testing.T) { 13 | ctx := context.Background() 14 | 15 | t.Run("Encrypt/Compare success", func(t *testing.T) { 16 | password := &Argon2IDPassword{} 17 | 18 | hashed, err := password.Encrypt(ctx, secret.NewText("some-password")) 19 | require.NoError(t, err) 20 | require.NotEqual(t, "some-password", hashed) 21 | 22 | ok, err := password.Compare(ctx, hashed, secret.NewText("some-password")) 23 | assert.True(t, ok) 24 | require.NoError(t, err) 25 | }) 26 | 27 | t.Run("Decrypte with a no base64 string", func(t *testing.T) { 28 | password := &Argon2IDPassword{} 29 | 30 | ok, err := password.Compare(ctx, secret.NewText("not a hex string#"), secret.NewText("some-password")) 31 | assert.False(t, ok) 32 | require.EqualError(t, err, "failed to decode the hash: the encoded hash is not in the correct format") 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /internal/tools/password/init.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 7 | ) 8 | 9 | //go:generate mockery --name Password 10 | type Password interface { 11 | Encrypt(ctx context.Context, password secret.Text) (secret.Text, error) 12 | Compare(ctx context.Context, hash, password secret.Text) (bool, error) 13 | } 14 | -------------------------------------------------------------------------------- /internal/tools/ptr/ptr.go: -------------------------------------------------------------------------------- 1 | package ptr 2 | 3 | // To returns a pointer from any types 4 | func To[K any](input K) *K { 5 | return &input 6 | } 7 | -------------------------------------------------------------------------------- /internal/tools/response/default.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/theduckcompany/duckcloud/internal/tools/errs" 9 | "github.com/theduckcompany/duckcloud/internal/tools/logger" 10 | "github.com/unrolled/render" 11 | ) 12 | 13 | // Default is used to write the response into an http.ResponseWriter and log the error. 14 | type Default struct { 15 | render *render.Render 16 | } 17 | 18 | // New return a new Default. 19 | func New(render *render.Render) *Default { 20 | return &Default{render} 21 | } 22 | 23 | // Write the given res as a json body and statusCode. 24 | func (t *Default) WriteJSON(w http.ResponseWriter, r *http.Request, statusCode int, res any) { 25 | if err, ok := res.(error); ok { 26 | t.WriteJSONError(w, r, err) 27 | return 28 | } 29 | 30 | if err := t.render.JSON(w, statusCode, res); err != nil { 31 | logger.LogEntrySetAttrs(r.Context(), slog.String("render-error", err.Error())) 32 | } 33 | } 34 | 35 | // WriteJSONError write the given error into the ResponseWriter. 36 | func (t *Default) WriteJSONError(w http.ResponseWriter, r *http.Request, err error) { 37 | var ierr *errs.Error 38 | 39 | logger.LogEntrySetError(r.Context(), err) 40 | 41 | if !errors.As(err, &ierr) { 42 | //nolint:errorlint // Is casted just above 43 | ierr = errs.Unhandled(err).(*errs.Error) 44 | } 45 | 46 | if rerr := t.render.JSON(w, ierr.Code(), ierr); rerr != nil { 47 | logger.LogEntrySetAttrs(r.Context(), slog.String("render-error", err.Error())) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/tools/response/init.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/unrolled/render" 7 | ) 8 | 9 | type Config struct { 10 | PrettyRender bool `mapstructure:"prettyRender"` 11 | } 12 | 13 | //go:generate mockery --name Writer 14 | type Writer interface { 15 | WriteJSON(w http.ResponseWriter, r *http.Request, statusCode int, res any) 16 | WriteJSONError(w http.ResponseWriter, r *http.Request, err error) 17 | } 18 | 19 | func Init(cfg Config) Writer { 20 | opts := render.Options{} 21 | 22 | if cfg.PrettyRender { 23 | opts.IndentJSON = true 24 | opts.IndentXML = true 25 | } 26 | 27 | return New(render.New(opts)) 28 | } 29 | -------------------------------------------------------------------------------- /internal/tools/response/init_test.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestWriterImplementations(t *testing.T) { 10 | assert.Implements(t, (*Writer)(nil), new(Default)) 11 | assert.Implements(t, (*Writer)(nil), new(MockWriter)) 12 | } 13 | -------------------------------------------------------------------------------- /internal/tools/response/writer_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package response 4 | 5 | import ( 6 | http "net/http" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // MockWriter is an autogenerated mock type for the Writer type 12 | type MockWriter struct { 13 | mock.Mock 14 | } 15 | 16 | // WriteJSON provides a mock function with given fields: w, r, statusCode, res 17 | func (_m *MockWriter) WriteJSON(w http.ResponseWriter, r *http.Request, statusCode int, res interface{}) { 18 | _m.Called(w, r, statusCode, res) 19 | } 20 | 21 | // WriteJSONError provides a mock function with given fields: w, r, err 22 | func (_m *MockWriter) WriteJSONError(w http.ResponseWriter, r *http.Request, err error) { 23 | _m.Called(w, r, err) 24 | } 25 | 26 | // NewMockWriter creates a new instance of MockWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 27 | // The first argument is typically a *testing.T value. 28 | func NewMockWriter(t interface { 29 | mock.TestingT 30 | Cleanup(func()) 31 | }) *MockWriter { 32 | mock := &MockWriter{} 33 | mock.Mock.Test(t) 34 | 35 | t.Cleanup(func() { mock.AssertExpectations(t) }) 36 | 37 | return mock 38 | } 39 | -------------------------------------------------------------------------------- /internal/tools/secret/text_test.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | import ( 4 | "log/slog" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestText(t *testing.T) { 12 | s1 := NewText("hello") 13 | 14 | t.Run("MarshalJSON", func(t *testing.T) { 15 | res, err := s1.MarshalJSON() 16 | require.NoError(t, err) 17 | 18 | assert.Equal(t, `"*****"`, string(res)) 19 | }) 20 | 21 | t.Run("MarshalText", func(t *testing.T) { 22 | res, err := s1.MarshalText() 23 | require.NoError(t, err) 24 | 25 | assert.Equal(t, `*****`, string(res)) 26 | }) 27 | 28 | t.Run("String", func(t *testing.T) { 29 | assert.Equal(t, `*****`, s1.String()) 30 | }) 31 | 32 | t.Run("String", func(t *testing.T) { 33 | assert.Equal(t, `*****`, s1.String()) 34 | }) 35 | 36 | t.Run("Raw", func(t *testing.T) { 37 | assert.Equal(t, "hello", s1.Raw()) 38 | }) 39 | 40 | t.Run("Equals", func(t *testing.T) { 41 | s2 := NewText("hello") 42 | 43 | assert.True(t, s1.Equals(s2)) 44 | assert.True(t, s2.Equals(s1)) 45 | }) 46 | 47 | t.Run("UnmarshalJSON", func(t *testing.T) { 48 | var res Text 49 | 50 | err := res.UnmarshalJSON([]byte(`"foobar"`)) 51 | require.NoError(t, err) 52 | 53 | assert.Equal(t, "foobar", res.Raw()) 54 | }) 55 | 56 | t.Run("UnmarshalText", func(t *testing.T) { 57 | var res Text 58 | 59 | err := res.UnmarshalText([]byte(`foobar`)) 60 | require.NoError(t, err) 61 | 62 | assert.Equal(t, "foobar", res.Raw()) 63 | }) 64 | 65 | t.Run("Value", func(t *testing.T) { 66 | v, err := s1.Value() 67 | require.NoError(t, err) 68 | 69 | assert.Equal(t, "hello", v) 70 | }) 71 | 72 | t.Run("Logvalue", func(t *testing.T) { 73 | assert.Implements(t, (*slog.LogValuer)(nil), s1) 74 | 75 | res := s1.LogValue() 76 | assert.Equal(t, slog.StringValue(RedactText), res) 77 | }) 78 | 79 | t.Run("Scan", func(t *testing.T) { 80 | var res Text 81 | 82 | err := res.Scan("foobar") 83 | require.NoError(t, err) 84 | 85 | assert.Equal(t, "foobar", res.Raw()) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /internal/tools/sqlstorage/client_test.go: -------------------------------------------------------------------------------- 1 | package sqlstorage 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNewSQliteClient(t *testing.T) { 11 | t.Run("success", func(t *testing.T) { 12 | cfg := Config{Path: t.TempDir() + "/db.sqlite"} 13 | 14 | client, err := NewSQliteClient(&cfg) 15 | require.NoError(t, err) 16 | 17 | require.NoError(t, client.Ping()) 18 | }) 19 | 20 | t.Run("with an invalid path", func(t *testing.T) { 21 | cfg := Config{Path: "/foo/some-invalidpath"} 22 | 23 | client, err := NewSQliteClient(&cfg) 24 | assert.Nil(t, client) 25 | require.EqualError(t, err, "unable to open database file: no such file or directory") 26 | }) 27 | 28 | t.Run("with not specified path", func(t *testing.T) { 29 | cfg := Config{Path: ""} 30 | 31 | client, err := NewSQliteClient(&cfg) 32 | assert.NotNil(t, client) 33 | require.NoError(t, err) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /internal/tools/sqlstorage/init.go: -------------------------------------------------------------------------------- 1 | package sqlstorage 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | ) 7 | 8 | func Init(cfg Config) (*sql.DB, Querier, error) { 9 | db, err := NewSQliteClient(&cfg) 10 | if err != nil { 11 | return nil, nil, fmt.Errorf("sqlite error: %w", err) 12 | } 13 | 14 | querier := NewSQLQuerier(db) 15 | 16 | return db, querier, nil 17 | } 18 | -------------------------------------------------------------------------------- /internal/tools/sqlstorage/paginate.go: -------------------------------------------------------------------------------- 1 | package sqlstorage 2 | 3 | import ( 4 | "errors" 5 | 6 | sq "github.com/Masterminds/squirrel" 7 | ) 8 | 9 | var ErrNonMatchingOrderAndStart = errors.New("OrderBy and StartAfter doesn't have the same number of arguments") 10 | 11 | type PaginateCmd struct { 12 | StartAfter map[string]string 13 | Limit int 14 | } 15 | 16 | func PaginateSelection(query sq.SelectBuilder, cmd *PaginateCmd) sq.SelectBuilder { 17 | if cmd == nil { 18 | return query 19 | } 20 | 21 | orderBy := []string{} 22 | for key := range cmd.StartAfter { 23 | orderBy = append(orderBy, key) 24 | } 25 | 26 | query = query.OrderBy(orderBy...) 27 | 28 | // TODO: Check that all the values in `cmd.OrderBy` are valid fields 29 | 30 | if len(cmd.StartAfter) > 0 { 31 | eqs := make(sq.Gt, len(cmd.StartAfter)) 32 | 33 | for key, val := range cmd.StartAfter { 34 | eqs[key] = val 35 | } 36 | 37 | query = query.Where(eqs) 38 | } 39 | 40 | if cmd.Limit > 0 { 41 | query = query.Limit(uint64(cmd.Limit)) 42 | } 43 | 44 | return query 45 | } 46 | -------------------------------------------------------------------------------- /internal/tools/sqlstorage/paginate_test.go: -------------------------------------------------------------------------------- 1 | package sqlstorage 2 | 3 | import ( 4 | "testing" 5 | 6 | sq "github.com/Masterminds/squirrel" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestPaginate(t *testing.T) { 12 | t.Run("success", func(t *testing.T) { 13 | query := PaginateSelection(sq.Select("col-a", "col-b"), &PaginateCmd{ 14 | StartAfter: map[string]string{"col-a": "some-value"}, 15 | Limit: 10, 16 | }) 17 | 18 | raw, args, err := query.ToSql() 19 | require.NoError(t, err) 20 | 21 | assert.Equal(t, `SELECT col-a, col-b WHERE col-a > ? ORDER BY col-a LIMIT 10`, raw) 22 | assert.EqualValues(t, []interface{}{"some-value"}, args) 23 | }) 24 | t.Run("success with nil", func(t *testing.T) { 25 | query := PaginateSelection(sq.Select("col-a", "col-b"), nil) 26 | 27 | raw, args, err := query.ToSql() 28 | require.NoError(t, err) 29 | 30 | assert.Equal(t, `SELECT col-a, col-b`, raw) 31 | assert.EqualValues(t, []interface{}(nil), args) 32 | }) 33 | 34 | t.Run("success multi-select", func(t *testing.T) { 35 | query := PaginateSelection(sq.Select("col-a", "col-b"), &PaginateCmd{ 36 | StartAfter: map[string]string{ 37 | "col-a": "some-val-a", 38 | "col-b": "some-val-b", 39 | }, 40 | Limit: 10, 41 | }) 42 | 43 | raw, args, err := query.ToSql() 44 | require.NoError(t, err) 45 | 46 | assert.Contains(t, raw, `SELECT col-a, col-b WHERE col-a > ? AND col-b > ?`) 47 | assert.Contains(t, raw, `ORDER BY col-`) 48 | assert.Contains(t, raw, `LIMIT 10`) 49 | 50 | assert.EqualValues(t, []interface{}{"some-val-a", "some-val-b"}, args) 51 | }) 52 | 53 | t.Run("without limit", func(t *testing.T) { 54 | query := PaginateSelection(sq.Select("col-a", "col-b"), &PaginateCmd{ 55 | StartAfter: map[string]string{}, 56 | Limit: -1, 57 | }) 58 | 59 | raw, args, err := query.ToSql() 60 | require.NoError(t, err) 61 | 62 | assert.Equal(t, `SELECT col-a, col-b`, raw) 63 | assert.Empty(t, args) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /internal/tools/sqlstorage/test_client.go: -------------------------------------------------------------------------------- 1 | package sqlstorage 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/theduckcompany/duckcloud/internal/migrations" 8 | ) 9 | 10 | func NewTestStorage(t *testing.T) Querier { 11 | cfg := Config{Path: ":memory:"} 12 | 13 | db, err := NewSQliteClient(&cfg) 14 | require.NoError(t, err) 15 | 16 | err = db.Ping() 17 | require.NoError(t, err) 18 | 19 | err = migrations.Run(db, nil) 20 | require.NoError(t, err) 21 | 22 | querier := NewSQLQuerier(db) 23 | 24 | return querier 25 | } 26 | -------------------------------------------------------------------------------- /internal/tools/sqlstorage/test_client_test.go: -------------------------------------------------------------------------------- 1 | package sqlstorage 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestTestStorage(t *testing.T) { 11 | client := NewTestStorage(t) 12 | 13 | row := client.QueryRow(`SELECT COUNT(*) FROM sqlite_schema 14 | where type='table' AND name NOT LIKE 'sqlite_%'`) 15 | 16 | require.NoError(t, row.Err()) 17 | var res int 18 | row.Scan(&res) 19 | 20 | // There is more than 3 tables 21 | assert.Greater(t, res, 3) 22 | } 23 | -------------------------------------------------------------------------------- /internal/tools/sqlstorage/time.go: -------------------------------------------------------------------------------- 1 | package sqlstorage 2 | 3 | import ( 4 | "database/sql/driver" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type SQLTime time.Time 10 | 11 | func (t SQLTime) Value() (driver.Value, error) { 12 | tt := time.Time(t) 13 | 14 | resStr, err := tt.UTC().MarshalText() 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return string(resStr), nil 20 | } 21 | 22 | func (t *SQLTime) Scan(value any) error { 23 | tt := (*time.Time)(t) 24 | 25 | switch v := value.(type) { 26 | case string: 27 | err := (*tt).UnmarshalText([]byte(v)) 28 | (*tt) = tt.UTC() 29 | return err 30 | default: 31 | return fmt.Errorf("unsuported type: %T", v) 32 | } 33 | } 34 | 35 | func (t SQLTime) Time() time.Time { 36 | return time.Time(t) 37 | } 38 | -------------------------------------------------------------------------------- /internal/tools/startutils/get-free-port.go: -------------------------------------------------------------------------------- 1 | package startutils 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | // GetFreePort asks the kernel for a free open port that is ready to use. 11 | func GetFreePort(t *testing.T) int { 12 | t.Helper() 13 | 14 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 15 | require.NoError(t, err) 16 | 17 | l, err := net.ListenTCP("tcp", addr) 18 | require.NoError(t, err) 19 | 20 | defer l.Close() 21 | return l.Addr().(*net.TCPAddr).Port 22 | } 23 | -------------------------------------------------------------------------------- /internal/tools/uuid/default.go: -------------------------------------------------------------------------------- 1 | package uuid 2 | 3 | import "github.com/google/uuid" 4 | 5 | // Default implementation of UUIDProvider 6 | type Default struct{} 7 | 8 | // NewProvider return a new Default uuid provider. 9 | func NewProvider() *Default { 10 | return &Default{} 11 | } 12 | 13 | // New implementation of uuid.Provider 14 | func (t Default) New() UUID { 15 | return UUID(uuid.Must(uuid.NewRandom()).String()) 16 | } 17 | 18 | // Parse implementation of uuid.Provider 19 | func (t Default) Parse(s string) (UUID, error) { 20 | u, err := uuid.Parse(s) 21 | if err != nil { 22 | return UUID(""), err 23 | } 24 | 25 | return UUID(u.String()), nil 26 | } 27 | -------------------------------------------------------------------------------- /internal/tools/uuid/default_test.go: -------------------------------------------------------------------------------- 1 | package uuid 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-ozzo/ozzo-validation/v4/is" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestUUID(t *testing.T) { 12 | t.Run("new", func(t *testing.T) { 13 | uuidSvc := NewProvider() 14 | 15 | id := uuidSvc.New() 16 | assert.NotEmpty(t, id) 17 | require.NoError(t, is.UUIDv4.Validate(id)) 18 | }) 19 | 20 | t.Run("parse success", func(t *testing.T) { 21 | uuidSvc := NewProvider() 22 | 23 | id, err := uuidSvc.Parse("bcb5425c-fa31-4b46-a6d3-1e4a35cacf93") 24 | require.NoError(t, err) 25 | assert.Equal(t, UUID("bcb5425c-fa31-4b46-a6d3-1e4a35cacf93"), id) 26 | }) 27 | 28 | t.Run("parse error", func(t *testing.T) { 29 | uuidSvc := NewProvider() 30 | 31 | id, err := uuidSvc.Parse("some-invalid-id") 32 | assert.Empty(t, id) 33 | require.EqualError(t, err, "invalid UUID length: 15") 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /internal/tools/uuid/init.go: -------------------------------------------------------------------------------- 1 | package uuid 2 | 3 | // UUID custome type 4 | type UUID string 5 | 6 | //go:generate mockery --name Service 7 | type Service interface { 8 | // New create a new UUID V4 9 | New() UUID 10 | Parse(string) (UUID, error) 11 | } 12 | -------------------------------------------------------------------------------- /internal/tools/uuid/init_test.go: -------------------------------------------------------------------------------- 1 | package uuid 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestServiceImplementations(t *testing.T) { 10 | assert.Implements(t, (*Service)(nil), new(Default)) 11 | assert.Implements(t, (*Service)(nil), new(Stub)) 12 | assert.Implements(t, (*Service)(nil), new(MockService)) 13 | } 14 | -------------------------------------------------------------------------------- /internal/tools/uuid/service_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package uuid 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // MockService is an autogenerated mock type for the Service type 8 | type MockService struct { 9 | mock.Mock 10 | } 11 | 12 | // New provides a mock function with given fields: 13 | func (_m *MockService) New() UUID { 14 | ret := _m.Called() 15 | 16 | var r0 UUID 17 | if rf, ok := ret.Get(0).(func() UUID); ok { 18 | r0 = rf() 19 | } else { 20 | r0 = ret.Get(0).(UUID) 21 | } 22 | 23 | return r0 24 | } 25 | 26 | // Parse provides a mock function with given fields: _a0 27 | func (_m *MockService) Parse(_a0 string) (UUID, error) { 28 | ret := _m.Called(_a0) 29 | 30 | var r0 UUID 31 | var r1 error 32 | if rf, ok := ret.Get(0).(func(string) (UUID, error)); ok { 33 | return rf(_a0) 34 | } 35 | if rf, ok := ret.Get(0).(func(string) UUID); ok { 36 | r0 = rf(_a0) 37 | } else { 38 | r0 = ret.Get(0).(UUID) 39 | } 40 | 41 | if rf, ok := ret.Get(1).(func(string) error); ok { 42 | r1 = rf(_a0) 43 | } else { 44 | r1 = ret.Error(1) 45 | } 46 | 47 | return r0, r1 48 | } 49 | 50 | // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 51 | // The first argument is typically a *testing.T value. 52 | func NewMockService(t interface { 53 | mock.TestingT 54 | Cleanup(func()) 55 | }) *MockService { 56 | mock := &MockService{} 57 | mock.Mock.Test(t) 58 | 59 | t.Cleanup(func() { mock.AssertExpectations(t) }) 60 | 61 | return mock 62 | } 63 | -------------------------------------------------------------------------------- /internal/tools/uuid/stub.go: -------------------------------------------------------------------------------- 1 | package uuid 2 | 3 | // Stub implementation of uuid.Provider. 4 | type Stub struct { 5 | UUID string 6 | } 7 | 8 | // New stub method. 9 | func (t *Stub) New() UUID { 10 | return UUID(t.UUID) 11 | } 12 | 13 | // Parse stub method. 14 | // 15 | // This method really parse the input. 16 | func (t *Stub) Parse(s string) (UUID, error) { 17 | return NewProvider().Parse(s) 18 | } 19 | -------------------------------------------------------------------------------- /internal/web/auth/page_masterpassword_ask.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/theduckcompany/duckcloud/internal/service/masterkey" 10 | "github.com/theduckcompany/duckcloud/internal/tools/errs" 11 | "github.com/theduckcompany/duckcloud/internal/tools/router" 12 | "github.com/theduckcompany/duckcloud/internal/tools/secret" 13 | "github.com/theduckcompany/duckcloud/internal/web/html" 14 | "github.com/theduckcompany/duckcloud/internal/web/html/templates/auth" 15 | ) 16 | 17 | type MasterAskPasswordPage struct { 18 | html html.Writer 19 | masterkey masterkey.Service 20 | } 21 | 22 | func NewAskMasterPasswordPage(html html.Writer, masterkey masterkey.Service) *MasterAskPasswordPage { 23 | return &MasterAskPasswordPage{ 24 | html: html, 25 | masterkey: masterkey, 26 | } 27 | } 28 | 29 | func (h *MasterAskPasswordPage) Register(r chi.Router, mids *router.Middlewares) { 30 | if mids != nil { 31 | r = r.With(mids.Defaults()...) 32 | } 33 | 34 | r.Get("/master-password/ask", h.printPage) 35 | r.Post("/master-password/ask", h.postForm) 36 | } 37 | 38 | func (h *MasterAskPasswordPage) printPage(w http.ResponseWriter, r *http.Request) { 39 | if h.masterkey.IsMasterKeyLoaded() { 40 | http.Redirect(w, r, "/", http.StatusSeeOther) 41 | return 42 | } 43 | 44 | h.html.WriteHTMLTemplate(w, r, http.StatusOK, &auth.AskMasterPasswordPageTmpl{}) 45 | } 46 | 47 | func (h *MasterAskPasswordPage) postForm(w http.ResponseWriter, r *http.Request) { 48 | password := secret.NewText(r.FormValue("password")) 49 | 50 | err := h.masterkey.LoadMasterKeyFromPassword(r.Context(), &password) 51 | if errors.Is(err, errs.ErrBadRequest) { 52 | h.html.WriteHTMLTemplate(w, r, http.StatusOK, &auth.AskMasterPasswordPageTmpl{ 53 | ErrorMsg: "invalid password", 54 | }) 55 | return 56 | } 57 | 58 | if err != nil { 59 | h.html.WriteHTMLErrorPage(w, r, fmt.Errorf("failed to load the master key from password: %w", err)) 60 | return 61 | } 62 | 63 | http.Redirect(w, r, "/", http.StatusFound) 64 | } 65 | -------------------------------------------------------------------------------- /internal/web/auth/utils.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/theduckcompany/duckcloud/internal/service/users" 9 | "github.com/theduckcompany/duckcloud/internal/service/websessions" 10 | "github.com/theduckcompany/duckcloud/internal/web/html" 11 | ) 12 | 13 | type AccessType int 14 | 15 | const ( 16 | AdminOnly AccessType = iota 17 | AnyUser 18 | ) 19 | 20 | type Authenticator struct { 21 | webSessions websessions.Service 22 | users users.Service 23 | html html.Writer 24 | } 25 | 26 | func NewAuthenticator(webSessions websessions.Service, users users.Service, html html.Writer) *Authenticator { 27 | return &Authenticator{webSessions, users, html} 28 | } 29 | 30 | func (a *Authenticator) GetUserAndSession(w http.ResponseWriter, r *http.Request, access AccessType) (*users.User, *websessions.Session, bool) { 31 | currentSession, err := a.webSessions.GetFromReq(r) 32 | switch { 33 | case err == nil: 34 | break 35 | case errors.Is(err, websessions.ErrSessionNotFound): 36 | a.webSessions.Logout(r, w) 37 | return nil, nil, true 38 | case errors.Is(err, websessions.ErrMissingSessionToken): 39 | http.Redirect(w, r, "/login", http.StatusFound) 40 | return nil, nil, true 41 | default: 42 | a.html.WriteHTMLErrorPage(w, r, fmt.Errorf("failed to websessions.GetFromReq: %w", err)) 43 | return nil, nil, true 44 | } 45 | 46 | user, err := a.users.GetByID(r.Context(), currentSession.UserID()) 47 | if err != nil { 48 | a.html.WriteHTMLErrorPage(w, r, err) 49 | return nil, nil, true 50 | } 51 | 52 | if user == nil { 53 | _ = a.webSessions.Logout(r, w) 54 | return nil, nil, true 55 | } 56 | 57 | if access == AdminOnly && !user.IsAdmin() { 58 | w.WriteHeader(http.StatusUnauthorized) 59 | w.Write([]byte(`
Action reserved to admins
`)) 60 | return nil, nil, true 61 | } 62 | 63 | return user, currentSession, false 64 | } 65 | 66 | func (a *Authenticator) Logout(w http.ResponseWriter, r *http.Request) { 67 | a.webSessions.Logout(r, w) 68 | } 69 | -------------------------------------------------------------------------------- /internal/web/browser/utils.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/theduckcompany/duckcloud/internal/service/dfs" 10 | "github.com/theduckcompany/duckcloud/internal/service/files" 11 | ) 12 | 13 | func serveContent(w http.ResponseWriter, r *http.Request, inode *dfs.INode, file io.ReadSeeker, fileMeta *files.FileMeta) { 14 | if fileMeta != nil { 15 | w.Header().Set("ETag", fmt.Sprintf("W/%q", fileMeta.Checksum())) 16 | w.Header().Set("Content-Type", fileMeta.MimeType()) 17 | } 18 | 19 | w.Header().Set("Expires", time.Now().Add(365*24*time.Hour).UTC().Format(http.TimeFormat)) 20 | w.Header().Set("Cache-Control", "max-age=31536000") 21 | 22 | http.ServeContent(w, r, inode.Name(), inode.LastModifiedAt(), file) 23 | } 24 | -------------------------------------------------------------------------------- /internal/web/home.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | "github.com/theduckcompany/duckcloud/internal/tools/router" 8 | "github.com/theduckcompany/duckcloud/internal/web/auth" 9 | "github.com/theduckcompany/duckcloud/internal/web/html" 10 | "github.com/theduckcompany/duckcloud/internal/web/html/templates/home" 11 | ) 12 | 13 | type HomePage struct { 14 | html html.Writer 15 | auth *auth.Authenticator 16 | } 17 | 18 | func NewHomePage( 19 | html html.Writer, 20 | auth *auth.Authenticator, 21 | ) *HomePage { 22 | return &HomePage{html, auth} 23 | } 24 | 25 | func (h *HomePage) Register(r chi.Router, mids *router.Middlewares) { 26 | if mids != nil { 27 | r = r.With(mids.Defaults()...) 28 | } 29 | 30 | r.Get("/", h.getHome) 31 | r.Get("/logout", h.logout) 32 | } 33 | 34 | func (h *HomePage) logout(w http.ResponseWriter, r *http.Request) { 35 | h.auth.Logout(w, r) 36 | } 37 | 38 | func (h *HomePage) getHome(w http.ResponseWriter, r *http.Request) { 39 | _, _, abort := h.auth.GetUserAndSession(w, r, auth.AnyUser) 40 | if abort { 41 | return 42 | } 43 | 44 | h.html.WriteHTMLTemplate(w, r, http.StatusOK, &home.HomePageTmpl{}) 45 | } 46 | -------------------------------------------------------------------------------- /internal/web/html/templates/auth/layout.html: -------------------------------------------------------------------------------- 1 | 2 | {{template "header"}} 3 | 4 | 5 | 6 | {{ yield }} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /internal/web/html/templates/auth/page_consent.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | 7 |
8 |
9 |
10 |

Authorize

11 |
12 |

Hello {{ .Username }} !

13 |

The client {{ .ClientName }} would like to perform following actions on your behalf.

14 |

15 |

    16 | {{ range $val := .Scopes }} 17 |
  • {{ $val }}
  • 18 | {{ end }} 19 |
20 | 23 |

24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /internal/web/html/templates/auth/page_error.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |

Oauth2 Error

7 |

Sorry, something went wrong during your authentication

8 |

{{.ErrorMsg}}

9 |
10 | Return Home 11 |
12 |
13 |
Details for the support:
14 | 15 | RequestID: {{.RequestID}} 16 | 17 |
18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /internal/web/html/templates/auth/page_masterpassword_ask.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | 7 |
8 |
9 |
10 |

Master password 11 | 15 |

16 |

17 |
18 | 19 |
20 |
21 | 22 | 23 |
{{ .ErrorMsg }}
24 |
25 |
26 | 27 |
28 | 31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | 40 | 45 | -------------------------------------------------------------------------------- /internal/web/html/templates/auth/templates.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "html/template" 4 | 5 | type LoginPageTmpl struct { 6 | UsernameContent string 7 | UsernameError string 8 | 9 | PasswordError string 10 | } 11 | 12 | func (t *LoginPageTmpl) Template() string { return "auth/page_login" } 13 | 14 | type ErrorPageTmpl struct { 15 | ErrorMsg string 16 | RequestID string 17 | } 18 | 19 | func (t *ErrorPageTmpl) Template() string { return "auth/page_error" } 20 | 21 | type ConsentPageTmpl struct { 22 | Username string 23 | Redirect template.URL 24 | ClientName string 25 | Scopes []string 26 | } 27 | 28 | func (t *ConsentPageTmpl) Template() string { return "auth/page_consent" } 29 | 30 | type AskMasterPasswordPageTmpl struct { 31 | ErrorMsg string 32 | } 33 | 34 | func (t *AskMasterPasswordPageTmpl) Template() string { return "auth/page_masterpassword_ask" } 35 | 36 | type RegisterMasterPasswordPageTmpl struct { 37 | PasswordError string 38 | ConfirmError string 39 | } 40 | 41 | func (t *RegisterMasterPasswordPageTmpl) Template() string { 42 | return "auth/page_masterpassword_register" 43 | } 44 | -------------------------------------------------------------------------------- /internal/web/html/templates/browser/modal_create_dir.html: -------------------------------------------------------------------------------- 1 | 33 | 34 | 43 | -------------------------------------------------------------------------------- /internal/web/html/templates/browser/modal_move.html: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /internal/web/html/templates/browser/modal_move_rows.html: -------------------------------------------------------------------------------- 1 | {{ $idx := 0}} 2 | {{range $path, $inode := $.FolderContent}} 3 | {{ $folderURL := pathJoin "/browser/move" $.DstPath.Space.ID $.DstPath.Path}} 4 | {{ $inodeURL := pathJoin $folderURL $inode.Name}} 5 | {{ $filePath := pathJoin $.DstPath.Path .Name}} 6 | 7 |
12 |
13 | {{ if and (.IsDir) (not ($.SrcPath.Contains $path))}} 14 | 18 | 19 | {{.Name}} 20 | 21 | {{else}} 22 | 23 | 24 | {{.Name}} 25 | 26 | {{end}} 27 |
28 |
29 | 30 | {{ $idx = add $idx 1}} 31 | 32 | {{end}} 33 | -------------------------------------------------------------------------------- /internal/web/html/templates/browser/modal_rename.html: -------------------------------------------------------------------------------- 1 | 33 | 34 | 43 | -------------------------------------------------------------------------------- /internal/web/html/templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /internal/web/html/templates/home/401.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |

401

7 |

Unauthorized access

8 |

You don't have the permissions to be here.

9 |
10 | Return Home 11 |
12 |
13 |
Details for the support:
14 | 15 | RequestID: {{.requestID}} 16 | 17 |
18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /internal/web/html/templates/home/500.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |

500

7 |

Sorry, something went wrong!

8 |

Hm... Unfortunately, the server crashed. Apologies.

9 |
10 | Return Home 11 |
12 |
13 |
Details for the support:
14 | 15 | RequestID: {{.requestID}} 16 | 17 |
18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /internal/web/html/templates/home/layout.html: -------------------------------------------------------------------------------- 1 | 2 | {{ template "header"}} 3 | 4 | 5 |
6 | {{ yield }} 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /internal/web/html/templates/home/page.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |

Welcome Home!

8 |
9 |
10 |
11 | 12 |
13 |
14 |
15 | 16 |

Browser

17 |
18 | 19 | 20 |
21 | 22 |

Settings

23 |
24 | 25 |
26 |
27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /internal/web/html/templates/home/templates.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | type HomePageTmpl struct{} 4 | 5 | func (t *HomePageTmpl) Template() string { return "home/page" } 6 | -------------------------------------------------------------------------------- /internal/web/html/templates/home/templates_test.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/theduckcompany/duckcloud/internal/web/html" 12 | ) 13 | 14 | func Test_Templates(t *testing.T) { 15 | renderer := html.NewRenderer(html.Config{ 16 | PrettyRender: false, 17 | HotReload: false, 18 | }) 19 | 20 | tests := []struct { 21 | Template html.Templater 22 | Name string 23 | Layout bool 24 | }{ 25 | { 26 | Name: "HomePageTmpl", 27 | Layout: true, 28 | Template: &HomePageTmpl{}, 29 | }, 30 | } 31 | 32 | for _, test := range tests { 33 | t.Run(test.Name, func(t *testing.T) { 34 | w := httptest.NewRecorder() 35 | r := httptest.NewRequest(http.MethodGet, "/foo", nil) 36 | 37 | if !test.Layout { 38 | r.Header.Add("HX-Boosted", "true") 39 | } 40 | 41 | renderer.WriteHTMLTemplate(w, r, http.StatusOK, test.Template) 42 | 43 | if !assert.Equal(t, http.StatusOK, w.Code) { 44 | res := w.Result() 45 | res.Body.Close() 46 | body, err := io.ReadAll(res.Body) 47 | require.NoError(t, err) 48 | t.Log(string(body)) 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/web/html/templates/settings/security/templates.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | import ( 4 | "github.com/theduckcompany/duckcloud/internal/service/davsessions" 5 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 6 | "github.com/theduckcompany/duckcloud/internal/service/websessions" 7 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 8 | ) 9 | 10 | type ContentTemplate struct { 11 | IsAdmin bool 12 | CurrentSession *websessions.Session 13 | WebSessions []websessions.Session 14 | Devices []davsessions.DavSession 15 | Spaces map[uuid.UUID]spaces.Space 16 | } 17 | 18 | func (t *ContentTemplate) Template() string { return "settings/security/page" } 19 | 20 | type PasswordFormTemplate struct { 21 | Error string 22 | } 23 | 24 | func (t *PasswordFormTemplate) Template() string { return "settings/security/password-form" } 25 | 26 | type WebdavFormTemplate struct { 27 | Error error 28 | Spaces []spaces.Space 29 | } 30 | 31 | func (t *WebdavFormTemplate) Template() string { return "settings/security/webdav-form" } 32 | 33 | type WebdavResultTemplate struct { 34 | Secret string 35 | NewSession *davsessions.DavSession 36 | } 37 | 38 | func (t *WebdavResultTemplate) Template() string { return "settings/security/webdav-result" } 39 | -------------------------------------------------------------------------------- /internal/web/html/templates/settings/security/webdav-result.html: -------------------------------------------------------------------------------- 1 | 40 | 41 | 46 | -------------------------------------------------------------------------------- /internal/web/html/templates/settings/spaces/modal_create_space.html: -------------------------------------------------------------------------------- 1 | 25 | 26 | 33 | -------------------------------------------------------------------------------- /internal/web/html/templates/settings/spaces/page.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{ range .Spaces}} 14 | 15 | 16 | 17 | 22 | 23 | 32 | 33 | 34 | {{end}} 35 | 36 |
NameUsersActions
{{.Name}} 18 | {{range .Owners}} 19 | {{ with index $.Users .}}{{.Username}}{{end}} 20 | {{end}} 21 | 24 | 25 | 31 |
37 |
38 | 39 | 49 |
50 |
51 | -------------------------------------------------------------------------------- /internal/web/html/templates/settings/spaces/templates.go: -------------------------------------------------------------------------------- 1 | package spaces 2 | 3 | import ( 4 | "github.com/theduckcompany/duckcloud/internal/service/spaces" 5 | "github.com/theduckcompany/duckcloud/internal/service/users" 6 | "github.com/theduckcompany/duckcloud/internal/tools/uuid" 7 | ) 8 | 9 | type ContentTemplate struct { 10 | IsAdmin bool 11 | Spaces []spaces.Space 12 | Users map[uuid.UUID]users.User 13 | } 14 | 15 | func (t *ContentTemplate) Template() string { return "settings/spaces/page" } 16 | 17 | type CreateSpaceModal struct { 18 | IsAdmin bool 19 | Selection UserSelectionTemplate 20 | } 21 | 22 | func (t *CreateSpaceModal) Template() string { return "settings/spaces/modal_create_space" } 23 | 24 | type UserSelectionTemplate struct { 25 | UnselectedUsers []users.User 26 | SelectedUsers []users.User 27 | } 28 | 29 | func (t *UserSelectionTemplate) Template() string { return "settings/spaces/user_selection" } 30 | -------------------------------------------------------------------------------- /internal/web/html/templates/settings/spaces/templates_test.go: -------------------------------------------------------------------------------- 1 | package spaces 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/theduckcompany/duckcloud/internal/web/html" 12 | ) 13 | 14 | func Test_Templates(t *testing.T) { 15 | renderer := html.NewRenderer(html.Config{ 16 | PrettyRender: false, 17 | HotReload: false, 18 | }) 19 | 20 | tests := []struct { 21 | Name string 22 | Template html.Templater 23 | Layout bool 24 | }{ 25 | { 26 | Name: "ContentTemplate", 27 | Layout: true, 28 | Template: &ContentTemplate{ 29 | IsAdmin: true, 30 | }, 31 | }, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run(test.Name, func(t *testing.T) { 36 | w := httptest.NewRecorder() 37 | r := httptest.NewRequest(http.MethodGet, "/foo", nil) 38 | 39 | if !test.Layout { 40 | r.Header.Add("HX-Boosted", "true") 41 | } 42 | 43 | renderer.WriteHTMLTemplate(w, r, http.StatusOK, test.Template) 44 | 45 | if !assert.Equal(t, http.StatusOK, w.Code) { 46 | res := w.Result() 47 | res.Body.Close() 48 | body, err := io.ReadAll(res.Body) 49 | require.NoError(t, err) 50 | t.Log(string(body)) 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/web/html/templates/settings/users/templates.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import "github.com/theduckcompany/duckcloud/internal/service/users" 4 | 5 | type ContentTemplate struct { 6 | Error error 7 | Current *users.User 8 | Users []users.User 9 | IsAdmin bool 10 | } 11 | 12 | func (t *ContentTemplate) Template() string { return "settings/users/page" } 13 | 14 | type RegistrationFormTemplate struct { 15 | Error error 16 | } 17 | 18 | func (t *RegistrationFormTemplate) Template() string { return "settings/users/registration-form" } 19 | -------------------------------------------------------------------------------- /internal/web/html/templates/settings/users/templates_test.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/theduckcompany/duckcloud/internal/service/users" 13 | "github.com/theduckcompany/duckcloud/internal/web/html" 14 | ) 15 | 16 | func Test_Templates(t *testing.T) { 17 | renderer := html.NewRenderer(html.Config{ 18 | PrettyRender: false, 19 | HotReload: false, 20 | }) 21 | 22 | tests := []struct { 23 | Template html.Templater 24 | Name string 25 | Layout bool 26 | }{ 27 | { 28 | Name: "ContentTemplate", 29 | Layout: true, 30 | Template: &ContentTemplate{ 31 | IsAdmin: true, 32 | Current: &users.ExampleAlice, 33 | Users: []users.User{users.ExampleAlice, users.ExampleBob}, 34 | Error: nil, 35 | }, 36 | }, 37 | { 38 | Name: "RegistrationFormTemplate", 39 | Layout: false, 40 | Template: &RegistrationFormTemplate{ 41 | Error: fmt.Errorf("some-error"), 42 | }, 43 | }, 44 | } 45 | 46 | for _, test := range tests { 47 | t.Run(test.Name, func(t *testing.T) { 48 | w := httptest.NewRecorder() 49 | r := httptest.NewRequest(http.MethodGet, "/foo", nil) 50 | 51 | if !test.Layout { 52 | r.Header.Add("HX-Boosted", "true") 53 | } 54 | 55 | renderer.WriteHTMLTemplate(w, r, http.StatusOK, test.Template) 56 | 57 | if !assert.Equal(t, http.StatusOK, w.Code) { 58 | res := w.Result() 59 | res.Body.Close() 60 | body, err := io.ReadAll(res.Body) 61 | require.NoError(t, err) 62 | t.Log(string(body)) 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/web/html/writer_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v0.0.0-dev. DO NOT EDIT. 2 | 3 | package html 4 | 5 | import ( 6 | http "net/http" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // MockWriter is an autogenerated mock type for the Writer type 12 | type MockWriter struct { 13 | mock.Mock 14 | } 15 | 16 | // WriteHTML provides a mock function with given fields: w, r, status, template, args 17 | func (_m *MockWriter) WriteHTML(w http.ResponseWriter, r *http.Request, status int, template string, args interface{}) { 18 | _m.Called(w, r, status, template, args) 19 | } 20 | 21 | // WriteHTMLErrorPage provides a mock function with given fields: w, r, err 22 | func (_m *MockWriter) WriteHTMLErrorPage(w http.ResponseWriter, r *http.Request, err error) { 23 | _m.Called(w, r, err) 24 | } 25 | 26 | // WriteHTMLTemplate provides a mock function with given fields: w, r, status, template 27 | func (_m *MockWriter) WriteHTMLTemplate(w http.ResponseWriter, r *http.Request, status int, template Templater) { 28 | _m.Called(w, r, status, template) 29 | } 30 | 31 | // NewMockWriter creates a new instance of MockWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 32 | // The first argument is typically a *testing.T value. 33 | func NewMockWriter(t interface { 34 | mock.TestingT 35 | Cleanup(func()) 36 | }) *MockWriter { 37 | mock := &MockWriter{} 38 | mock.Mock.Test(t) 39 | 40 | t.Cleanup(func() { mock.AssertExpectations(t) }) 41 | 42 | return mock 43 | } 44 | -------------------------------------------------------------------------------- /internal/web/settings/redirections.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | "github.com/theduckcompany/duckcloud/internal/tools/router" 8 | ) 9 | 10 | type Redirections struct{} 11 | 12 | func NewRedirections() *Redirections { 13 | return &Redirections{} 14 | } 15 | 16 | func (h *Redirections) Register(r chi.Router, mids *router.Middlewares) { 17 | if mids != nil { 18 | r = r.With(mids.Defaults()...) 19 | } 20 | r.Get("/settings", http.RedirectHandler("/settings/security", http.StatusMovedPermanently).ServeHTTP) 21 | } 22 | 23 | type passwordFormCmd struct { 24 | Error error 25 | } 26 | -------------------------------------------------------------------------------- /internal/web/settings/redirections_test.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func Test_RedirectionsSettings(t *testing.T) { 13 | t.Run("redirectDefaultSettings success", func(t *testing.T) { 14 | handler := NewRedirections() 15 | 16 | w := httptest.NewRecorder() 17 | r := httptest.NewRequest(http.MethodGet, "/settings", nil) 18 | srv := chi.NewRouter() 19 | handler.Register(srv, nil) 20 | srv.ServeHTTP(w, r) 21 | 22 | res := w.Result() 23 | defer res.Body.Close() 24 | assert.Equal(t, http.StatusMovedPermanently, res.StatusCode) 25 | assert.Equal(t, "/settings/security", res.Header.Get("Location")) 26 | }) 27 | } 28 | --------------------------------------------------------------------------------