├── .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 |