├── .github ├── dependabot.yaml └── workflows │ ├── build.yaml │ ├── cla.yaml │ ├── helm.yaml │ ├── lint.yaml │ ├── publish.yaml │ └── test.yaml ├── .gitignore ├── .golangci.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── api ├── api.go ├── api_test.go ├── httpapi │ ├── httpapi.go │ ├── httpapi_test.go │ ├── status_writer.go │ └── status_writer_test.go └── httpmw │ ├── buildinfo.go │ ├── buildinfo_test.go │ ├── cors.go │ ├── cors_test.go │ ├── logger.go │ ├── ratelimit.go │ ├── ratelimit_test.go │ ├── recover.go │ ├── recover_test.go │ ├── requestid.go │ └── requestid_test.go ├── buildinfo ├── buildinfo.go └── buildinfo_test.go ├── cli ├── add.go ├── add_test.go ├── remove.go ├── remove_test.go ├── root.go ├── root_test.go ├── server.go ├── server_test.go ├── signal_unix.go ├── signal_windows.go ├── signature.go ├── version.go └── version_test.go ├── cmd └── marketplace │ ├── main.go │ └── main_test.go ├── database ├── database.go ├── database_test.go └── nodb.go ├── docker-bake.hcl ├── extensionsign ├── doc.go ├── sigmanifest.go └── sigzip.go ├── fixtures ├── generate.bash ├── icon.png ├── names ├── publishers ├── upload.bash └── versions ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── helm ├── .helmignore ├── Chart.yaml ├── README.md ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── pvc.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── tests │ │ └── test-connection.yaml └── values.yaml ├── storage ├── artifactory.go ├── artifactory_test.go ├── easyzip │ ├── zip.go │ └── zip_test.go ├── local.go ├── local_test.go ├── signature.go ├── signature_test.go ├── storage.go └── storage_test.go ├── testutil ├── extensions.go ├── extensions_test.go ├── mockdb.go └── mockstorage.go └── util ├── util.go └── util_test.go /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | time: "06:00" 8 | timezone: "America/Chicago" 9 | labels: [] 10 | groups: 11 | github-actions: 12 | patterns: 13 | - "*" 14 | 15 | - package-ecosystem: "gomod" 16 | directory: "/" 17 | schedule: 18 | interval: "weekly" 19 | time: "06:00" 20 | timezone: "America/Chicago" 21 | labels: [] 22 | open-pull-requests-limit: 15 23 | groups: 24 | x: 25 | patterns: 26 | - "golang.org/x/*" 27 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write # For creating releases. 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-go@v5 18 | with: 19 | go-version: "~1.22" 20 | 21 | - name: Get Go cache paths 22 | id: go-cache-paths 23 | run: | 24 | echo "::set-output name=go-build::$(go env GOCACHE)" 25 | echo "::set-output name=go-mod::$(go env GOMODCACHE)" 26 | 27 | - name: Go build cache 28 | uses: actions/cache@v4 29 | with: 30 | path: ${{ steps.go-cache-paths.outputs.go-build }} 31 | key: ${{ runner.os }}-release-go-build-${{ hashFiles('**/go.sum') }} 32 | 33 | - name: Go mod cache 34 | uses: actions/cache@v4 35 | with: 36 | path: ${{ steps.go-cache-paths.outputs.go-mod }} 37 | key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }} 38 | 39 | - run: make build 40 | 41 | - uses: softprops/action-gh-release@v2 42 | with: 43 | draft: true 44 | files: ./bin/* 45 | -------------------------------------------------------------------------------- /.github/workflows/cla.yaml: -------------------------------------------------------------------------------- 1 | name: "CLA Assistant" 2 | on: 3 | issue_comment: 4 | types: [created] 5 | pull_request_target: 6 | types: [opened,closed,synchronize] 7 | 8 | jobs: 9 | CLAssistant: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: "CLA Assistant" 13 | if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' 14 | uses: contributor-assistant/github-action@v2.6.1 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | # the below token should have repo scope and must be manually added by you in the repository's secret 18 | PERSONAL_ACCESS_TOKEN : ${{ secrets.CDRCOMMUNITY_GITHUB_TOKEN }} 19 | with: 20 | remote-organization-name: 'coder' 21 | remote-repository-name: 'cla' 22 | path-to-signatures: 'v2022-09-04/signatures.json' 23 | path-to-document: 'https://github.com/coder/cla/blob/main/README.md' 24 | # branch should not be protected 25 | branch: 'main' 26 | allowlist: dependabot* 27 | -------------------------------------------------------------------------------- /.github/workflows/helm.yaml: -------------------------------------------------------------------------------- 1 | name: lint/helm 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "helm/**" 9 | - ".github/workflows/helm.yaml" 10 | pull_request: 11 | paths: 12 | - "helm/**" 13 | - ".github/workflows/helm.yaml" 14 | workflow_dispatch: 15 | 16 | # Cancel in-progress runs for pull requests when developers push 17 | # additional changes 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.ref }} 20 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 21 | 22 | jobs: 23 | lint: 24 | timeout-minutes: 5 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: azure/setup-helm@v4 29 | with: 30 | token: ${{ secrets.GITHUB_TOKEN }} 31 | - run: helm lint --strict ./helm 32 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: lint/go 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "**.go" 9 | - "go.sum" 10 | - ".github/workflows/lint.yaml" 11 | pull_request: 12 | paths: 13 | - "**.go" 14 | - "go.sum" 15 | - ".github/workflows/lint.yaml" 16 | workflow_dispatch: 17 | 18 | # Cancel in-progress runs for pull requests when developers push 19 | # additional changes 20 | concurrency: 21 | group: ${{ github.workflow }}-${{ github.ref }} 22 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 23 | 24 | jobs: 25 | lint: 26 | timeout-minutes: 5 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-go@v5 31 | with: 32 | go-version: 1.24.2 33 | - name: golangci-lint 34 | uses: golangci/golangci-lint-action@v7.0.0 35 | with: 36 | version: v2.1.2 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: The version to publish. 11 | type: string 12 | required: true 13 | 14 | permissions: 15 | packages: write # For pushing to ghcr.io. 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: robinraju/release-downloader@v1.12 23 | with: 24 | repository: "coder/code-marketplace" 25 | tag: ${{ github.event.inputs.version || github.ref_name }} 26 | fileName: "code-marketplace-linux-*" 27 | out-file-path: "bin" 28 | 29 | - uses: docker/login-action@v3 30 | with: 31 | registry: ghcr.io 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - uses: docker/setup-qemu-action@v3 36 | - uses: docker/setup-buildx-action@v3 37 | - run: docker buildx bake -f ./docker-bake.hcl --push 38 | env: 39 | VERSION: ${{ github.event.inputs.version || github.ref_name }} 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test/go 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "**.go" 9 | - "go.sum" 10 | - ".github/workflows/test.yaml" 11 | pull_request: 12 | paths: 13 | - "**.go" 14 | - "go.sum" 15 | - ".github/workflows/test.yaml" 16 | workflow_dispatch: 17 | 18 | # Cancel in-progress runs for pull requests when developers push 19 | # additional changes 20 | concurrency: 21 | group: ${{ github.workflow }}-${{ github.ref }} 22 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 23 | 24 | jobs: 25 | test: 26 | runs-on: ${{ matrix.os }} 27 | timeout-minutes: 20 28 | strategy: 29 | matrix: 30 | os: 31 | - ubuntu-latest 32 | - macos-latest 33 | - windows-2022 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: actions/setup-go@v5 37 | with: 38 | go-version: "~1.22" 39 | 40 | - name: Echo Go Cache Paths 41 | id: go-cache-paths 42 | run: | 43 | echo "::set-output name=go-build::$(go env GOCACHE)" 44 | echo "::set-output name=go-mod::$(go env GOMODCACHE)" 45 | 46 | - name: Go Build Cache 47 | uses: actions/cache@v4 48 | with: 49 | path: ${{ steps.go-cache-paths.outputs.go-build }} 50 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.**', '**.go') }} 51 | 52 | - name: Go Mod Cache 53 | uses: actions/cache@v4 54 | with: 55 | path: ${{ steps.go-cache-paths.outputs.go-mod }} 56 | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} 57 | 58 | - name: Install gotestsum 59 | shell: bash 60 | run: go install gotest.tools/gotestsum@latest 61 | 62 | - run: make test 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | bin 3 | coverage 4 | extensions 5 | .idea 6 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | disable: 4 | - errcheck 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ## [2.3.1](https://github.com/coder/code-marketplace/releases/tag/v2.3.1) - 2025-03-06 11 | 12 | ### Changed 13 | 14 | - Updated several dependencies with CVEs. 15 | 16 | ## [2.3.0](https://github.com/coder/code-marketplace/releases/tag/v2.3.0) - 2024-12-20 17 | 18 | ### Added 19 | 20 | - Add empty signatures when starting the server with --sign. This will not work 21 | with VS Code on Windows and macOS as we do not have the key required, but it 22 | will work for open source versions of VS Code (VSCodium, code-server) and VS 23 | Code on Linux where signatures must exist but are not actually checked. 24 | 25 | ### Changed 26 | 27 | - Ignore extensions without a manifest. This is not expected in normal use, but 28 | could happen if, for example, a manifest temporarily failed to download, which 29 | would then crash the entire process with a segfault. 30 | 31 | ## [2.2.1](https://github.com/coder/code-marketplace/releases/tag/v2.2.1) - 2024-08-14 32 | 33 | ### Fixed 34 | 35 | - The "attempt to download manually" URL in VS Code will now work. 36 | 37 | ## [2.2.0](https://github.com/coder/code-marketplace/releases/tag/v2.2.0) - 2024-07-17 38 | 39 | ### Changed 40 | 41 | - Default max page size increased from 50 to 200. 42 | 43 | ### Added 44 | 45 | - New `server` sub-command flag `--max-page-size` for setting the max page size. 46 | 47 | ## [2.1.0](https://github.com/coder/code-marketplace/releases/tag/v2.1.0) - 2023-12-21 48 | 49 | ### Added 50 | 51 | - New `server` sub-command flag `--list-cache-duration` for setting the duration 52 | of the cache used when listing and searching extensions. The default is still 53 | one minute. 54 | - Local storage will also use a cache for listing/searching extensions 55 | (previously only Artifactory storage used a cache). 56 | 57 | ## [2.0.1](https://github.com/coder/code-marketplace/releases/tag/v2.0.1) - 2023-12-08 58 | 59 | ### Fixed 60 | 61 | - Extensions with problematic UTF-8 characters will no longer cause a panic. 62 | - Preview extensions will now show up as such. 63 | 64 | ## [2.0.0](https://github.com/coder/code-marketplace/releases/tag/v2.0.0) - 2023-10-11 65 | 66 | ### Breaking changes 67 | 68 | - When removing extensions, the version is now delineated by `@` instead of `-` 69 | (for example `remove vscodevim.vim@1.0.0`). This fixes being unable to remove 70 | extensions with `-` in their names. Removal is the only backwards-incompatible 71 | change; extensions are still added, stored, and queried the same way. 72 | 73 | ### Added 74 | 75 | - Support for platform-specific extensions. Previously all versions would have 76 | been treated as universal and overwritten each other but now versions for 77 | different platforms will be stored separately and show up separately in the 78 | API response. If there are platform-specific versions that have already been 79 | added, they will continue to be treated as universal versions so these should 80 | be removed and re-added to be properly registered as platform-specific. 81 | 82 | ## [1.2.2](https://github.com/coder/code-marketplace/releases/tag/v1.2.2) - 2023-05-30 83 | 84 | ### Changed 85 | 86 | - Help/usage outputs the binary name as `code-marketplace` instead of 87 | `marketplace` to be consistent with documentation. 88 | - Binary is symlinked into /usr/local/bin in the Docker image so it can be 89 | invoked as simply `code-marketplace`. 90 | 91 | ## [1.2.1](https://github.com/coder/code-marketplace/releases/tag/v1.2.1) - 2022-10-31 92 | 93 | ### Fixed 94 | 95 | - Adding extensions from a URL. This broke in 1.2.0 with the addition of bulk 96 | adding. 97 | 98 | ## [1.2.0](https://github.com/coder/code-marketplace/releases/tag/v1.2.0) - 2022-10-20 99 | 100 | ### Added 101 | 102 | - Artifactory integration. Set the ARTIFACTORY_TOKEN environment variable and 103 | pass --artifactory and --repo (instead of --extensions-dir) to use. 104 | - Stat endpoints. This is just to prevent noisy 404s from being logged; the 105 | endpoints do nothing since stats are not yet supported. 106 | - Bulk add from a directory. This only works when adding from a local directory 107 | and not from web URLs. 108 | 109 | ## [1.1.0](https://github.com/coder/code-marketplace/releases/tag/v1.1.0) - 2022-10-03 110 | 111 | ### Added 112 | 113 | - `add` sub-command for adding extensions to the marketplace. 114 | - `remove` sub-command for removing extensions from the marketplace. 115 | 116 | ### Changed 117 | 118 | - Compile statically so binaries work on Alpine. 119 | 120 | ## [1.0.0](https://github.com/coder/code-marketplace/releases/tag/v1.0.0) - 2022-09-12 121 | 122 | ### Added 123 | 124 | - Initial marketplace implementation. 125 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Development 4 | 5 | ```console 6 | mkdir extensions 7 | go run ./cmd/marketplace/main.go server [flags] 8 | ``` 9 | 10 | When you make a change that affects people deploying the marketplace please 11 | update the changelog as part of your PR. 12 | 13 | You can use `make gen` to generate a mock `extensions` directory for testing and 14 | `make upload` to upload them to an Artifactory repository. 15 | 16 | ## Tests 17 | 18 | To run the tests: 19 | 20 | ``` 21 | make test 22 | ``` 23 | 24 | To run the Artifactory tests against a real repository instead of a mock: 25 | 26 | ``` 27 | export ARTIFACTORY_URI=myuri 28 | export ARTIFACTORY_REPO=myrepo 29 | export ARTIFACTORY_TOKEN=mytoken 30 | make test 31 | ``` 32 | 33 | See the readme for using the marketplace with code-server. 34 | 35 | When testing with code-server you may run into issues with content security 36 | policy if the marketplace runs on a different domain over HTTP; in this case you 37 | will need to disable content security policy in your browser or manually edit 38 | the policy in code-server's source. 39 | 40 | ## Releasing 41 | 42 | 1. Check that the changelog lists all the important changes. 43 | 2. Update the changelog with the release date. 44 | 3. Push a tag with the new version. 45 | 4. Update the resulting draft release with the changelog contents. 46 | 5. Publish the draft release. 47 | 6. Bump the Helm chart version once the Docker images have published. 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:experimental 2 | 3 | FROM scratch AS binaries 4 | ARG TARGETARCH 5 | COPY ./bin/code-marketplace-linux-$TARGETARCH /opt/code-marketplace 6 | 7 | FROM alpine:latest 8 | COPY --chmod=755 --from=binaries /opt/code-marketplace /opt 9 | RUN ln -s /opt/code-marketplace /usr/local/bin/code-marketplace 10 | 11 | ENTRYPOINT [ "code-marketplace", "server" ] 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lint: lint/go 2 | .PHONY: lint 3 | 4 | lint/go: 5 | golangci-lint run 6 | .PHONY: lint/go 7 | 8 | test-clean: 9 | go clean -testcache 10 | .PHONY: test-clean 11 | 12 | test: test-clean 13 | gotestsum -- -v -short -coverprofile coverage ./... 14 | .PHONY: test 15 | 16 | coverage: 17 | go tool cover -func=coverage 18 | .PHONY: coverage 19 | 20 | gen: 21 | bash ./fixtures/generate.bash 22 | .PHONY: gen 23 | 24 | upload: 25 | bash ./fixtures/upload.bash 26 | .PHONY: gen 27 | 28 | TAG=$(shell git describe --always) 29 | 30 | build: 31 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-mac-amd64 ./cmd/marketplace/main.go 32 | CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-mac-arm64 ./cmd/marketplace/main.go 33 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-linux-amd64 ./cmd/marketplace/main.go 34 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-linux-arm64 ./cmd/marketplace/main.go 35 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-windows-amd64 ./cmd/marketplace/main.go 36 | CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-windows-arm64 ./cmd/marketplace/main.go 37 | .PHONY: build 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Extension Marketplace 2 | 3 | The Code Extension Marketplace is an open-source alternative to the VS Code 4 | Marketplace for use in editors like 5 | [code-server](https://github.com/coder/code-server) or [VSCodium](https://github.com/VSCodium/vscodium). 6 | 7 | It is maintained by [Coder](https://www.coder.com) and is used by our enterprise 8 | customers in regulated and security-conscious industries like banking, asset 9 | management, military, and intelligence where they deploy Coder in an air-gapped 10 | network and accessing an internet-hosted marketplace is not allowed. 11 | 12 | This marketplace reads extensions from file storage and provides an API for 13 | editors to consume. It does not have a frontend or any mechanisms for extension 14 | authors to add or update extensions in the marketplace. 15 | 16 | ## Deployment 17 | 18 | The marketplace is a single binary. Deployment involves running the binary, 19 | pointing it to a directory of extensions, and exposing the binary's bound 20 | address in some way. 21 | 22 | ### Kubernetes 23 | 24 | If deploying with Kubernetes see the [Helm directory](./helm) otherwise read on. 25 | 26 | ### Getting the binary 27 | 28 | The binary can be downloaded from GitHub releases. For example here is a way to 29 | download the latest release using `wget`. Replace `$os` and `$arch` with your 30 | operating system and architecture. 31 | 32 | ```console 33 | wget https://github.com/coder/code-marketplace/releases/latest/download/code-marketplace-$os-$arch -O ./code-marketplace 34 | chmod +x ./code-marketplace 35 | ``` 36 | 37 | ### Running the server 38 | 39 | The marketplace server can be ran using the `server` sub-command. 40 | 41 | ```console 42 | ./code-marketplace server [flags] 43 | ``` 44 | 45 | Run `./code-marketplace --help` for a full list of options. 46 | 47 | ### Local storage 48 | 49 | To use a local directory for extension storage use the `--extensions-dir` flag. 50 | 51 | ```console 52 | 53 | ./code-marketplace [command] --extensions-dir ./extensions 54 | ``` 55 | 56 | ### Artifactory storage 57 | 58 | It is possible use Artifactory as a file store instead of local storage. For 59 | this to work the `ARTIFACTORY_TOKEN` environment variable must be set. 60 | 61 | ```console 62 | export ARTIFACTORY_TOKEN="my-token" 63 | ./code-marketplace [command] --artifactory http://artifactory.server/artifactory --repo extensions 64 | ``` 65 | 66 | The token will be used in the `Authorization` header with the value `Bearer 67 | `. 68 | 69 | ### Exposing the marketplace 70 | 71 | The marketplace must be put behind TLS otherwise code-server will reject 72 | connecting to the API. This could mean using a TLS-terminating reverse proxy 73 | like NGINX or Caddy with your own domain and certificates or using a service 74 | like Cloudflare. 75 | 76 | When hosting the marketplace behind a reverse proxy set either the `Forwarded` 77 | header or both the `X-Forwarded-Host` and `X-Forwarded-Proto` headers. These 78 | headers are used to generate absolute URLs to extension assets in API responses. 79 | One way to test this is to make a query and check one of the URLs in the 80 | response: 81 | 82 | ```console 83 | curl 'https://example.com/api/extensionquery' -H 'Accept: application/json;api-version=3.0-preview.1' --compressed -H 'Content-Type: application/json' --data-raw '{"filters":[{"criteria":[{"filterType":8,"value":"Microsoft.VisualStudio.Code"}],"pageSize":1}],"flags":439}' | jq .results[0].extensions[0].versions[0].assetUri 84 | "https://example.com/assets/vscodevim/vim/1.24.1" 85 | ``` 86 | 87 | The marketplace does not support being hosted behind a base path; it must be 88 | proxied at the root of your domain. 89 | 90 | ### Health checks 91 | 92 | The `/healthz` endpoint can be used to determine if the marketplace is ready to 93 | receive requests. 94 | 95 | ## Adding extensions 96 | 97 | Extensions can be added to the marketplace by file, directory, or web URL. 98 | 99 | ```console 100 | ./code-marketplace add extension.vsix [flags] 101 | ./code-marketplace add extension-vsixs/ [flags] 102 | ./code-marketplace add https://domain.tld/extension.vsix [flags] 103 | ``` 104 | 105 | If the extension has dependencies or is in an extension pack those details will 106 | be printed. Extensions listed as dependencies must also be added but extensions 107 | in a pack are optional. 108 | 109 | If an extension is open source you can get it from one of three locations: 110 | 111 | 1. GitHub releases (if the extension publishes releases to GitHub). 112 | 2. Open VSX (if the extension is published to Open VSX). 113 | 3. Building from source. 114 | 115 | For example to add the Python extension from Open VSX: 116 | 117 | ```console 118 | ./code-marketplace add https://open-vsx.org/api/ms-python/python/2022.14.0/file/ms-python.python-2022.14.0.vsix [flags] 119 | ``` 120 | 121 | Or the Vim extension from GitHub: 122 | 123 | ```console 124 | ./code-marketplace add https://github.com/VSCodeVim/Vim/releases/download/v1.24.1/vim-1.24.1.vsix [flags] 125 | ``` 126 | 127 | ## Removing extensions 128 | 129 | Extensions can be removed from the marketplace by ID and version or `--all` to 130 | remove all versions. 131 | 132 | ```console 133 | ./code-marketplace remove ms-python.python@2022.14.0 [flags] 134 | ./code-marketplace remove ms-python.python --all [flags] 135 | ``` 136 | 137 | ## Scanning frequency and caching 138 | 139 | The marketplace does not utilize a database. When an extension query is made, 140 | the marketplace scans the local file system or queries Artifactory on demand to 141 | find all the available extensions. 142 | 143 | However, for Artifactory in particular this can be slow, so this full list of 144 | extensions is cached in memory for a default of one minute and reused for any 145 | subsequent requests that fall within that duration. This duration can be 146 | configured or disabled with `--list-cache-duration` and applies to both storage 147 | backends. 148 | 149 | This means that when you add or remove an extension, depending on when the last 150 | request was made, it can take a duration between zero and 151 | `--list-cache-duration` for the query response to reflect that change. 152 | 153 | Artifactory storage also uses a second in-memory cache for extension manifests, 154 | which are referenced in extension queries (for things like categories). This 155 | cache is initially populated with all available extension manifests on startup. 156 | Extensions added after the server is running are added to the cache on-demand 157 | the next time extensions are scanned. 158 | 159 | The manifest cache has no expiration and never evicts manifests because it was 160 | expected that extensions are typically only ever added and individual extension 161 | version manifests never change; however we would like to implement evicting 162 | manifests of extensions that have been removed. 163 | 164 | With local storage, manifests are read directly from the file system on 165 | demand. Requests for other extension assets (such as icons) for both storage 166 | backends have no cache and are read/proxied directly from the file system or 167 | Artifactory since they are not in the extension query hot path. 168 | 169 | ## Usage in code-server 170 | 171 | You can point code-server to your marketplace by setting the 172 | `EXTENSIONS_GALLERY` environment variable. 173 | 174 | The value of this variable is a JSON blob that specifies the service URL, item 175 | URL, and resource URL template. 176 | 177 | - `serviceURL`: specifies the location of the API (`https:///api`). 178 | - `itemURL`: the frontend for extensions which is currently just a mostly blank 179 | page that says "not supported" (`https:///item`) 180 | - `resourceURLTemplate`: used to download web extensions like Vim; code-server 181 | itself will replace the `{publisher}`, `{name}`, `{version}`, and `{path}` 182 | template variables so use them verbatim 183 | (`https:///files/{publisher}/{name}/{version}/{path}`). 184 | 185 | For example (replace `` with your marketplace's domain): 186 | 187 | ```console 188 | export EXTENSIONS_GALLERY='{"serviceUrl":"https:///api", "itemUrl":"https:///item", "resourceUrlTemplate": "https:///files/{publisher}/{name}/{version}/{path}"}' 189 | code-server 190 | ``` 191 | 192 | If code-server reports content security policy errors ensure that the 193 | marketplace is running behind an https URL. 194 | 195 | ### Custom certificate authority 196 | 197 | If you are using a custom certificate authority or a self-signed certificate and 198 | get errors like "unable to verify the first certificate", you may need to set 199 | the [NODE_EXTRA_CA_CERTS](https://nodejs.org/api/cli.html#node_extra_ca_certsfile) 200 | environment variable for code-server to find your certificates bundle. 201 | 202 | Make sure your bundle contains the full certificate chain. This can be necessary 203 | because Node does not read system certificates by default and while VS Code has 204 | code for reading them, it appears not to work or be enabled for the web version. 205 | 206 | Some so-called "web" extensions (like `vscodevim.vim`) are installed in the 207 | browser, and extension searches are also performed from the browser, so your 208 | certificate bundle may also need to be installed on the client machine in 209 | addition to the remote machine. 210 | 211 | ## Usage in VS Code & VSCodium 212 | 213 | Although not officially supported, you can follow the examples below to start 214 | using code-marketplace with VS Code and VSCodium: 215 | 216 | - [VS Code](https://github.com/eclipse/openvsx/wiki/Using-Open-VSX-in-VS-Code) 217 | 218 | Extension signing may have to be disabled in VS Code. 219 | 220 | - [VSCodium](https://github.com/VSCodium/vscodium/blob/master/docs/index.md#howto-switch-marketplace) 221 | 222 | ``` 223 | export VSCODE_GALLERY_SERVICE_URL="https:///api 224 | export VSCODE_GALLERY_ITEM_URL="https:///item" 225 | # Or set a product.json file in `~/.config/VSCodium/product.json` 226 | codium 227 | ``` 228 | 229 | ## Missing features 230 | 231 | - Recommended extensions. 232 | - Featured extensions. 233 | - Download counts. 234 | - Ratings. 235 | - Searching by popularity. 236 | - Published, released, and updated dates for extensions (for example this will 237 | cause bogus release dates to show for versions). 238 | - Frontend for browsing available extensions. 239 | - Extension validation (only the marketplace owner can add extensions anyway). 240 | - Adding and updating extensions by extension authors. 241 | 242 | ## Planned work 243 | 244 | - Bulk add from one Artifactory repository to another (or to itself). 245 | - Optional database to speed up queries. 246 | - Progress indicators when adding/removing extensions. 247 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/go-chi/chi/v5" 10 | "github.com/go-chi/chi/v5/middleware" 11 | 12 | "cdr.dev/slog" 13 | "github.com/coder/code-marketplace/api/httpapi" 14 | "github.com/coder/code-marketplace/api/httpmw" 15 | "github.com/coder/code-marketplace/database" 16 | "github.com/coder/code-marketplace/storage" 17 | ) 18 | 19 | const MaxPageSizeDefault int = 200 20 | 21 | // QueryRequest implements an untyped object. It is the data sent to the API to 22 | // query for extensions. 23 | // https://github.com/microsoft/vscode/blob/a69f95fdf3dc27511517eef5ff62b21c7a418015/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L338-L342 24 | type QueryRequest struct { 25 | Filters []database.Filter `json:"filters"` 26 | Flags database.Flag `json:"flags"` 27 | } 28 | 29 | // QueryResponse implements IRawGalleryQueryResult. This is the response sent 30 | // to extension queries. 31 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L81-L92 32 | type QueryResponse struct { 33 | Results []QueryResult `json:"results"` 34 | } 35 | 36 | // QueryResult implements IRawGalleryQueryResult.results. 37 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L82-L91 38 | type QueryResult struct { 39 | Extensions []*database.Extension `json:"extensions"` 40 | Metadata []ResultMetadata `json:"resultMetadata"` 41 | } 42 | 43 | // ResultMetadata implements IRawGalleryQueryResult.resultMetadata. 44 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L84-L90 45 | type ResultMetadata struct { 46 | Type string `json:"metadataType"` 47 | Items []ResultMetadataItem `json:"metadataItems"` 48 | } 49 | 50 | // ResultMetadataItem implements IRawGalleryQueryResult.metadataItems. 51 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L86-L89 52 | type ResultMetadataItem struct { 53 | Name string `json:"name"` 54 | Count int `json:"count"` 55 | } 56 | 57 | type Options struct { 58 | Database database.Database 59 | Logger slog.Logger 60 | // Set to <0 to disable. 61 | RateLimit int 62 | Storage storage.Storage 63 | MaxPageSize int 64 | } 65 | 66 | type API struct { 67 | Database database.Database 68 | Handler http.Handler 69 | Logger slog.Logger 70 | MaxPageSize int 71 | } 72 | 73 | // New creates a new API server. 74 | func New(options *Options) *API { 75 | if options.RateLimit == 0 { 76 | options.RateLimit = 512 77 | } 78 | 79 | if options.MaxPageSize == 0 { 80 | options.MaxPageSize = MaxPageSizeDefault 81 | } 82 | 83 | r := chi.NewRouter() 84 | 85 | r.Use( 86 | httpmw.Cors(), 87 | httpmw.RateLimitPerMinute(options.RateLimit), 88 | middleware.GetHead, 89 | httpmw.AttachRequestID, 90 | httpmw.Recover(options.Logger), 91 | httpmw.AttachBuildInfo, 92 | httpmw.Logger(options.Logger), 93 | ) 94 | 95 | api := &API{ 96 | Database: options.Database, 97 | Handler: r, 98 | Logger: options.Logger, 99 | MaxPageSize: options.MaxPageSize, 100 | } 101 | 102 | r.Get("/", func(rw http.ResponseWriter, r *http.Request) { 103 | httpapi.WriteBytes(rw, http.StatusOK, []byte("Marketplace is running")) 104 | }) 105 | 106 | r.Get("/healthz", func(rw http.ResponseWriter, r *http.Request) { 107 | httpapi.WriteBytes(rw, http.StatusOK, []byte("API server running")) 108 | }) 109 | 110 | // TODO: Read API version header and output a warning if it has changed since 111 | // that could indicate something needs to be updated. 112 | r.Post("/api/extensionquery", api.extensionQuery) 113 | 114 | // Endpoint for getting an extension's files or the extension zip. 115 | r.Mount("/files", http.StripPrefix("/files", options.Storage.FileServer())) 116 | 117 | // VS Code can use the files in the response to get file paths but it will 118 | // sometimes ignore that and use requests to /assets with hardcoded types to 119 | // get files. 120 | r.Get("/assets/{publisher}/{extension}/{version}/{type}", api.assetRedirect) 121 | 122 | // This is the "download manually" URL, which like /assets is hardcoded and 123 | // ignores the VSIX asset URL provided to VS Code in the response. We provide 124 | // it at /publishers for backwards compatibility since that is where we 125 | // originally had it, but VS Code appends to the service URL which means the 126 | // path VS Code actually uses is /api/publishers. 127 | // https://github.com/microsoft/vscode/blob/c727b5484ebfbeff1e1d29654cae5c17af1c826f/build/lib/extensions.ts#L228 128 | r.Get("/publishers/{publisher}/vsextensions/{extension}/{version}/{type}", api.assetRedirect) 129 | r.Get("/api/publishers/{publisher}/vsextensions/{extension}/{version}/{type}", api.assetRedirect) 130 | 131 | // This is the URL you get taken to when you click the extension's names, 132 | // ratings, etc from the extension details page. 133 | r.Get("/item", func(rw http.ResponseWriter, r *http.Request) { 134 | httpapi.WriteBytes(rw, http.StatusOK, []byte("Extension pages are not supported")) 135 | }) 136 | 137 | // Web extensions post stats to this endpoint. 138 | r.Post("/api/itemName/{publisher}.{name}/version/{version}/statType/{type}/vscodewebextension", func(rw http.ResponseWriter, r *http.Request) { 139 | httpapi.WriteBytes(rw, http.StatusOK, []byte("Extension stats are not supported")) 140 | }) 141 | 142 | // Non-web extensions post stats to this endpoint. 143 | r.Post("/api/publishers/{publisher}/extensions/{name}/{version}/stats", func(rw http.ResponseWriter, r *http.Request) { 144 | // Will have a `statType` query param. 145 | httpapi.WriteBytes(rw, http.StatusOK, []byte("Extension stats are not supported")) 146 | }) 147 | 148 | return api 149 | } 150 | 151 | func (api *API) extensionQuery(rw http.ResponseWriter, r *http.Request) { 152 | ctx := r.Context() 153 | 154 | var query QueryRequest 155 | if r.ContentLength <= 0 { 156 | query = QueryRequest{} 157 | } else { 158 | err := json.NewDecoder(r.Body).Decode(&query) 159 | if err != nil { 160 | httpapi.Write(rw, http.StatusBadRequest, httpapi.ErrorResponse{ 161 | Message: "Unable to read query", 162 | Detail: "Check that the posted data is valid JSON", 163 | RequestID: httpmw.RequestID(r), 164 | }) 165 | return 166 | } 167 | } 168 | 169 | // Validate query sizes. 170 | if len(query.Filters) == 0 { 171 | query.Filters = append(query.Filters, database.Filter{}) 172 | } else if len(query.Filters) > 1 { 173 | // VS Code always seems to use one filter. 174 | httpapi.Write(rw, http.StatusBadRequest, httpapi.ErrorResponse{ 175 | Message: "Too many filters", 176 | Detail: "Check that you only have one filter", 177 | RequestID: httpmw.RequestID(r), 178 | }) 179 | } 180 | for _, filter := range query.Filters { 181 | if filter.PageSize < 0 || filter.PageSize > api.MaxPageSize { 182 | httpapi.Write(rw, http.StatusBadRequest, httpapi.ErrorResponse{ 183 | Message: "The page size must be between 0 and " + strconv.Itoa(api.MaxPageSize), 184 | Detail: "Contact an administrator to increase the page size", 185 | RequestID: httpmw.RequestID(r), 186 | }) 187 | } 188 | } 189 | 190 | baseURL := httpapi.RequestBaseURL(r, "/") 191 | 192 | // Each filter gets its own entry in the results. 193 | results := []QueryResult{} 194 | for _, filter := range query.Filters { 195 | extensions, count, err := api.Database.GetExtensions(ctx, filter, query.Flags, baseURL) 196 | if err != nil { 197 | api.Logger.Error(ctx, "Unable to execute query", slog.Error(err)) 198 | httpapi.Write(rw, http.StatusInternalServerError, httpapi.ErrorResponse{ 199 | Message: "Internal server error while executing query", 200 | Detail: "Contact an administrator with the request ID", 201 | RequestID: httpmw.RequestID(r), 202 | }) 203 | return 204 | } 205 | 206 | api.Logger.Debug(ctx, "got extensions for filter", 207 | slog.F("filter", filter), 208 | slog.F("count", count)) 209 | 210 | results = append(results, QueryResult{ 211 | Extensions: extensions, 212 | Metadata: []ResultMetadata{{ 213 | Type: "ResultCount", 214 | Items: []ResultMetadataItem{{ 215 | Count: count, 216 | Name: "TotalCount", 217 | }}, 218 | }}, 219 | }) 220 | } 221 | 222 | httpapi.Write(rw, http.StatusOK, QueryResponse{Results: results}) 223 | } 224 | 225 | func (api *API) assetRedirect(rw http.ResponseWriter, r *http.Request) { 226 | baseURL := httpapi.RequestBaseURL(r, "/") 227 | assetType := storage.AssetType(chi.URLParam(r, "type")) 228 | if assetType == "vspackage" { 229 | assetType = storage.VSIXAssetType 230 | } 231 | version := storage.VersionFromString(chi.URLParam(r, "version")) 232 | if version.TargetPlatform == "" { 233 | version.TargetPlatform = storage.Platform(r.URL.Query().Get("targetPlatform")) 234 | } 235 | url, err := api.Database.GetExtensionAssetPath(r.Context(), &database.Asset{ 236 | Extension: chi.URLParam(r, "extension"), 237 | Publisher: chi.URLParam(r, "publisher"), 238 | Type: assetType, 239 | Version: version, 240 | }, baseURL) 241 | if err != nil && os.IsNotExist(err) { 242 | httpapi.Write(rw, http.StatusNotFound, httpapi.ErrorResponse{ 243 | Message: "Extension asset does not exist", 244 | Detail: "Please check the asset path", 245 | RequestID: httpmw.RequestID(r), 246 | }) 247 | return 248 | } else if err != nil { 249 | httpapi.Write(rw, http.StatusInternalServerError, httpapi.ErrorResponse{ 250 | Message: "Unable to read extension", 251 | Detail: "Contact an administrator with the request ID", 252 | RequestID: httpmw.RequestID(r), 253 | }) 254 | return 255 | } 256 | 257 | http.Redirect(rw, r, url, http.StatusMovedPermanently) 258 | } 259 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/require" 14 | 15 | "cdr.dev/slog" 16 | "cdr.dev/slog/sloggers/slogtest" 17 | "github.com/coder/code-marketplace/api" 18 | "github.com/coder/code-marketplace/api/httpapi" 19 | "github.com/coder/code-marketplace/database" 20 | "github.com/coder/code-marketplace/testutil" 21 | ) 22 | 23 | func TestAPI(t *testing.T) { 24 | t.Parallel() 25 | 26 | exts := []*database.Extension{} 27 | for i := 0; i < 10; i++ { 28 | exts = append(exts, &database.Extension{ 29 | ID: fmt.Sprintf("extension-%d", i), 30 | }) 31 | } 32 | 33 | cases := []struct { 34 | Name string 35 | Path string 36 | Request any 37 | Response any 38 | Status int 39 | Method string 40 | }{ 41 | { 42 | Name: "Root", 43 | Path: "/", 44 | Status: http.StatusOK, 45 | }, 46 | { 47 | Name: "404", 48 | Path: "/non-existent", 49 | Status: http.StatusNotFound, 50 | }, 51 | { 52 | Name: "Healthz", 53 | Path: "/healthz", 54 | Status: http.StatusOK, 55 | }, 56 | { 57 | Name: "MalformedQuery", 58 | Path: "/api/extensionquery", 59 | Status: http.StatusBadRequest, 60 | Request: "foo", 61 | Response: &httpapi.ErrorResponse{ 62 | Message: "Unable to read query", 63 | Detail: "Check that the posted data is valid JSON", 64 | }, 65 | }, 66 | { 67 | Name: "EmptyPayload", 68 | Path: "/api/extensionquery", 69 | Status: http.StatusOK, 70 | Response: &api.QueryResponse{ 71 | Results: []api.QueryResult{{ 72 | Metadata: []api.ResultMetadata{{ 73 | Type: "ResultCount", 74 | Items: []api.ResultMetadataItem{{ 75 | Count: 0, 76 | Name: "TotalCount", 77 | }}, 78 | }}, 79 | }}, 80 | }, 81 | }, 82 | { 83 | Name: "NoCriteria", 84 | Path: "/api/extensionquery", 85 | Status: http.StatusOK, 86 | Request: &api.QueryRequest{ 87 | Filters: []database.Filter{}, 88 | }, 89 | Response: &api.QueryResponse{ 90 | Results: []api.QueryResult{{ 91 | Metadata: []api.ResultMetadata{{ 92 | Type: "ResultCount", 93 | Items: []api.ResultMetadataItem{{ 94 | Count: 0, 95 | Name: "TotalCount", 96 | }}, 97 | }}, 98 | }}, 99 | }, 100 | }, 101 | { 102 | Name: "ManyQueries", 103 | Path: "/api/extensionquery", 104 | Status: http.StatusBadRequest, 105 | Request: &api.QueryRequest{ 106 | Filters: make([]database.Filter, 2), 107 | }, 108 | Response: &httpapi.ErrorResponse{ 109 | Message: "Too many filters", 110 | Detail: "Check that you only have one filter", 111 | }, 112 | }, 113 | { 114 | Name: "HugePages", 115 | Path: "/api/extensionquery", 116 | Status: http.StatusBadRequest, 117 | Request: &api.QueryRequest{ 118 | Filters: []database.Filter{{ 119 | PageSize: 500, 120 | }}, 121 | }, 122 | Response: &httpapi.ErrorResponse{ 123 | Message: "The page size must be between 0 and 200", 124 | Detail: "Contact an administrator to increase the page size", 125 | }, 126 | }, 127 | { 128 | Name: "DBError", 129 | Path: "/api/extensionquery", 130 | Status: http.StatusInternalServerError, 131 | Request: &api.QueryRequest{ 132 | // testDB is configured to error if this flag is set. 133 | Flags: database.Unpublished, 134 | }, 135 | Response: &httpapi.ErrorResponse{ 136 | Message: "Internal server error while executing query", 137 | Detail: "Contact an administrator with the request ID", 138 | }, 139 | }, 140 | { 141 | Name: "GetExtensions", 142 | Path: "/api/extensionquery", 143 | Status: http.StatusOK, 144 | Request: &api.QueryRequest{ 145 | Filters: []database.Filter{{ 146 | Criteria: []database.Criteria{{ 147 | Type: database.Target, 148 | Value: "Microsoft.VisualStudio.Code", 149 | }}, 150 | PageNumber: 1, 151 | PageSize: 50, 152 | }}, 153 | }, 154 | Response: &api.QueryResponse{ 155 | Results: []api.QueryResult{{ 156 | Extensions: exts, 157 | Metadata: []api.ResultMetadata{{ 158 | Type: "ResultCount", 159 | Items: []api.ResultMetadataItem{{ 160 | Count: len(exts), 161 | Name: "TotalCount", 162 | }}, 163 | }}, 164 | }}, 165 | }, 166 | }, 167 | { 168 | Name: "FileAPI", 169 | Path: "/files/exists", 170 | Status: http.StatusOK, 171 | Response: "foobar", 172 | }, 173 | { 174 | Name: "FileAPINotExists", 175 | Path: "/files/nonexistent", 176 | Status: http.StatusNotFound, 177 | }, 178 | { 179 | Name: "AssetError", 180 | Path: "/assets/error/extension/version/type", 181 | Status: http.StatusInternalServerError, 182 | Response: &httpapi.ErrorResponse{ 183 | Message: "Unable to read extension", 184 | Detail: "Contact an administrator with the request ID", 185 | }, 186 | }, 187 | { 188 | Name: "AssetNotExist", 189 | Path: "/assets/notexist/extension/version/type", 190 | Status: http.StatusNotFound, 191 | Response: &httpapi.ErrorResponse{ 192 | Message: "Extension asset does not exist", 193 | Detail: "Please check the asset path", 194 | }, 195 | }, 196 | { 197 | Name: "AssetOK", 198 | Path: "/assets/publisher/extension/version/type", 199 | Status: http.StatusMovedPermanently, 200 | Response: "/files/publisher/extension/version/foo", 201 | }, 202 | { 203 | Name: "AssetOKPlatform", 204 | Path: "/assets/publisher/extension/version@linux-x64/type", 205 | Status: http.StatusMovedPermanently, 206 | Response: "/files/publisher/extension/version@linux-x64/foo", 207 | }, 208 | { 209 | Name: "AssetOKPlatformQuery", 210 | Path: "/assets/publisher/extension/version/type?targetPlatform=linux-x64", 211 | Status: http.StatusMovedPermanently, 212 | Response: "/files/publisher/extension/version@linux-x64/foo", 213 | }, 214 | { 215 | Name: "AssetOKDuplicatedPlatformQuery", 216 | Path: "/assets/publisher/extension/version@darwin-x64/type?targetPlatform=linux-x64", 217 | Status: http.StatusMovedPermanently, 218 | Response: "/files/publisher/extension/version@darwin-x64/foo", 219 | }, 220 | // Old vspackage path, for backwards compatibility. 221 | { 222 | Name: "DownloadNotExist", 223 | Path: "/publishers/notexist/vsextensions/extension/version/vspackage", 224 | Status: http.StatusNotFound, 225 | Response: &httpapi.ErrorResponse{ 226 | Message: "Extension asset does not exist", 227 | Detail: "Please check the asset path", 228 | }, 229 | }, 230 | { 231 | Name: "DownloadOK", 232 | Path: "/publishers/publisher/vsextensions/extension/version/vspackage", 233 | Status: http.StatusMovedPermanently, 234 | Response: "/files/publisher/extension/version/extension.vsix", 235 | }, 236 | // The vspackage path currently generated by VS Code. 237 | { 238 | Name: "APIDownloadNotExist", 239 | Path: "/api/publishers/notexist/vsextensions/extension/version/vspackage", 240 | Status: http.StatusNotFound, 241 | Response: &httpapi.ErrorResponse{ 242 | Message: "Extension asset does not exist", 243 | Detail: "Please check the asset path", 244 | }, 245 | Method: http.MethodGet, 246 | }, 247 | { 248 | Name: "APIDownloadOK", 249 | Path: "/api/publishers/publisher/vsextensions/extension/version/vspackage", 250 | Status: http.StatusMovedPermanently, 251 | Response: "/files/publisher/extension/version/extension.vsix", 252 | Method: http.MethodGet, 253 | }, 254 | { 255 | Name: "Item", 256 | Path: "/item", 257 | Status: http.StatusOK, 258 | }, 259 | { 260 | Name: "WebExtensionStat", 261 | Path: "/api/itemName/vscodevim.vim/version/1.23.1/statType/1/vscodewebextension", 262 | Status: http.StatusOK, 263 | }, 264 | { 265 | Name: "ExtensionStat", 266 | Path: "/api/publishers/vscodevim/extensions/vim/1.23.1/stats?statType=1", 267 | Status: http.StatusOK, 268 | }, 269 | } 270 | 271 | for _, c := range cases { 272 | c := c 273 | t.Run(c.Name, func(t *testing.T) { 274 | t.Parallel() 275 | 276 | logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) 277 | apiServer := api.New(&api.Options{ 278 | Database: testutil.NewMockDB(exts), 279 | Storage: testutil.NewMockStorage(), 280 | Logger: logger, 281 | MaxPageSize: api.MaxPageSizeDefault, 282 | }) 283 | 284 | server := httptest.NewServer(apiServer.Handler) 285 | defer server.Close() 286 | 287 | url := server.URL + c.Path 288 | 289 | // Do not follow redirects. 290 | client := &http.Client{ 291 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 292 | return http.ErrUseLastResponse 293 | }, 294 | } 295 | 296 | // Most /api calls are POSTs, the rest are GETs. 297 | var method = c.Method 298 | if method == "" { 299 | if strings.HasPrefix(c.Path, "/api") { 300 | method = http.MethodPost 301 | } else { 302 | method = http.MethodGet 303 | } 304 | } 305 | 306 | var resp *http.Response 307 | var err error 308 | switch method { 309 | case http.MethodPost: 310 | var body []byte 311 | if str, ok := c.Request.(string); ok { 312 | body = []byte(str) 313 | } else if c.Request != nil { 314 | body, err = json.Marshal(c.Request) 315 | require.NoError(t, err) 316 | } 317 | resp, err = client.Post(url, "application/json", bytes.NewReader(body)) 318 | case http.MethodGet: 319 | resp, err = client.Get(url) 320 | default: 321 | t.Fatal(method + " is not handled in the test yet, please add it now") 322 | } 323 | require.NoError(t, err) 324 | require.Equal(t, c.Status, resp.StatusCode) 325 | 326 | if c.Response != nil { 327 | // Copy the request ID so the objects can match. 328 | if a, aok := c.Response.(*httpapi.ErrorResponse); aok { 329 | var body httpapi.ErrorResponse 330 | err := json.NewDecoder(resp.Body).Decode(&body) 331 | require.NoError(t, err) 332 | a.RequestID = body.RequestID 333 | require.Equal(t, c.Response, &body) 334 | } else if c.Status == http.StatusMovedPermanently { 335 | require.Equal(t, c.Response, resp.Header.Get("Location")) 336 | } else if a, aok := c.Response.(string); aok { 337 | b, err := io.ReadAll(resp.Body) 338 | require.NoError(t, err) 339 | require.Equal(t, a, string(b)) 340 | } else { 341 | var body api.QueryResponse 342 | err := json.NewDecoder(resp.Body).Decode(&body) 343 | require.NoError(t, err) 344 | require.Equal(t, c.Response, &body) 345 | } 346 | } 347 | }) 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /api/httpapi/httpapi.go: -------------------------------------------------------------------------------- 1 | package httpapi 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type ErrorResponse struct { 14 | Message string `json:"message"` 15 | Detail string `json:"detail"` 16 | RequestID uuid.UUID `json:"requestId,omitempty"` 17 | } 18 | 19 | // WriteBytes tries to write the provided bytes and errors if unable. 20 | func WriteBytes(rw http.ResponseWriter, status int, bytes []byte) { 21 | rw.WriteHeader(status) 22 | _, err := rw.Write(bytes) 23 | if err != nil { 24 | http.Error(rw, err.Error(), http.StatusInternalServerError) 25 | return 26 | } 27 | } 28 | 29 | // Write outputs a standardized format to an HTTP response body. 30 | func Write(rw http.ResponseWriter, status int, response interface{}) { 31 | buf := &bytes.Buffer{} 32 | enc := json.NewEncoder(buf) 33 | enc.SetEscapeHTML(true) 34 | err := enc.Encode(response) 35 | if err != nil { 36 | http.Error(rw, err.Error(), http.StatusInternalServerError) 37 | return 38 | } 39 | rw.Header().Set("Content-Type", "application/json; charset=utf-8") 40 | WriteBytes(rw, status, buf.Bytes()) 41 | } 42 | 43 | const ( 44 | ForwardedHeader = "Forwarded" 45 | XForwardedHostHeader = "X-Forwarded-Host" 46 | XForwardedProtoHeader = "X-Forwarded-Proto" 47 | ) 48 | 49 | // RequestBaseURL returns the base URL of the request. It prioritizes 50 | // forwarded proxy headers. 51 | func RequestBaseURL(r *http.Request, basePath string) url.URL { 52 | proto := "" 53 | host := "" 54 | 55 | // by=;for=;host=;proto= 56 | forwarded := strings.Split(r.Header.Get(ForwardedHeader), ";") 57 | for _, val := range forwarded { 58 | parts := strings.SplitN(val, "=", 2) 59 | switch strings.TrimSpace(parts[0]) { 60 | case "host": 61 | host = strings.TrimSpace(parts[1]) 62 | case "proto": 63 | proto = strings.TrimSpace(parts[1]) 64 | } 65 | } 66 | 67 | if proto == "" { 68 | proto = r.Header.Get(XForwardedProtoHeader) 69 | } 70 | if proto == "" { 71 | proto = "http" 72 | } 73 | 74 | if host == "" { 75 | host = r.Header.Get(XForwardedHostHeader) 76 | } 77 | if host == "" { 78 | host = r.Host 79 | } 80 | 81 | return url.URL{ 82 | Scheme: proto, 83 | Host: host, 84 | Path: strings.TrimRight(basePath, "/"), 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /api/httpapi/httpapi_test.go: -------------------------------------------------------------------------------- 1 | package httpapi_test 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | 10 | "github.com/google/uuid" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/coder/code-marketplace/api/httpapi" 14 | ) 15 | 16 | type TestResponse struct { 17 | Message string 18 | } 19 | 20 | func TestWrite(t *testing.T) { 21 | t.Parallel() 22 | 23 | t.Run("OK", func(t *testing.T) { 24 | t.Parallel() 25 | 26 | message := TestResponse{Message: "foo"} 27 | rw := httptest.NewRecorder() 28 | httpapi.Write(rw, http.StatusOK, message) 29 | require.Equal(t, http.StatusOK, rw.Code) 30 | 31 | var m TestResponse 32 | err := json.NewDecoder(rw.Body).Decode(&m) 33 | require.NoError(t, err) 34 | require.Equal(t, message, m) 35 | }) 36 | 37 | t.Run("Error", func(t *testing.T) { 38 | t.Parallel() 39 | 40 | message := httpapi.ErrorResponse{Message: "foo", Detail: "bar", RequestID: uuid.New()} 41 | rw := httptest.NewRecorder() 42 | httpapi.Write(rw, http.StatusMethodNotAllowed, message) 43 | require.Equal(t, http.StatusMethodNotAllowed, rw.Code) 44 | 45 | var m httpapi.ErrorResponse 46 | err := json.NewDecoder(rw.Body).Decode(&m) 47 | require.NoError(t, err) 48 | require.Equal(t, message, m) 49 | }) 50 | 51 | t.Run("Malformed", func(t *testing.T) { 52 | t.Parallel() 53 | 54 | rw := httptest.NewRecorder() 55 | httpapi.Write(rw, http.StatusMethodNotAllowed, "no") 56 | // This will still be the original code since it was already set. 57 | require.Equal(t, http.StatusMethodNotAllowed, rw.Code) 58 | 59 | var m httpapi.ErrorResponse 60 | err := json.NewDecoder(rw.Body).Decode(&m) 61 | require.Error(t, err) 62 | }) 63 | } 64 | 65 | func TestBaseURL(t *testing.T) { 66 | t.Parallel() 67 | 68 | r := httptest.NewRequest("GET", "/", nil) 69 | url, err := url.Parse("http://example.com/foo") 70 | require.NoError(t, err) 71 | require.Equal(t, *url, httpapi.RequestBaseURL(r, "/foo")) 72 | 73 | r.Header.Set(httpapi.XForwardedHostHeader, "foo.bar") 74 | r.Header.Set(httpapi.XForwardedProtoHeader, "qux") 75 | 76 | url, err = url.Parse("qux://foo.bar") 77 | require.NoError(t, err) 78 | require.Equal(t, *url, httpapi.RequestBaseURL(r, "")) 79 | 80 | url, err = url.Parse("qux://foo.bar") 81 | require.NoError(t, err) 82 | require.Equal(t, *url, httpapi.RequestBaseURL(r, "/")) 83 | 84 | r.Header.Set(httpapi.ForwardedHeader, "by=idk;for=idk;host=fred.thud;proto=baz") 85 | 86 | url, err = url.Parse("baz://fred.thud/quirk/bling") 87 | require.NoError(t, err) 88 | require.Equal(t, *url, httpapi.RequestBaseURL(r, "/quirk/bling")) 89 | } 90 | -------------------------------------------------------------------------------- /api/httpapi/status_writer.go: -------------------------------------------------------------------------------- 1 | package httpapi 2 | 3 | import ( 4 | "bufio" 5 | "net" 6 | "net/http" 7 | 8 | "golang.org/x/xerrors" 9 | ) 10 | 11 | var _ http.ResponseWriter = (*StatusWriter)(nil) 12 | var _ http.Hijacker = (*StatusWriter)(nil) 13 | 14 | // StatusWriter intercepts the status of the request and the response body up to 15 | // maxBodySize if Status >= 400. It is guaranteed to be the ResponseWriter 16 | // directly downstream from Middleware. 17 | type StatusWriter struct { 18 | http.ResponseWriter 19 | Status int 20 | Hijacked bool 21 | responseBody []byte 22 | 23 | wroteHeader bool 24 | } 25 | 26 | func (w *StatusWriter) WriteHeader(status int) { 27 | if !w.wroteHeader { 28 | w.Status = status 29 | w.wroteHeader = true 30 | } 31 | w.ResponseWriter.WriteHeader(status) 32 | } 33 | 34 | func (w *StatusWriter) Write(b []byte) (int, error) { 35 | const maxBodySize = 4096 36 | 37 | if !w.wroteHeader { 38 | w.Status = http.StatusOK 39 | w.wroteHeader = true 40 | } 41 | 42 | if w.Status >= http.StatusBadRequest { 43 | // This is technically wrong as multiple calls to write will simply 44 | // overwrite w.ResponseBody but we typically only write to the response body 45 | // once and this field is only used for logging. 46 | w.responseBody = make([]byte, minInt(len(b), maxBodySize)) 47 | copy(w.responseBody, b) 48 | } 49 | 50 | return w.ResponseWriter.Write(b) 51 | } 52 | 53 | func minInt(a, b int) int { 54 | if a < b { 55 | return a 56 | } 57 | return b 58 | } 59 | 60 | func (w *StatusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 61 | hijacker, ok := w.ResponseWriter.(http.Hijacker) 62 | if !ok { 63 | return nil, nil, xerrors.Errorf("%T is not a http.Hijacker", w.ResponseWriter) 64 | } 65 | w.Hijacked = true 66 | 67 | return hijacker.Hijack() 68 | } 69 | 70 | func (w *StatusWriter) ResponseBody() []byte { 71 | return w.responseBody 72 | } 73 | -------------------------------------------------------------------------------- /api/httpapi/status_writer_test.go: -------------------------------------------------------------------------------- 1 | package httpapi_test 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "net" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | "golang.org/x/xerrors" 13 | 14 | "github.com/coder/code-marketplace/api/httpapi" 15 | ) 16 | 17 | func TestStatusWriter(t *testing.T) { 18 | t.Parallel() 19 | 20 | t.Run("WriteHeader", func(t *testing.T) { 21 | t.Parallel() 22 | 23 | var ( 24 | rec = httptest.NewRecorder() 25 | w = &httpapi.StatusWriter{ResponseWriter: rec} 26 | ) 27 | 28 | w.WriteHeader(http.StatusOK) 29 | require.Equal(t, http.StatusOK, w.Status) 30 | // Validate that the code is written to the underlying Response. 31 | require.Equal(t, http.StatusOK, rec.Code) 32 | }) 33 | 34 | t.Run("WriteHeaderTwice", func(t *testing.T) { 35 | t.Parallel() 36 | 37 | var ( 38 | rec = httptest.NewRecorder() 39 | w = &httpapi.StatusWriter{ResponseWriter: rec} 40 | code = http.StatusNotFound 41 | ) 42 | 43 | w.WriteHeader(code) 44 | w.WriteHeader(http.StatusOK) 45 | // Validate that we only record the first status code. 46 | require.Equal(t, code, w.Status) 47 | // Validate that the code is written to the underlying Response. 48 | require.Equal(t, code, rec.Code) 49 | }) 50 | 51 | t.Run("WriteNoHeader", func(t *testing.T) { 52 | t.Parallel() 53 | var ( 54 | rec = httptest.NewRecorder() 55 | w = &httpapi.StatusWriter{ResponseWriter: rec} 56 | body = []byte("hello") 57 | ) 58 | 59 | _, err := w.Write(body) 60 | require.NoError(t, err) 61 | 62 | // Should set the status to OK. 63 | require.Equal(t, http.StatusOK, w.Status) 64 | // We don't record the body for codes <400. 65 | require.Equal(t, []byte(nil), w.ResponseBody()) 66 | require.Equal(t, body, rec.Body.Bytes()) 67 | }) 68 | 69 | t.Run("WriteAfterHeader", func(t *testing.T) { 70 | t.Parallel() 71 | var ( 72 | rec = httptest.NewRecorder() 73 | w = &httpapi.StatusWriter{ResponseWriter: rec} 74 | body = []byte("hello") 75 | code = http.StatusInternalServerError 76 | ) 77 | 78 | w.WriteHeader(code) 79 | _, err := w.Write(body) 80 | require.NoError(t, err) 81 | 82 | require.Equal(t, code, w.Status) 83 | require.Equal(t, body, w.ResponseBody()) 84 | require.Equal(t, body, rec.Body.Bytes()) 85 | }) 86 | 87 | t.Run("WriteMaxBody", func(t *testing.T) { 88 | t.Parallel() 89 | var ( 90 | rec = httptest.NewRecorder() 91 | w = &httpapi.StatusWriter{ResponseWriter: rec} 92 | // 8kb body. 93 | body = make([]byte, 8<<10) 94 | code = http.StatusInternalServerError 95 | ) 96 | 97 | _, err := rand.Read(body) 98 | require.NoError(t, err) 99 | 100 | w.WriteHeader(code) 101 | _, err = w.Write(body) 102 | require.NoError(t, err) 103 | 104 | require.Equal(t, code, w.Status) 105 | require.Equal(t, body, rec.Body.Bytes()) 106 | require.Equal(t, body[:4096], w.ResponseBody()) 107 | }) 108 | 109 | t.Run("Hijack", func(t *testing.T) { 110 | t.Parallel() 111 | var ( 112 | rec = httptest.NewRecorder() 113 | ) 114 | 115 | w := &httpapi.StatusWriter{ResponseWriter: hijacker{rec}} 116 | 117 | _, _, err := w.Hijack() 118 | require.Error(t, err) 119 | require.Equal(t, "hijacked", err.Error()) 120 | }) 121 | } 122 | 123 | type hijacker struct { 124 | http.ResponseWriter 125 | } 126 | 127 | func (hijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { 128 | return nil, nil, xerrors.New("hijacked") 129 | } 130 | -------------------------------------------------------------------------------- /api/httpmw/buildinfo.go: -------------------------------------------------------------------------------- 1 | package httpmw 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/coder/code-marketplace/buildinfo" 7 | ) 8 | 9 | // AttachBuildInfo adds a build info header to each HTTP request. 10 | func AttachBuildInfo(next http.Handler) http.Handler { 11 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 12 | rw.Header().Add("Build-Version", buildinfo.Version()) 13 | next.ServeHTTP(rw, r) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /api/httpmw/buildinfo_test.go: -------------------------------------------------------------------------------- 1 | package httpmw_test 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/require" 10 | 11 | "github.com/coder/code-marketplace/api/httpmw" 12 | "github.com/coder/code-marketplace/buildinfo" 13 | ) 14 | 15 | func TestBuildInfo(t *testing.T) { 16 | t.Parallel() 17 | 18 | rtr := chi.NewRouter() 19 | rtr.Use(httpmw.AttachBuildInfo) 20 | rtr.Get("/", func(w http.ResponseWriter, r *http.Request) { 21 | w.WriteHeader(http.StatusOK) 22 | }) 23 | r := httptest.NewRequest("GET", "/", nil) 24 | rw := httptest.NewRecorder() 25 | rtr.ServeHTTP(rw, r) 26 | 27 | res := rw.Result() 28 | defer res.Body.Close() 29 | require.Equal(t, http.StatusOK, res.StatusCode) 30 | require.Equal(t, buildinfo.Version(), res.Header.Get("Build-Version")) 31 | } 32 | -------------------------------------------------------------------------------- /api/httpmw/cors.go: -------------------------------------------------------------------------------- 1 | package httpmw 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/cors" 7 | ) 8 | 9 | const ( 10 | // Server headers. 11 | AccessControlAllowOriginHeader = "Access-Control-Allow-Origin" 12 | AccessControlAllowCredentialsHeader = "Access-Control-Allow-Credentials" 13 | AccessControlAllowMethodsHeader = "Access-Control-Allow-Methods" 14 | AccessControlAllowHeadersHeader = "Access-Control-Allow-Headers" 15 | VaryHeader = "Vary" 16 | 17 | // Client headers. 18 | OriginHeader = "Origin" 19 | AccessControlRequestMethodHeader = "Access-Control-Request-Method" 20 | AccessControlRequestHeadersHeader = "Access-Control-Request-Headers" 21 | ) 22 | 23 | func Cors() func(next http.Handler) http.Handler { 24 | return cors.Handler(cors.Options{ 25 | AllowedOrigins: []string{"*"}, 26 | AllowedMethods: []string{ 27 | http.MethodHead, 28 | http.MethodGet, 29 | http.MethodPost, 30 | http.MethodPut, 31 | http.MethodPatch, 32 | http.MethodDelete, 33 | }, 34 | AllowedHeaders: []string{"*"}, 35 | AllowCredentials: true, 36 | MaxAge: 300, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /api/httpmw/cors_test.go: -------------------------------------------------------------------------------- 1 | package httpmw_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/coder/code-marketplace/api/httpmw" 11 | ) 12 | 13 | func TestCors(t *testing.T) { 14 | t.Parallel() 15 | 16 | methods := []string{ 17 | http.MethodOptions, 18 | http.MethodHead, 19 | http.MethodGet, 20 | http.MethodPost, 21 | http.MethodPut, 22 | http.MethodPatch, 23 | http.MethodDelete, 24 | } 25 | 26 | tests := []struct { 27 | name string 28 | origin string 29 | allowedOrigin string 30 | headers string 31 | allowedHeaders string 32 | }{ 33 | { 34 | name: "LocalHTTP", 35 | origin: "http://localhost:3000", 36 | allowedOrigin: "*", 37 | }, 38 | { 39 | name: "LocalHTTPS", 40 | origin: "https://localhost:3000", 41 | allowedOrigin: "*", 42 | }, 43 | { 44 | name: "HTTP", 45 | origin: "http://code-server.domain.tld", 46 | allowedOrigin: "*", 47 | }, 48 | { 49 | name: "HTTPS", 50 | origin: "https://code-server.domain.tld", 51 | allowedOrigin: "*", 52 | }, 53 | { 54 | // VS Code appears to use this origin. 55 | name: "VSCode", 56 | origin: "vscode-file://vscode-app", 57 | allowedOrigin: "*", 58 | }, 59 | { 60 | name: "NoOrigin", 61 | allowedOrigin: "", 62 | }, 63 | { 64 | name: "Headers", 65 | origin: "foobar", 66 | allowedOrigin: "*", 67 | headers: "X-TEST,X-TEST2", 68 | allowedHeaders: "X-Test, X-Test2", 69 | }, 70 | } 71 | 72 | for _, test := range tests { 73 | test := test 74 | t.Run(test.name, func(t *testing.T) { 75 | t.Parallel() 76 | 77 | for _, method := range methods { 78 | method := method 79 | t.Run(method, func(t *testing.T) { 80 | t.Parallel() 81 | 82 | r := httptest.NewRequest(method, "http://dev.coder.com", nil) 83 | if test.origin != "" { 84 | r.Header.Set(httpmw.OriginHeader, test.origin) 85 | } 86 | 87 | // OPTIONS requests need to know what method will be requested, or 88 | // go-chi/cors will error. Both request headers and methods should be 89 | // ignored for regular requests even if they are set, although that is 90 | // not tested here. 91 | if method == http.MethodOptions { 92 | r.Header.Set(httpmw.AccessControlRequestMethodHeader, http.MethodGet) 93 | if test.headers != "" { 94 | r.Header.Set(httpmw.AccessControlRequestHeadersHeader, test.headers) 95 | } 96 | } 97 | 98 | rw := httptest.NewRecorder() 99 | handler := httpmw.Cors()(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 100 | rw.WriteHeader(http.StatusNoContent) 101 | })) 102 | handler.ServeHTTP(rw, r) 103 | 104 | // Should always set some kind of allowed origin, if allowed. 105 | require.Equal(t, test.allowedOrigin, rw.Header().Get(httpmw.AccessControlAllowOriginHeader)) 106 | 107 | // OPTIONS should echo back the request method and headers (if there 108 | // is an origin header set) and we should never get to our handler as 109 | // the middleware short-circuits with a 200. 110 | if method == http.MethodOptions && test.origin == "" { 111 | require.Equal(t, "", rw.Header().Get(httpmw.AccessControlAllowMethodsHeader)) 112 | require.Equal(t, "", rw.Header().Get(httpmw.AccessControlAllowHeadersHeader)) 113 | require.Equal(t, http.StatusOK, rw.Code) 114 | } else if method == http.MethodOptions { 115 | require.Equal(t, http.MethodGet, rw.Header().Get(httpmw.AccessControlAllowMethodsHeader)) 116 | require.Equal(t, test.allowedHeaders, rw.Header().Get(httpmw.AccessControlAllowHeadersHeader)) 117 | require.Equal(t, http.StatusOK, rw.Code) 118 | } else { 119 | require.Equal(t, "", rw.Header().Get(httpmw.AccessControlAllowMethodsHeader)) 120 | require.Equal(t, "", rw.Header().Get(httpmw.AccessControlAllowHeadersHeader)) 121 | require.Equal(t, http.StatusNoContent, rw.Code) 122 | } 123 | }) 124 | } 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /api/httpmw/logger.go: -------------------------------------------------------------------------------- 1 | package httpmw 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "cdr.dev/slog" 8 | "github.com/coder/code-marketplace/api/httpapi" 9 | ) 10 | 11 | func Logger(log slog.Logger) func(next http.Handler) http.Handler { 12 | return func(next http.Handler) http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | start := time.Now() 15 | sw := &httpapi.StatusWriter{ResponseWriter: w} 16 | 17 | httplog := log.With( 18 | slog.F("host", r.Host), 19 | slog.F("path", r.URL.Path), 20 | slog.F("remote_addr", r.RemoteAddr), 21 | slog.F("client_id", r.Header.Get("x-market-client-id")), 22 | slog.F("user_id", r.Header.Get("x-market-user-id")), 23 | ) 24 | 25 | next.ServeHTTP(sw, r) 26 | 27 | // Do not log successful health check requests. 28 | if r.URL.Path == "/healthz" && sw.Status == 200 { 29 | return 30 | } 31 | 32 | httplog = httplog.With( 33 | slog.F("took", time.Since(start)), 34 | slog.F("status_code", sw.Status), 35 | slog.F("latency_ms", float64(time.Since(start)/time.Millisecond)), 36 | ) 37 | 38 | if sw.Status >= 400 { 39 | httplog = httplog.With( 40 | slog.F("response_body", string(sw.ResponseBody())), 41 | ) 42 | } 43 | 44 | logLevelFn := httplog.Debug 45 | if sw.Status >= 400 { 46 | logLevelFn = httplog.Warn 47 | } 48 | if sw.Status >= 500 { 49 | // Server errors should be treated as an ERROR log level. 50 | logLevelFn = httplog.Error 51 | } 52 | 53 | logLevelFn(r.Context(), r.Method) 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /api/httpmw/ratelimit.go: -------------------------------------------------------------------------------- 1 | package httpmw 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/go-chi/httprate" 8 | 9 | "github.com/coder/code-marketplace/api/httpapi" 10 | ) 11 | 12 | // RateLimitPerMinute returns a handler that limits requests per-minute based 13 | // on IP and endpoint. 14 | func RateLimitPerMinute(count int) func(http.Handler) http.Handler { 15 | // -1 is no rate limit 16 | if count <= 0 { 17 | return func(handler http.Handler) http.Handler { 18 | return handler 19 | } 20 | } 21 | return httprate.Limit( 22 | count, 23 | 1*time.Minute, 24 | httprate.WithKeyFuncs(func(r *http.Request) (string, error) { 25 | return httprate.KeyByIP(r) 26 | }, httprate.KeyByEndpoint), 27 | httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { 28 | httpapi.Write(w, http.StatusTooManyRequests, httpapi.ErrorResponse{ 29 | Message: "You have been rate limited!", 30 | Detail: "Please wait a minute then try again.", 31 | }) 32 | }), 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /api/httpmw/ratelimit_test.go: -------------------------------------------------------------------------------- 1 | package httpmw_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/go-chi/chi/v5" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/coder/code-marketplace/api/httpmw" 13 | ) 14 | 15 | func TestRateLimit(t *testing.T) { 16 | t.Parallel() 17 | t.Run("NoUser", func(t *testing.T) { 18 | t.Parallel() 19 | rtr := chi.NewRouter() 20 | rtr.Use(httpmw.RateLimitPerMinute(5)) 21 | rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { 22 | rw.WriteHeader(http.StatusOK) 23 | }) 24 | 25 | require.Eventually(t, func() bool { 26 | req := httptest.NewRequest("GET", "/", nil) 27 | rec := httptest.NewRecorder() 28 | rtr.ServeHTTP(rec, req) 29 | resp := rec.Result() 30 | defer resp.Body.Close() 31 | return resp.StatusCode == http.StatusTooManyRequests 32 | }, 5*time.Second, 25*time.Millisecond) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /api/httpmw/recover.go: -------------------------------------------------------------------------------- 1 | package httpmw 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "runtime/debug" 7 | 8 | "cdr.dev/slog" 9 | "github.com/coder/code-marketplace/api/httpapi" 10 | ) 11 | 12 | func Recover(log slog.Logger) func(h http.Handler) http.Handler { 13 | return func(h http.Handler) http.Handler { 14 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 15 | defer func() { 16 | err := recover() 17 | if err != nil { 18 | log.Warn(context.Background(), 19 | "panic serving http request (recovered)", 20 | slog.F("panic", err), 21 | slog.F("stack", string(debug.Stack())), 22 | ) 23 | 24 | var hijacked bool 25 | if sw, ok := rw.(*httpapi.StatusWriter); ok { 26 | hijacked = sw.Hijacked 27 | } 28 | 29 | if !hijacked { 30 | httpapi.Write(rw, http.StatusInternalServerError, httpapi.ErrorResponse{ 31 | Message: "An internal server error occurred.", 32 | Detail: "Application recovered from a panic", 33 | RequestID: RequestID(r), 34 | }) 35 | } 36 | } 37 | }() 38 | 39 | h.ServeHTTP(rw, r) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/httpmw/recover_test.go: -------------------------------------------------------------------------------- 1 | package httpmw_test 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/require" 10 | 11 | "cdr.dev/slog/sloggers/slogtest" 12 | "github.com/coder/code-marketplace/api/httpapi" 13 | "github.com/coder/code-marketplace/api/httpmw" 14 | ) 15 | 16 | func TestRecover(t *testing.T) { 17 | t.Parallel() 18 | 19 | handler := func(isPanic, hijack bool) http.HandlerFunc { 20 | return func(rw http.ResponseWriter, r *http.Request) { 21 | if isPanic { 22 | panic("Oh no!") 23 | } 24 | 25 | rw.WriteHeader(http.StatusOK) 26 | } 27 | } 28 | 29 | cases := []struct { 30 | Name string 31 | Code int 32 | Panic bool 33 | Hijack bool 34 | }{ 35 | { 36 | Name: "OK", 37 | Code: http.StatusOK, 38 | Panic: false, 39 | Hijack: false, 40 | }, 41 | { 42 | Name: "Panic", 43 | Code: http.StatusInternalServerError, 44 | Panic: true, 45 | Hijack: false, 46 | }, 47 | { 48 | Name: "Hijack", 49 | Code: 0, 50 | Panic: true, 51 | Hijack: true, 52 | }, 53 | } 54 | 55 | for _, c := range cases { 56 | c := c 57 | 58 | t.Run(c.Name, func(t *testing.T) { 59 | t.Parallel() 60 | 61 | var ( 62 | log = slogtest.Make(t, nil) 63 | rtr = chi.NewRouter() 64 | r = httptest.NewRequest("GET", "/", nil) 65 | sw = &httpapi.StatusWriter{ 66 | ResponseWriter: httptest.NewRecorder(), 67 | Hijacked: c.Hijack, 68 | } 69 | ) 70 | 71 | rtr.Use(httpmw.AttachRequestID, httpmw.Recover(log)) 72 | rtr.Get("/", handler(c.Panic, c.Hijack)) 73 | rtr.ServeHTTP(sw, r) 74 | 75 | require.Equal(t, c.Code, sw.Status) 76 | }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /api/httpmw/requestid.go: -------------------------------------------------------------------------------- 1 | package httpmw 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/google/uuid" 8 | 9 | "cdr.dev/slog" 10 | ) 11 | 12 | type requestIDContextKey struct{} 13 | 14 | // RequestID returns the ID of the request. 15 | func RequestID(r *http.Request) uuid.UUID { 16 | rid, ok := r.Context().Value(requestIDContextKey{}).(uuid.UUID) 17 | if !ok { 18 | panic("developer error: request id middleware not provided") 19 | } 20 | return rid 21 | } 22 | 23 | // AttachRequestID adds a request ID to each HTTP request. 24 | func AttachRequestID(next http.Handler) http.Handler { 25 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 26 | rid := uuid.New() 27 | 28 | ctx := context.WithValue(r.Context(), requestIDContextKey{}, rid) 29 | ctx = slog.With(ctx, slog.F("request_id", rid)) 30 | 31 | rw.Header().Set("X-Coder-Request-Id", rid.String()) 32 | next.ServeHTTP(rw, r.WithContext(ctx)) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /api/httpmw/requestid_test.go: -------------------------------------------------------------------------------- 1 | package httpmw_test 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/require" 10 | 11 | "github.com/coder/code-marketplace/api/httpmw" 12 | ) 13 | 14 | func TestRequestID(t *testing.T) { 15 | t.Parallel() 16 | 17 | rtr := chi.NewRouter() 18 | rtr.Use(httpmw.AttachRequestID) 19 | rtr.Get("/", func(w http.ResponseWriter, r *http.Request) { 20 | rid := httpmw.RequestID(r) 21 | w.WriteHeader(http.StatusOK) 22 | _, err := w.Write([]byte(rid.String())) 23 | require.NoError(t, err) 24 | }) 25 | r := httptest.NewRequest("GET", "/", nil) 26 | rw := httptest.NewRecorder() 27 | rtr.ServeHTTP(rw, r) 28 | 29 | res := rw.Result() 30 | defer res.Body.Close() 31 | require.Equal(t, http.StatusOK, res.StatusCode) 32 | require.NotEmpty(t, res.Header.Get("X-Coder-Request-ID")) 33 | require.NotEmpty(t, rw.Body.Bytes()) 34 | } 35 | -------------------------------------------------------------------------------- /buildinfo/buildinfo.go: -------------------------------------------------------------------------------- 1 | package buildinfo 2 | 3 | var ( 4 | // Injected with ldflags at build-time. 5 | tag string 6 | ) 7 | 8 | func Version() string { 9 | if tag == "" { 10 | return "unknown" 11 | } 12 | return tag 13 | } 14 | -------------------------------------------------------------------------------- /buildinfo/buildinfo_test.go: -------------------------------------------------------------------------------- 1 | package buildinfo_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/coder/code-marketplace/buildinfo" 9 | ) 10 | 11 | func TestBuildInfo(t *testing.T) { 12 | t.Parallel() 13 | 14 | version := buildinfo.Version() 15 | require.Equal(t, version, "unknown") 16 | } 17 | -------------------------------------------------------------------------------- /cli/add.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | "golang.org/x/xerrors" 12 | 13 | "github.com/coder/code-marketplace/storage" 14 | "github.com/coder/code-marketplace/util" 15 | ) 16 | 17 | func add() *cobra.Command { 18 | addFlags, opts := serverFlags() 19 | cmd := &cobra.Command{ 20 | Use: "add ", 21 | Short: "Add an extension to the marketplace", 22 | Example: strings.Join([]string{ 23 | " marketplace add https://domain.tld/extension.vsix --extensions-dir ./extensions", 24 | " marketplace add extension.vsix --artifactory http://artifactory.server/artifactory --repo extensions", 25 | " marketplace add extension-vsixs/ --extensions-dir ./extensions", 26 | }, "\n"), 27 | Args: cobra.ExactArgs(1), 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | ctx, cancel := context.WithCancel(cmd.Context()) 30 | defer cancel() 31 | 32 | store, err := storage.NewStorage(ctx, opts) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | // The source might be a local directory with extensions. 38 | isDir := false 39 | if !strings.HasPrefix(args[0], "http://") && !strings.HasPrefix(args[0], "https://") { 40 | stat, err := os.Stat(args[0]) 41 | if err != nil { 42 | return err 43 | } 44 | isDir = stat.IsDir() 45 | } 46 | 47 | var failed []string 48 | if isDir { 49 | files, err := os.ReadDir(args[0]) 50 | if err != nil { 51 | return err 52 | } 53 | for _, file := range files { 54 | s, err := doAdd(ctx, filepath.Join(args[0], file.Name()), store) 55 | if err != nil { 56 | _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Failed to unpack %s: %s\n", file.Name(), err.Error()) 57 | failed = append(failed, file.Name()) 58 | } else { 59 | _, _ = fmt.Fprintln(cmd.OutOrStdout(), strings.Join(s, "\n")) 60 | } 61 | } 62 | } else { 63 | s, err := doAdd(ctx, args[0], store) 64 | if err != nil { 65 | return err 66 | } 67 | _, _ = fmt.Fprintln(cmd.OutOrStdout(), strings.Join(s, "\n")) 68 | } 69 | 70 | if len(failed) > 0 { 71 | return xerrors.Errorf( 72 | "Failed to add %s: %s", 73 | util.Plural(len(failed), "extension", ""), 74 | strings.Join(failed, ", ")) 75 | } 76 | return nil 77 | }, 78 | } 79 | addFlags(cmd) 80 | 81 | return cmd 82 | } 83 | 84 | func doAdd(ctx context.Context, source string, store storage.Storage) ([]string, error) { 85 | // Read in the extension. In the future we might support stdin as well. 86 | vsix, err := storage.ReadVSIX(ctx, source) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | // The manifest is required to know where to place the extension since it 92 | // is unsafe to rely on the file name or URI. 93 | manifest, err := storage.ReadVSIXManifest(vsix) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | location, err := store.AddExtension(ctx, manifest, vsix) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | deps := []string{} 104 | pack := []string{} 105 | for _, prop := range manifest.Metadata.Properties.Property { 106 | if prop.Value == "" { 107 | continue 108 | } 109 | switch prop.ID { 110 | case storage.DependencyPropertyType: 111 | deps = append(deps, strings.Split(prop.Value, ",")...) 112 | case storage.PackPropertyType: 113 | pack = append(pack, strings.Split(prop.Value, ",")...) 114 | } 115 | } 116 | 117 | depCount := len(deps) 118 | id := storage.ExtensionIDFromManifest(manifest) 119 | summary := []string{ 120 | fmt.Sprintf("Unpacked %s to %s", id, location), 121 | fmt.Sprintf(" - %s has %s", id, util.Plural(depCount, "dependency", "dependencies")), 122 | } 123 | 124 | if depCount > 0 { 125 | for _, id := range deps { 126 | summary = append(summary, fmt.Sprintf(" - %s", id)) 127 | } 128 | } 129 | 130 | packCount := len(pack) 131 | if packCount > 0 { 132 | summary = append(summary, fmt.Sprintf(" - %s is in a pack with %s", id, util.Plural(packCount, "other extension", ""))) 133 | for _, id := range pack { 134 | summary = append(summary, fmt.Sprintf(" - %s", id)) 135 | } 136 | } else { 137 | summary = append(summary, fmt.Sprintf(" - %s is not in a pack", id)) 138 | } 139 | 140 | return summary, nil 141 | } 142 | -------------------------------------------------------------------------------- /cli/add_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/coder/code-marketplace/cli" 15 | "github.com/coder/code-marketplace/storage" 16 | "github.com/coder/code-marketplace/testutil" 17 | ) 18 | 19 | func TestAddHelp(t *testing.T) { 20 | t.Parallel() 21 | 22 | cmd := cli.Root() 23 | cmd.SetArgs([]string{"add", "--help"}) 24 | buf := new(bytes.Buffer) 25 | cmd.SetOut(buf) 26 | 27 | err := cmd.Execute() 28 | require.NoError(t, err) 29 | 30 | output := buf.String() 31 | require.Contains(t, output, "Add an extension", "has help") 32 | } 33 | 34 | func TestAdd(t *testing.T) { 35 | t.Parallel() 36 | 37 | tests := []struct { 38 | // error is the expected error. 39 | error string 40 | // extensions are extensions to add. Use for success cases. 41 | extensions []testutil.Extension 42 | // name is the name of the test. 43 | name string 44 | // platforms to add for the latest version of each extension. 45 | platforms []storage.Platform 46 | // vsixes contains raw bytes of extensions to add. Use for failure cases. 47 | vsixes [][]byte 48 | }{ 49 | { 50 | name: "OK", 51 | extensions: []testutil.Extension{testutil.Extensions[0]}, 52 | }, 53 | { 54 | name: "OKPlatforms", 55 | extensions: []testutil.Extension{testutil.Extensions[0]}, 56 | platforms: []storage.Platform{ 57 | storage.PlatformUnknown, 58 | storage.PlatformWin32X64, 59 | storage.PlatformLinuxX64, 60 | storage.PlatformDarwinX64, 61 | storage.PlatformWeb, 62 | }, 63 | }, 64 | { 65 | name: "InvalidVSIX", 66 | error: "not a valid zip", 67 | vsixes: [][]byte{{}}, 68 | }, 69 | { 70 | name: "BulkOK", 71 | extensions: []testutil.Extension{ 72 | testutil.Extensions[0], 73 | testutil.Extensions[1], 74 | testutil.Extensions[2], 75 | testutil.Extensions[3], 76 | }, 77 | }, 78 | { 79 | name: "BulkInvalid", 80 | error: "Failed to add 2 extensions: 0.vsix, 1.vsix", 81 | extensions: []testutil.Extension{ 82 | testutil.Extensions[0], 83 | testutil.Extensions[1], 84 | testutil.Extensions[2], 85 | testutil.Extensions[3], 86 | }, 87 | vsixes: [][]byte{ 88 | {}, 89 | []byte("foo"), 90 | }, 91 | }, 92 | } 93 | 94 | for _, test := range tests { 95 | test := test 96 | t.Run(test.name, func(t *testing.T) { 97 | t.Parallel() 98 | 99 | extdir := t.TempDir() 100 | count := 0 101 | create := func(vsix []byte) { 102 | source := filepath.Join(extdir, fmt.Sprintf("%d.vsix", count)) 103 | err := os.WriteFile(source, vsix, 0o644) 104 | require.NoError(t, err) 105 | count++ 106 | } 107 | for _, vsix := range test.vsixes { 108 | create(vsix) 109 | } 110 | for _, ext := range test.extensions { 111 | if len(test.platforms) > 0 { 112 | for _, platform := range test.platforms { 113 | create(testutil.CreateVSIXFromExtension(t, ext, storage.Version{Version: ext.LatestVersion, TargetPlatform: platform})) 114 | } 115 | } else { 116 | create(testutil.CreateVSIXFromExtension(t, ext, storage.Version{Version: ext.LatestVersion})) 117 | } 118 | } 119 | 120 | // With multiple extensions use bulk add by pointing to the directory 121 | // otherwise point to the vsix file. When not using bulk add also test 122 | // from HTTP. 123 | sources := []string{extdir} 124 | if count == 1 { 125 | sources = []string{filepath.Join(extdir, "0.vsix")} 126 | 127 | handler := func(rw http.ResponseWriter, r *http.Request) { 128 | var vsix []byte 129 | if test.vsixes == nil { 130 | vsix = testutil.CreateVSIXFromExtension(t, test.extensions[0], storage.Version{Version: test.extensions[0].LatestVersion}) 131 | } else { 132 | vsix = test.vsixes[0] 133 | } 134 | _, err := rw.Write(vsix) 135 | require.NoError(t, err) 136 | } 137 | server := httptest.NewServer(http.HandlerFunc(handler)) 138 | defer server.Close() 139 | 140 | sources = append(sources, server.URL) 141 | } 142 | 143 | for _, source := range sources { 144 | cmd := cli.Root() 145 | args := []string{"add", source, "--extensions-dir", extdir} 146 | cmd.SetArgs(args) 147 | buf := new(bytes.Buffer) 148 | cmd.SetOut(buf) 149 | 150 | err := cmd.Execute() 151 | output := buf.String() 152 | 153 | if test.error != "" { 154 | require.Error(t, err) 155 | require.Regexp(t, test.error, err.Error()) 156 | } else { 157 | require.NoError(t, err) 158 | require.NotContains(t, output, "Failed to add") 159 | } 160 | 161 | // Should list all the extensions that worked. 162 | for _, ext := range test.extensions { 163 | // Should exist on disk. 164 | dest := filepath.Join(extdir, ext.Publisher, ext.Name, ext.LatestVersion) 165 | _, err := os.Stat(dest) 166 | require.NoError(t, err) 167 | // Should tell you where it went. 168 | id := storage.ExtensionID(ext.Publisher, ext.Name, ext.LatestVersion) 169 | require.Contains(t, output, fmt.Sprintf("Unpacked %s to %s", id, dest)) 170 | // Should mention the dependencies and pack. 171 | require.Contains(t, output, fmt.Sprintf("%s has %d dep", id, len(ext.Dependencies))) 172 | if len(ext.Pack) > 0 { 173 | require.Contains(t, output, fmt.Sprintf("%s is in a pack with %d other", id, len(ext.Pack))) 174 | } else { 175 | require.Contains(t, output, fmt.Sprintf("%s is not in a pack", id)) 176 | } 177 | } 178 | } 179 | }) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /cli/remove.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | "golang.org/x/xerrors" 12 | 13 | "github.com/coder/code-marketplace/storage" 14 | "github.com/coder/code-marketplace/util" 15 | ) 16 | 17 | func remove() *cobra.Command { 18 | var ( 19 | all bool 20 | ) 21 | addFlags, opts := serverFlags() 22 | 23 | cmd := &cobra.Command{ 24 | Use: "remove ", 25 | Short: "Remove an extension from the marketplace", 26 | Example: strings.Join([]string{ 27 | " marketplace remove publisher.extension@1.0.0 --extensions-dir ./extensions", 28 | " marketplace remove publisher.extension --all --artifactory http://artifactory.server/artifactory --repo extensions", 29 | }, "\n"), 30 | Args: cobra.ExactArgs(1), 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | ctx, cancel := context.WithCancel(cmd.Context()) 33 | defer cancel() 34 | 35 | store, err := storage.NewStorage(ctx, opts) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | targetId := args[0] 41 | publisher, name, versionStr, err := storage.ParseExtensionID(targetId) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | version := storage.Version{Version: versionStr} 47 | if version.Version != "" && all { 48 | return xerrors.Errorf("cannot specify both --all and version %s", version) 49 | } 50 | 51 | allVersions, err := store.Versions(ctx, publisher, name) 52 | if err != nil && !errors.Is(err, os.ErrNotExist) { 53 | return err 54 | } 55 | 56 | versionCount := len(allVersions) 57 | if version.Version == "" && !all { 58 | return xerrors.Errorf( 59 | "use %s@ to target a specific version or pass --all to delete %s of %s", 60 | targetId, 61 | util.Plural(versionCount, "version", ""), 62 | targetId, 63 | ) 64 | } 65 | 66 | // TODO: Allow deleting by platform as well? 67 | var toDelete []storage.Version 68 | if all { 69 | toDelete = allVersions 70 | } else { 71 | for _, sv := range allVersions { 72 | if version.Version == sv.Version { 73 | toDelete = append(toDelete, sv) 74 | } 75 | } 76 | } 77 | if len(toDelete) == 0 { 78 | return xerrors.Errorf("%s does not exist", targetId) 79 | } 80 | 81 | _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removing %s...\n", util.Plural(len(toDelete), "version", "")) 82 | var failed []string 83 | for _, delete := range toDelete { 84 | err = store.RemoveExtension(ctx, publisher, name, delete) 85 | if err != nil { 86 | _, _ = fmt.Fprintf(cmd.OutOrStdout(), " - %s (%s)\n", delete, err) 87 | failed = append(failed, delete.String()) 88 | } else { 89 | _, _ = fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", delete) 90 | } 91 | } 92 | 93 | if len(failed) > 0 { 94 | return xerrors.Errorf( 95 | "Failed to remove %s: %s", 96 | util.Plural(len(failed), "version", ""), 97 | strings.Join(failed, ", ")) 98 | } 99 | return nil 100 | }, 101 | } 102 | 103 | cmd.Flags().BoolVar(&all, "all", false, "Whether to delete all versions of the extension.") 104 | addFlags(cmd) 105 | 106 | return cmd 107 | } 108 | -------------------------------------------------------------------------------- /cli/remove_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/coder/code-marketplace/cli" 13 | "github.com/coder/code-marketplace/storage" 14 | "github.com/coder/code-marketplace/testutil" 15 | ) 16 | 17 | func TestRemoveHelp(t *testing.T) { 18 | t.Parallel() 19 | 20 | cmd := cli.Root() 21 | cmd.SetArgs([]string{"remove", "--help"}) 22 | buf := new(bytes.Buffer) 23 | cmd.SetOut(buf) 24 | 25 | err := cmd.Execute() 26 | require.NoError(t, err) 27 | 28 | output := buf.String() 29 | require.Contains(t, output, "Remove an extension", "has help") 30 | } 31 | 32 | func TestRemove(t *testing.T) { 33 | t.Parallel() 34 | 35 | tests := []struct { 36 | // all means to pass --all. 37 | all bool 38 | // error is the expected error, if any. 39 | error string 40 | // expected contains the versions should have been deleted, if any. 41 | expected []storage.Version 42 | // extension is the extension to remove. Every version of 43 | // testutil.Extensions[0] will be added before each test. 44 | extension testutil.Extension 45 | // name is the name of the test. 46 | name string 47 | // version is the version to remove. 48 | version string 49 | }{ 50 | { 51 | name: "RemoveOne", 52 | extension: testutil.Extensions[0], 53 | version: "2.0.0", 54 | expected: []storage.Version{ 55 | {Version: "2.0.0"}, 56 | }, 57 | }, 58 | { 59 | name: "RemovePlatforms", 60 | extension: testutil.Extensions[0], 61 | version: testutil.Extensions[0].LatestVersion, 62 | expected: []storage.Version{ 63 | {Version: "3.0.0"}, 64 | {Version: "3.0.0", TargetPlatform: storage.PlatformAlpineX64}, 65 | {Version: "3.0.0", TargetPlatform: storage.PlatformDarwinX64}, 66 | {Version: "3.0.0", TargetPlatform: storage.PlatformLinuxArm64}, 67 | {Version: "3.0.0", TargetPlatform: storage.PlatformLinuxX64}, 68 | {Version: "3.0.0", TargetPlatform: storage.PlatformWin32X64}, 69 | }, 70 | }, 71 | { 72 | name: "All", 73 | extension: testutil.Extensions[0], 74 | all: true, 75 | expected: []storage.Version{ 76 | {Version: "1.0.0"}, 77 | {Version: "1.0.0", TargetPlatform: storage.PlatformWin32X64}, 78 | {Version: "1.5.2"}, 79 | {Version: "2.0.0"}, 80 | {Version: "2.2.2"}, 81 | {Version: "3.0.0"}, 82 | {Version: "3.0.0", TargetPlatform: storage.PlatformAlpineX64}, 83 | {Version: "3.0.0", TargetPlatform: storage.PlatformDarwinX64}, 84 | {Version: "3.0.0", TargetPlatform: storage.PlatformLinuxArm64}, 85 | {Version: "3.0.0", TargetPlatform: storage.PlatformLinuxX64}, 86 | {Version: "3.0.0", TargetPlatform: storage.PlatformWin32X64}, 87 | }, 88 | }, 89 | { 90 | name: "MissingTarget", 91 | error: "target a specific version or pass --all", 92 | extension: testutil.Extensions[0], 93 | }, 94 | { 95 | name: "MissingTargetNoVersions", 96 | error: "target a specific version or pass --all", 97 | extension: testutil.Extensions[1], 98 | }, 99 | { 100 | name: "AllWithVersion", 101 | error: "cannot specify both", 102 | extension: testutil.Extensions[0], 103 | all: true, 104 | version: testutil.Extensions[0].LatestVersion, 105 | }, 106 | { 107 | name: "NoVersion", 108 | error: "does not exist", 109 | extension: testutil.Extensions[0], 110 | version: "does-not-exist", 111 | }, 112 | { 113 | name: "NoVersions", 114 | error: "does not exist", 115 | extension: testutil.Extensions[1], 116 | version: testutil.Extensions[1].LatestVersion, 117 | }, 118 | { 119 | name: "AllNoVersions", 120 | error: "does not exist", 121 | extension: testutil.Extensions[1], 122 | all: true, 123 | }, 124 | { 125 | // Cannot target specific platforms at the moment. If we wanted this 126 | // we would likely need to use a `--platform` flag since we already use @ 127 | // to delineate the version. 128 | name: "NoPlatformTarget", 129 | error: "does not exist", 130 | extension: testutil.Extensions[0], 131 | version: "1.0.0@win32-x64", 132 | }, 133 | } 134 | 135 | for _, test := range tests { 136 | test := test 137 | t.Run(test.name, func(t *testing.T) { 138 | t.Parallel() 139 | 140 | extdir := t.TempDir() 141 | ext := testutil.Extensions[0] 142 | for _, version := range ext.Versions { 143 | manifestPath := filepath.Join(extdir, ext.Publisher, ext.Name, version.String(), "extension.vsixmanifest") 144 | err := os.MkdirAll(filepath.Dir(manifestPath), 0o755) 145 | require.NoError(t, err) 146 | err = os.WriteFile(manifestPath, testutil.ConvertExtensionToManifestBytes(t, ext, version), 0o644) 147 | require.NoError(t, err) 148 | } 149 | 150 | id := fmt.Sprintf("%s.%s", test.extension.Publisher, test.extension.Name) 151 | if test.version != "" { 152 | id = fmt.Sprintf("%s@%s", id, test.version) 153 | } 154 | 155 | cmd := cli.Root() 156 | args := []string{"remove", id, "--extensions-dir", extdir} 157 | if test.all { 158 | args = append(args, "--all") 159 | } 160 | cmd.SetArgs(args) 161 | buf := new(bytes.Buffer) 162 | cmd.SetOut(buf) 163 | 164 | err := cmd.Execute() 165 | output := buf.String() 166 | 167 | if test.error != "" { 168 | require.Error(t, err) 169 | require.Regexp(t, test.error, err.Error()) 170 | } else { 171 | require.NoError(t, err) 172 | require.NotContains(t, output, "Failed to remove") 173 | } 174 | 175 | // Should list all the extensions that were able to be removed. 176 | if len(test.expected) > 0 { 177 | require.Contains(t, output, fmt.Sprintf("Removing %d version", len(test.expected))) 178 | for _, version := range test.expected { 179 | // Should not exist on disk. 180 | dest := filepath.Join(extdir, test.extension.Publisher, test.extension.Name, version.String()) 181 | _, err := os.Stat(dest) 182 | require.Error(t, err) 183 | require.Contains(t, output, fmt.Sprintf(" - %s\n", version)) 184 | } 185 | } 186 | }) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /cli/root.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func Root() *cobra.Command { 10 | cmd := &cobra.Command{ 11 | Use: "code-marketplace", 12 | SilenceErrors: true, 13 | SilenceUsage: true, 14 | Long: "Code extension marketplace", 15 | Example: strings.Join([]string{ 16 | " code-marketplace server --extensions-dir ./extensions", 17 | }, "\n"), 18 | } 19 | 20 | cmd.AddCommand(add(), remove(), server(), version(), signature()) 21 | 22 | cmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output") 23 | 24 | return cmd 25 | } 26 | -------------------------------------------------------------------------------- /cli/root_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/coder/code-marketplace/cli" 10 | ) 11 | 12 | func TestRoot(t *testing.T) { 13 | t.Parallel() 14 | 15 | cmd := cli.Root() 16 | buf := new(bytes.Buffer) 17 | cmd.SetOut(buf) 18 | 19 | err := cmd.Execute() 20 | require.NoError(t, err) 21 | 22 | output := buf.String() 23 | require.Contains(t, output, "Code extension marketplace", "has help") 24 | } 25 | -------------------------------------------------------------------------------- /cli/server.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | "net/http" 8 | "os/signal" 9 | "strings" 10 | "time" 11 | 12 | "github.com/spf13/cobra" 13 | "golang.org/x/sync/errgroup" 14 | "golang.org/x/xerrors" 15 | 16 | "cdr.dev/slog" 17 | "cdr.dev/slog/sloggers/sloghuman" 18 | "github.com/coder/code-marketplace/api" 19 | "github.com/coder/code-marketplace/database" 20 | "github.com/coder/code-marketplace/storage" 21 | ) 22 | 23 | func serverFlags() (addFlags func(cmd *cobra.Command), opts *storage.Options) { 24 | opts = &storage.Options{} 25 | return func(cmd *cobra.Command) { 26 | cmd.Flags().StringVar(&opts.ExtDir, "extensions-dir", "", "The path to extensions.") 27 | cmd.Flags().StringVar(&opts.Artifactory, "artifactory", "", "Artifactory server URL.") 28 | cmd.Flags().StringVar(&opts.Repo, "repo", "", "Artifactory repository.") 29 | 30 | if cmd.Use == "server" { 31 | // Server only flags 32 | cmd.Flags().BoolVar(&opts.IncludeEmptySignatures, "sign", false, "Includes an empty signature for all extensions.") 33 | cmd.Flags().DurationVar(&opts.ListCacheDuration, "list-cache-duration", time.Minute, "The duration of the extension cache.") 34 | } 35 | 36 | var before func(cmd *cobra.Command, args []string) error 37 | if cmd.PreRunE != nil { 38 | before = cmd.PreRunE 39 | } 40 | if cmd.PreRun != nil { 41 | beforeNoE := cmd.PreRun 42 | before = func(cmd *cobra.Command, args []string) error { 43 | beforeNoE(cmd, args) 44 | return nil 45 | } 46 | } 47 | 48 | cmd.PreRunE = func(cmd *cobra.Command, args []string) error { 49 | opts.Logger = cmdLogger(cmd) 50 | if before != nil { 51 | return before(cmd, args) 52 | } 53 | return nil 54 | } 55 | }, opts 56 | } 57 | 58 | func cmdLogger(cmd *cobra.Command) slog.Logger { 59 | verbose, _ := cmd.Flags().GetBool("verbose") 60 | logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr())) 61 | if verbose { 62 | logger = logger.Leveled(slog.LevelDebug) 63 | } 64 | return logger 65 | } 66 | 67 | func server() *cobra.Command { 68 | var ( 69 | address string 70 | maxpagesize int 71 | ) 72 | addFlags, opts := serverFlags() 73 | 74 | cmd := &cobra.Command{ 75 | Use: "server", 76 | Short: "Start the Code extension marketplace", 77 | Example: strings.Join([]string{ 78 | " marketplace server --extensions-dir ./extensions", 79 | " marketplace server --artifactory http://artifactory.server/artifactory --repo extensions", 80 | }, "\n"), 81 | RunE: func(cmd *cobra.Command, args []string) error { 82 | ctx, cancel := context.WithCancel(cmd.Context()) 83 | defer cancel() 84 | logger := opts.Logger 85 | 86 | notifyCtx, notifyStop := signal.NotifyContext(ctx, interruptSignals...) 87 | defer notifyStop() 88 | 89 | store, err := storage.NewStorage(ctx, opts) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | // A separate listener is required to get the resulting address (as 95 | // opposed to using http.ListenAndServe()). 96 | listener, err := net.Listen("tcp", address) 97 | if err != nil { 98 | return xerrors.Errorf("listen %q: %w", address, err) 99 | } 100 | defer listener.Close() 101 | tcpAddr, valid := listener.Addr().(*net.TCPAddr) 102 | if !valid { 103 | return xerrors.New("must be listening on tcp") 104 | } 105 | logger.Info(ctx, "Started API server", slog.F("address", tcpAddr)) 106 | 107 | // Always no database for now. 108 | database := &database.NoDB{ 109 | Storage: store, 110 | Logger: logger, 111 | } 112 | 113 | // Start the API server. 114 | mapi := api.New(&api.Options{ 115 | Database: database, 116 | Storage: store, 117 | Logger: logger, 118 | MaxPageSize: maxpagesize, 119 | }) 120 | server := &http.Server{ 121 | Handler: mapi.Handler, 122 | BaseContext: func(_ net.Listener) context.Context { 123 | return ctx 124 | }, 125 | } 126 | eg := errgroup.Group{} 127 | eg.Go(func() error { 128 | return server.Serve(listener) 129 | }) 130 | errCh := make(chan error, 1) 131 | go func() { 132 | select { 133 | case errCh <- eg.Wait(): 134 | default: 135 | } 136 | }() 137 | 138 | // Wait for an interrupt or error. 139 | var exitErr error 140 | select { 141 | case <-notifyCtx.Done(): 142 | exitErr = notifyCtx.Err() 143 | logger.Info(ctx, "Interrupt caught, gracefully exiting...") 144 | case exitErr = <-errCh: 145 | } 146 | if exitErr != nil && !errors.Is(exitErr, context.Canceled) { 147 | logger.Error(ctx, "Unexpected error, shutting down server...", slog.Error(exitErr)) 148 | } 149 | 150 | // Shut down the server. 151 | logger.Info(ctx, "Shutting down API server...") 152 | cancel() // Cancel in-flight requests since Shutdown() will not do this. 153 | timeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) 154 | defer cancel() 155 | err = server.Shutdown(timeout) 156 | if err != nil { 157 | logger.Error(ctx, "API server shutdown took longer than 5s", slog.Error(err)) 158 | } else { 159 | logger.Info(ctx, "Gracefully shut down API server\n") 160 | } 161 | 162 | return nil 163 | }, 164 | } 165 | 166 | cmd.Flags().IntVar(&maxpagesize, "max-page-size", api.MaxPageSizeDefault, "The maximum number of pages to request") 167 | cmd.Flags().StringVar(&address, "address", "127.0.0.1:3001", "The address on which to serve the marketplace API.") 168 | addFlags(cmd) 169 | 170 | return cmd 171 | } 172 | -------------------------------------------------------------------------------- /cli/server_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/coder/code-marketplace/cli" 10 | ) 11 | 12 | func TestServer(t *testing.T) { 13 | t.Parallel() 14 | 15 | cmd := cli.Root() 16 | cmd.SetArgs([]string{"server", "--help"}) 17 | buf := new(bytes.Buffer) 18 | cmd.SetOut(buf) 19 | 20 | err := cmd.Execute() 21 | require.NoError(t, err) 22 | 23 | output := buf.String() 24 | require.Contains(t, output, "Start the Code", "has help") 25 | } 26 | -------------------------------------------------------------------------------- /cli/signal_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package cli 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | ) 9 | 10 | var interruptSignals = []os.Signal{ 11 | os.Interrupt, 12 | syscall.SIGTERM, 13 | syscall.SIGHUP, 14 | } 15 | -------------------------------------------------------------------------------- /cli/signal_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package cli 4 | 5 | import ( 6 | "os" 7 | ) 8 | 9 | var interruptSignals = []os.Signal{os.Interrupt} 10 | -------------------------------------------------------------------------------- /cli/signature.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "golang.org/x/xerrors" 9 | 10 | "github.com/coder/code-marketplace/extensionsign" 11 | ) 12 | 13 | func signature() *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "signature", 16 | Short: "Commands for debugging and working with signatures.", 17 | Hidden: true, // Debugging tools 18 | Aliases: []string{"sig", "sigs", "signatures"}, 19 | } 20 | cmd.AddCommand(compareSignatureSigZips()) 21 | return cmd 22 | } 23 | 24 | func compareSignatureSigZips() *cobra.Command { 25 | cmd := &cobra.Command{ 26 | Use: "compare", 27 | Args: cobra.ExactArgs(2), 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | decode := func(path string) (extensionsign.SignatureManifest, error) { 30 | data, err := os.ReadFile(path) 31 | if err != nil { 32 | return extensionsign.SignatureManifest{}, xerrors.Errorf("read %q: %w", args[0], err) 33 | } 34 | 35 | sig, err := extensionsign.ExtractSignatureManifest(data) 36 | if err != nil { 37 | return extensionsign.SignatureManifest{}, xerrors.Errorf("unmarshal %q: %w", path, err) 38 | } 39 | return sig, nil 40 | } 41 | 42 | a, err := decode(args[0]) 43 | if err != nil { 44 | return err 45 | } 46 | b, err := decode(args[1]) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | _, _ = fmt.Fprintf(os.Stdout, "Signature A:%s\n", a) 52 | _, _ = fmt.Fprintf(os.Stdout, "Signature B:%s\n", b) 53 | err = a.Equal(b) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | _, _ = fmt.Fprintf(os.Stdout, "Signatures are equal\n") 59 | return nil 60 | }, 61 | } 62 | return cmd 63 | } 64 | -------------------------------------------------------------------------------- /cli/version.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/coder/code-marketplace/buildinfo" 9 | ) 10 | 11 | func version() *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "version", 14 | Short: "Show marketplace version", 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | _, _ = fmt.Fprintln(cmd.OutOrStdout(), buildinfo.Version()) 17 | return nil 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cli/version_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/coder/code-marketplace/buildinfo" 10 | "github.com/coder/code-marketplace/cli" 11 | ) 12 | 13 | func TestVersion(t *testing.T) { 14 | t.Parallel() 15 | 16 | cmd := cli.Root() 17 | cmd.SetArgs([]string{"version"}) 18 | buf := new(bytes.Buffer) 19 | cmd.SetOut(buf) 20 | 21 | err := cmd.Execute() 22 | require.NoError(t, err) 23 | 24 | output := buf.String() 25 | require.Contains(t, output, buildinfo.Version(), "has version") 26 | } 27 | -------------------------------------------------------------------------------- /cmd/marketplace/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/coder/code-marketplace/cli" 8 | ) 9 | 10 | func main() { 11 | err := cli.Root().Execute() 12 | if err != nil { 13 | _, _ = fmt.Fprintln(os.Stderr, err) 14 | os.Exit(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cmd/marketplace/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMain(t *testing.T) { 8 | t.Parallel() 9 | // It would be interesting to test this but it would require launching a 10 | // separate process then somehow merging the coverage. 11 | // main() 12 | } 13 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/coder/code-marketplace/storage" 9 | ) 10 | 11 | // API references: 12 | // https://github.com/microsoft/vscode-vsce/blob/main/src/xml.ts 13 | // https://github.com/microsoft/vscode/blob/main/src/vs/platform/extensionManagement/common/extensionManagement.ts 14 | // https://github.com/microsoft/vscode/blob/32095ba21fc83f38506d5550f9cb4ed0de233447/src/vs/platform/extensionManagement/common/extensionGalleryService.ts 15 | 16 | // SortBy implements SortBy. 17 | // https://github.com/microsoft/vscode/blob/main/src/vs/platform/extensionManagement/common/extensionManagement.ts#L254-L263 18 | type SortBy int 19 | 20 | const ( 21 | NoneOrRelevance SortBy = 0 22 | LastUpdatedDate SortBy = 1 23 | Title SortBy = 2 24 | PublisherName SortBy = 3 25 | InstallCount SortBy = 4 26 | PublishedDate SortBy = 5 27 | AverageRating SortBy = 6 28 | WeightedRating SortBy = 12 29 | ) 30 | 31 | // SortOrder implements SortOrder. 32 | // https://github.com/microsoft/vscode/blob/main/src/vs/platform/extensionManagement/common/extensionManagement.ts#L265-L269 33 | type SortOrder int 34 | 35 | const ( 36 | Default SortOrder = 0 37 | Ascending SortOrder = 1 38 | Descending SortOrder = 2 39 | ) 40 | 41 | // Criteria implements ICriterium. The criteria is an OR, not AND except for 42 | // Target. Any extension that matches any of the criteria (plus Target if 43 | // included) is included in the result. 44 | // https://github.com/microsoft/vscode/blob/a69f95fdf3dc27511517eef5ff62b21c7a418015/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L209-L212 45 | type Criteria struct { 46 | Type FilterType `json:"filterType"` 47 | Value string `json:"value"` 48 | } 49 | 50 | // FilterType implements FilterType. 51 | // https://github.com/microsoft/vscode/blob/a69f95fdf3dc27511517eef5ff62b21c7a418015/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L178-L187 52 | type FilterType int 53 | 54 | const ( 55 | Tag FilterType = 1 56 | ExtensionID FilterType = 4 57 | Category FilterType = 5 58 | ExtensionName FilterType = 7 59 | Target FilterType = 8 60 | Featured FilterType = 9 61 | SearchText FilterType = 10 62 | ExcludeWithFlags FilterType = 12 63 | ) 64 | 65 | // Flag implements Flags. 66 | // https://github.com/microsoft/vscode/blob/a69f95fdf3dc27511517eef5ff62b21c7a418015/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L94-L172 67 | type Flag int 68 | 69 | const ( 70 | None Flag = 0x0 71 | IncludeVersions Flag = 0x1 72 | IncludeFiles Flag = 0x2 73 | IncludeCategoryAndTags Flag = 0x4 74 | IncludeSharedAccounts Flag = 0x8 75 | IncludeVersionProperties Flag = 0x10 76 | ExcludeNonValidated Flag = 0x20 77 | IncludeInstallationTargets Flag = 0x40 78 | IncludeAssetURI Flag = 0x80 79 | IncludeStatistics Flag = 0x100 80 | IncludeLatestVersionOnly Flag = 0x200 81 | Unpublished Flag = 0x1000 82 | ) 83 | 84 | // Filter implements an untyped object. 85 | // https://github.com/microsoft/vscode/blob/a69f95fdf3dc27511517eef5ff62b21c7a418015/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L340 86 | type Filter struct { 87 | Criteria []Criteria `json:"criteria"` 88 | PageNumber int `json:"pageNumber"` 89 | PageSize int `json:"pageSize"` 90 | SortBy SortBy `json:"sortBy"` 91 | SortOrder SortOrder `json:"sortOrder"` 92 | } 93 | 94 | // Extension implements IRawGalleryExtension. This represents a single 95 | // available extension along with all its available versions. 96 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L65-L79 97 | type Extension struct { 98 | ID string `json:"extensionId"` 99 | Name string `json:"extensionName"` 100 | DisplayName string `json:"displayName"` 101 | ShortDescription string `json:"shortDescription"` 102 | Publisher ExtPublisher `json:"publisher"` 103 | Versions []ExtVersion `json:"versions"` 104 | Statistics []ExtStat `json:"statistics"` 105 | Tags []string `json:"tags,omitempty"` 106 | ReleaseDate time.Time `json:"releaseDate"` 107 | PublishedDate time.Time `json:"publishedDate"` 108 | LastUpdated time.Time `json:"lastUpdated"` 109 | Categories []string `json:"categories,omitempty"` 110 | Flags string `json:"flags"` 111 | } 112 | 113 | // ExtPublisher implements IRawGalleryExtensionPublisher. 114 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L57-L63 115 | type ExtPublisher struct { 116 | DisplayName string `json:"displayName"` 117 | PublisherID string `json:"publisherId"` 118 | PublisherName string `json:"publisherName"` 119 | Domain string `json:"string,omitempty"` 120 | DomainVerified bool `json:"isDomainVerified,omitempty"` 121 | } 122 | 123 | // ExtVersion implements IRawGalleryExtensionVersion. 124 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L42-L50 125 | type ExtVersion struct { 126 | storage.Version 127 | LastUpdated time.Time `json:"lastUpdated"` 128 | AssetURI string `json:"assetUri"` 129 | FallbackAssetURI string `json:"fallbackAssetUri"` 130 | Files []ExtFile `json:"files"` 131 | Properties []ExtProperty `json:"properties,omitempty"` 132 | } 133 | 134 | // ExtFile implements IRawGalleryExtensionFile. 135 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L32-L35 136 | type ExtFile struct { 137 | Type storage.AssetType `json:"assetType"` 138 | Source string `json:"source"` 139 | } 140 | 141 | // ExtProperty implements IRawGalleryExtensionProperty. 142 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L37-L40 143 | type ExtProperty struct { 144 | Key storage.PropertyType `json:"key"` 145 | Value string `json:"value"` 146 | } 147 | 148 | // ExtStat implements IRawGalleryExtensionStatistics. 149 | // https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L52-L55 150 | type ExtStat struct { 151 | StatisticName string `json:"statisticName"` 152 | Value float32 `json:"value"` 153 | } 154 | 155 | type Asset struct { 156 | Extension string 157 | Publisher string 158 | Type storage.AssetType 159 | Version storage.Version 160 | } 161 | 162 | type Database interface { 163 | // GetExtensionAssetPath returns the path of an asset by the asset type. 164 | GetExtensionAssetPath(ctx context.Context, asset *Asset, baseURL url.URL) (string, error) 165 | // GetExtensions returns paged extensions from the database that match the 166 | // filter along the total number of matched extensions. 167 | GetExtensions(ctx context.Context, filter Filter, flags Flag, baseURL url.URL) ([]*Extension, int, error) 168 | } 169 | -------------------------------------------------------------------------------- /database/nodb.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/url" 7 | "os" 8 | "path" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "github.com/lithammer/fuzzysearch/fuzzy" 14 | "golang.org/x/sync/errgroup" 15 | 16 | "cdr.dev/slog" 17 | 18 | "github.com/coder/code-marketplace/storage" 19 | ) 20 | 21 | // NoDB implements Database. It reads extensions directly off storage then 22 | // filters, sorts, and paginates them. In other words, the file system is the 23 | // database. 24 | type NoDB struct { 25 | Storage storage.Storage 26 | Logger slog.Logger 27 | } 28 | 29 | func (db *NoDB) GetExtensionAssetPath(ctx context.Context, asset *Asset, baseURL url.URL) (string, error) { 30 | manifest, err := db.Storage.Manifest(ctx, asset.Publisher, asset.Extension, asset.Version) 31 | if err != nil { 32 | return "", err 33 | } 34 | 35 | fileBase := (&url.URL{ 36 | Scheme: baseURL.Scheme, 37 | Host: baseURL.Host, 38 | Path: path.Join( 39 | baseURL.Path, 40 | "files", 41 | asset.Publisher, 42 | asset.Extension, 43 | asset.Version.String()), 44 | }).String() 45 | 46 | for _, a := range manifest.Assets.Asset { 47 | if a.Addressable == "true" && a.Type == asset.Type { 48 | return fileBase + "/" + a.Path, nil 49 | } 50 | } 51 | 52 | return "", os.ErrNotExist 53 | } 54 | 55 | func (db *NoDB) GetExtensions(ctx context.Context, filter Filter, flags Flag, baseURL url.URL) ([]*Extension, int, error) { 56 | vscodeExts := []*noDBExtension{} 57 | 58 | start := time.Now() 59 | err := db.Storage.WalkExtensions(ctx, func(manifest *storage.VSIXManifest, versions []storage.Version) error { 60 | vscodeExt := convertManifestToExtension(manifest) 61 | if matched, distances := getMatches(vscodeExt, filter); matched { 62 | vscodeExt.versions = versions 63 | vscodeExt.distances = distances 64 | vscodeExts = append(vscodeExts, vscodeExt) 65 | } 66 | return nil 67 | }) 68 | if err != nil { 69 | return nil, 0, err 70 | } 71 | 72 | total := len(vscodeExts) 73 | db.Logger.Debug(ctx, "walk extensions", slog.F("took", time.Since(start)), slog.F("count", total)) 74 | 75 | start = time.Now() 76 | sortExtensions(vscodeExts, filter) 77 | db.Logger.Debug(ctx, "sort extensions", slog.F("took", time.Since(start))) 78 | 79 | start = time.Now() 80 | vscodeExts = paginateExtensions(vscodeExts, filter) 81 | db.Logger.Debug(ctx, "paginate extensions", slog.F("took", time.Since(start))) 82 | 83 | start = time.Now() 84 | err = db.handleFlags(ctx, vscodeExts, flags, baseURL) 85 | if err != nil { 86 | return nil, 0, err 87 | } 88 | db.Logger.Debug(ctx, "handle flags", slog.F("took", time.Since(start))) 89 | 90 | convertedExts := []*Extension{} 91 | for _, ext := range vscodeExts { 92 | convertedExts = append(convertedExts, &Extension{ 93 | ID: ext.ID, 94 | Name: ext.Name, 95 | DisplayName: ext.DisplayName, 96 | ShortDescription: ext.ShortDescription, 97 | Publisher: ext.Publisher, 98 | Versions: ext.Versions, 99 | Statistics: ext.Statistics, 100 | Tags: ext.Tags, 101 | ReleaseDate: ext.ReleaseDate, 102 | PublishedDate: ext.PublishedDate, 103 | LastUpdated: ext.LastUpdated, 104 | Categories: ext.Categories, 105 | Flags: ext.Flags, 106 | }) 107 | } 108 | return convertedExts, total, nil 109 | } 110 | 111 | func getMatches(extension *noDBExtension, filter Filter) (bool, []int) { 112 | // Normally we would want to handle ExcludeWithFlags but the only flag that 113 | // seems usable with it (and the only flag VS Code seems to send) is 114 | // Unpublished and currently there is no concept of unpublished extensions so 115 | // there is nothing to do. 116 | var ( 117 | triedFilter = false 118 | hasTarget = false 119 | distances = []int{} 120 | ) 121 | match := func(matches bool) { 122 | triedFilter = true 123 | if matches { 124 | distances = append(distances, 0) 125 | } 126 | } 127 | for _, c := range filter.Criteria { 128 | switch c.Type { 129 | case Tag: 130 | match(containsFold(extension.Tags, c.Value)) 131 | case ExtensionID: 132 | match(strings.EqualFold(extension.ID, c.Value)) 133 | case Category: 134 | match(containsFold(extension.Categories, c.Value)) 135 | case ExtensionName: 136 | // The value here is the fully qualified name `publisher.extension`. 137 | match(strings.EqualFold(extension.Publisher.PublisherName+"."+extension.Name, c.Value)) 138 | case Target: 139 | // Unlike the other criteria the target is an AND so if it does not match 140 | // we can abort early. 141 | if c.Value != "Microsoft.VisualStudio.Code" { 142 | return false, nil 143 | } 144 | // Otherwise we need to only include the extension if one of the other 145 | // criteria also matched which we can only know after we have gone through 146 | // them all since not all criteria are for matching (ExcludeWithFlags). 147 | hasTarget = true 148 | case Featured: 149 | // Currently unsupported; this would require a database. 150 | match(false) 151 | case SearchText: 152 | triedFilter = true 153 | // REVIEW: Does this even make any sense? 154 | // REVIEW: Should include categories and tags? 155 | // Search each token of the input individually. 156 | tokens := strings.FieldsFunc(c.Value, func(r rune) bool { 157 | return r == ' ' || r == ',' || r == '.' 158 | }) 159 | // Publisher is implement as SearchText via `publisher:"name"`. 160 | searchTokens := []string{} 161 | for _, token := range tokens { 162 | parts := strings.SplitN(token, ":", 2) 163 | if len(parts) == 2 && parts[0] == "publisher" { 164 | match(strings.EqualFold(extension.Publisher.PublisherName, strings.Trim(parts[1], "\""))) 165 | } else if token != "" { 166 | searchTokens = append(searchTokens, token) 167 | } 168 | } 169 | candidates := []string{extension.Name, extension.Publisher.PublisherName, extension.ShortDescription} 170 | allMatches := fuzzy.Ranks{} 171 | for _, token := range searchTokens { 172 | matches := fuzzy.RankFindFold(token, candidates) 173 | if len(matches) == 0 { 174 | // If even one token does not match all the matches are invalid. 175 | allMatches = fuzzy.Ranks{} 176 | break 177 | } 178 | allMatches = append(allMatches, matches...) 179 | } 180 | for _, match := range allMatches { 181 | distances = append(distances, match.Distance) 182 | } 183 | } 184 | } 185 | if !triedFilter && hasTarget { 186 | return true, nil 187 | } 188 | sort.Ints(distances) 189 | return len(distances) > 0, distances 190 | } 191 | 192 | func sortExtensions(extensions []*noDBExtension, filter Filter) { 193 | sort.Slice(extensions, func(i, j int) bool { 194 | less := false 195 | a := extensions[i] 196 | b := extensions[j] 197 | outer: 198 | switch filter.SortBy { 199 | // These are not supported because we are not storing this information. 200 | case LastUpdatedDate: 201 | fallthrough 202 | case PublishedDate: 203 | fallthrough 204 | case AverageRating: 205 | fallthrough 206 | case WeightedRating: 207 | fallthrough 208 | case InstallCount: 209 | fallthrough 210 | case Title: 211 | less = a.Name < b.Name 212 | case PublisherName: 213 | if a.Publisher.PublisherName < b.Publisher.PublisherName { 214 | less = true 215 | } else if a.Publisher.PublisherName == b.Publisher.PublisherName { 216 | less = a.Name < b.Name 217 | } 218 | default: // NoneOrRelevance 219 | // No idea if this is any good but select the extension with the closest 220 | // match. If they both have a match with the same closeness look for the 221 | // next closest and so on. 222 | blen := len(b.distances) 223 | for k := range a.distances { // Iterate in order since these are sorted. 224 | if k >= blen { // Same closeness so far but a has more matches than b. 225 | less = true 226 | break outer 227 | } else if a.distances[k] < b.distances[k] { 228 | less = true 229 | break outer 230 | } else if a.distances[k] > b.distances[k] { 231 | break outer 232 | } 233 | } 234 | // Same closeness so far but b has more matches than a. 235 | if len(a.distances) < blen { 236 | break outer 237 | } 238 | // Same closeness, use name instead. 239 | less = a.Name < b.Name 240 | } 241 | if filter.SortOrder == Ascending { 242 | return !less 243 | } else { 244 | return less 245 | } 246 | }) 247 | } 248 | 249 | func paginateExtensions(exts []*noDBExtension, filter Filter) []*noDBExtension { 250 | page := filter.PageNumber 251 | if page <= 0 { 252 | page = 1 253 | } 254 | size := filter.PageSize 255 | if size <= 0 { 256 | size = 50 257 | } 258 | start := (page - 1) * size 259 | length := len(exts) 260 | if start > length { 261 | start = length 262 | } 263 | end := start + size 264 | if end > length { 265 | end = length 266 | } 267 | return exts[start:end] 268 | } 269 | 270 | func (db *NoDB) handleFlags(ctx context.Context, exts []*noDBExtension, flags Flag, baseURL url.URL) error { 271 | var eg errgroup.Group 272 | for _, ext := range exts { 273 | // Files, properties, and asset URIs are part of versions so if they are set 274 | // assume we also want to include versions. 275 | if flags&IncludeVersions != 0 || 276 | flags&IncludeFiles != 0 || 277 | flags&IncludeVersionProperties != 0 || 278 | flags&IncludeLatestVersionOnly != 0 || 279 | flags&IncludeAssetURI != 0 { 280 | // Depending on the storage mechanism fetching a manifest can be very 281 | // slow so run the requests in parallel. 282 | ext := ext 283 | eg.Go(func() error { 284 | versions, err := db.getVersions(ctx, ext, flags, baseURL) 285 | if err != nil { 286 | return err 287 | } 288 | ext.Versions = versions 289 | return nil 290 | }) 291 | } 292 | 293 | // TODO: This does not seem to be included in any interfaces so no idea 294 | // where to put this info if it is requested. 295 | // flags&IncludeInstallationTargets != 0 296 | 297 | // Categories and tags are already included (for filtering on them) so we 298 | // need to instead remove them. 299 | if flags&IncludeCategoryAndTags == 0 { 300 | ext.Categories = []string{} 301 | ext.Tags = []string{} 302 | } 303 | 304 | // Unsupported flags. 305 | // if flags&IncludeSharedAccounts != 0 {} 306 | // if flags&ExcludeNonValidated != 0 {} 307 | // if flags&IncludeStatistics != 0 {} 308 | // if flags&Unpublished != 0 {} 309 | } 310 | return eg.Wait() 311 | } 312 | 313 | func (db *NoDB) getVersions(ctx context.Context, ext *noDBExtension, flags Flag, baseURL url.URL) ([]ExtVersion, error) { 314 | ctx = slog.With(ctx, 315 | slog.F("publisher", ext.Publisher.PublisherName), 316 | slog.F("extension", ext.Name)) 317 | 318 | var storageVers []storage.Version 319 | if flags&IncludeLatestVersionOnly != 0 { 320 | // There might be multiple platforms for this version so find all the ones 321 | // that match. Since they are sorted we can bail once one does not match. 322 | latestVersion := ext.versions[0].Version 323 | for _, version := range ext.versions { 324 | if version.Version == latestVersion { 325 | storageVers = append(storageVers, version) 326 | } else { 327 | break 328 | } 329 | } 330 | } else { 331 | storageVers = ext.versions 332 | } 333 | 334 | versions := []ExtVersion{} 335 | for _, storageVer := range storageVers { 336 | ctx := slog.With(ctx, slog.F("version", storageVer)) 337 | manifest, err := db.Storage.Manifest(ctx, ext.Publisher.PublisherName, ext.Name, storageVer) 338 | if err != nil && errors.Is(err, context.Canceled) { 339 | return nil, err 340 | } else if err != nil { 341 | db.Logger.Error(ctx, "Unable to read version manifest", slog.Error(err)) 342 | continue 343 | } 344 | 345 | version := ExtVersion{ 346 | Version: storageVer, 347 | // LastUpdated: time.Now(), // TODO: Use modified time? 348 | } 349 | 350 | if flags&IncludeFiles != 0 { 351 | fileBase := (&url.URL{ 352 | Scheme: baseURL.Scheme, 353 | Host: baseURL.Host, 354 | Path: path.Join( 355 | baseURL.Path, 356 | "/files", 357 | ext.Publisher.PublisherName, 358 | ext.Name, 359 | version.String()), 360 | }).String() 361 | for _, asset := range manifest.Assets.Asset { 362 | if asset.Addressable != "true" { 363 | continue 364 | } 365 | version.Files = append(version.Files, ExtFile{ 366 | Type: asset.Type, 367 | Source: fileBase + "/" + asset.Path, 368 | }) 369 | } 370 | } 371 | 372 | if flags&IncludeVersionProperties != 0 { 373 | version.Properties = []ExtProperty{} 374 | for _, prop := range manifest.Metadata.Properties.Property { 375 | version.Properties = append(version.Properties, ExtProperty{ 376 | Key: prop.ID, 377 | Value: prop.Value, 378 | }) 379 | } 380 | } 381 | 382 | if flags&IncludeAssetURI != 0 { 383 | version.AssetURI = (&url.URL{ 384 | Scheme: baseURL.Scheme, 385 | Host: baseURL.Host, 386 | Path: path.Join( 387 | baseURL.Path, 388 | "assets", 389 | ext.Publisher.PublisherName, 390 | ext.Name, 391 | version.String()), 392 | }).String() 393 | version.FallbackAssetURI = version.AssetURI 394 | } 395 | 396 | versions = append(versions, version) 397 | } 398 | return versions, nil 399 | } 400 | 401 | // noDBExtension adds some properties for internally filtering. 402 | type noDBExtension struct { 403 | Extension 404 | // Used internally for ranking. Lower means more relevant. 405 | distances []int `json:"-"` 406 | // Used internally to avoid reading and sorting versions twice. 407 | versions []storage.Version `json:"-"` 408 | } 409 | 410 | func convertManifestToExtension(manifest *storage.VSIXManifest) *noDBExtension { 411 | return &noDBExtension{ 412 | Extension: Extension{ 413 | // Normally this is a GUID but in the absence of a database just put 414 | // together the publisher and extension name since that will be unique. 415 | ID: manifest.Metadata.Identity.Publisher + "." + manifest.Metadata.Identity.ID, 416 | // The ID in the manifest is actually the extension name (for example 417 | // `python`) which vscode-vsce pulls from the package.json's `name`. 418 | Name: manifest.Metadata.Identity.ID, 419 | DisplayName: manifest.Metadata.DisplayName, 420 | ShortDescription: manifest.Metadata.Description, 421 | Publisher: ExtPublisher{ 422 | // Normally this is a GUID but in the absence of a database just put the 423 | // publisher name since that will be unique. 424 | PublisherID: manifest.Metadata.Identity.Publisher, 425 | PublisherName: manifest.Metadata.Identity.Publisher, 426 | // There is not actually a separate display name field for publishers. 427 | DisplayName: manifest.Metadata.Identity.Publisher, 428 | }, 429 | Tags: strings.Split(manifest.Metadata.Tags, ","), 430 | // ReleaseDate: time.Now(), // TODO: Use creation time? 431 | // PublishedDate: time.Now(), // TODO: Use creation time? 432 | // LastUpdated: time.Now(), // TODO: Use modified time? 433 | Categories: strings.Split(manifest.Metadata.Categories, ","), 434 | Flags: manifest.Metadata.GalleryFlags, 435 | }, 436 | } 437 | } 438 | 439 | func containsFold(a []string, b string) bool { 440 | for _, astr := range a { 441 | if strings.EqualFold(astr, b) { 442 | return true 443 | } 444 | } 445 | return false 446 | } 447 | -------------------------------------------------------------------------------- /docker-bake.hcl: -------------------------------------------------------------------------------- 1 | variable "VERSION" {} 2 | 3 | group "default" { 4 | targets = ["code-marketplace"] 5 | } 6 | 7 | target "code-marketplace" { 8 | dockerfile = "./Dockerfile" 9 | tags = [ 10 | "ghcr.io/coder/code-marketplace:${VERSION}", 11 | ] 12 | platforms = ["linux/amd64", "linux/arm64"] 13 | } 14 | -------------------------------------------------------------------------------- /extensionsign/doc.go: -------------------------------------------------------------------------------- 1 | // Package extensionsign provides utilities for working with extension signatures. 2 | package extensionsign 3 | -------------------------------------------------------------------------------- /extensionsign/sigmanifest.go: -------------------------------------------------------------------------------- 1 | package extensionsign 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "io" 10 | 11 | "golang.org/x/xerrors" 12 | 13 | "github.com/coder/code-marketplace/storage/easyzip" 14 | ) 15 | 16 | // SignatureManifest should be serialized to JSON before being signed. 17 | type SignatureManifest struct { 18 | Package File 19 | // Entries is base64(filepath) -> File 20 | Entries map[string]File 21 | } 22 | 23 | func (a SignatureManifest) String() string { 24 | return fmt.Sprintf("Package %q with Entries: %d", a.Package.Digests.SHA256, len(a.Entries)) 25 | } 26 | 27 | // Equal is helpful for debugging to know if two manifests are equal. 28 | // They can change if any file is removed/added/edited to an extension. 29 | func (a SignatureManifest) Equal(b SignatureManifest) error { 30 | var errs []error 31 | if err := a.Package.Equal(b.Package); err != nil { 32 | errs = append(errs, xerrors.Errorf("package: %w", err)) 33 | } 34 | 35 | if len(a.Entries) != len(b.Entries) { 36 | errs = append(errs, xerrors.Errorf("entry count mismatch: %d != %d", len(a.Entries), len(b.Entries))) 37 | } 38 | 39 | for k, v := range a.Entries { 40 | if _, ok := b.Entries[k]; !ok { 41 | errs = append(errs, xerrors.Errorf("entry %q not found in second set", k)) 42 | continue 43 | } 44 | if err := v.Equal(b.Entries[k]); err != nil { 45 | errs = append(errs, xerrors.Errorf("entry %q: %w", k, err)) 46 | } 47 | } 48 | return errors.Join(errs...) 49 | } 50 | 51 | type File struct { 52 | Size int64 `json:"size"` 53 | Digests Digests `json:"digests"` 54 | } 55 | 56 | func (f File) Equal(b File) error { 57 | if f.Size != b.Size { 58 | return xerrors.Errorf("size mismatch: %d != %d", f.Size, b.Size) 59 | } 60 | if f.Digests.SHA256 != b.Digests.SHA256 { 61 | return xerrors.Errorf("sha256 mismatch: %s != %s", f.Digests.SHA256, b.Digests.SHA256) 62 | } 63 | return nil 64 | } 65 | 66 | func FileManifest(file io.Reader) (File, error) { 67 | hash := sha256.New() 68 | 69 | n, err := io.Copy(hash, file) 70 | if err != nil { 71 | return File{}, xerrors.Errorf("hash file: %w", err) 72 | } 73 | 74 | return File{ 75 | Size: n, 76 | Digests: Digests{ 77 | SHA256: base64.StdEncoding.EncodeToString(hash.Sum(nil)), 78 | }, 79 | }, nil 80 | } 81 | 82 | type Digests struct { 83 | SHA256 string `json:"sha256"` 84 | } 85 | 86 | // GenerateSignatureManifest generates a signature manifest for a VSIX file. 87 | // It does not sign the manifest. The manifest is the base64 encoded file path 88 | // followed by the sha256 hash of the file, and it's size. 89 | func GenerateSignatureManifest(vsixFile []byte) (SignatureManifest, error) { 90 | pkgManifest, err := FileManifest(bytes.NewReader(vsixFile)) 91 | if err != nil { 92 | return SignatureManifest{}, xerrors.Errorf("package manifest: %w", err) 93 | } 94 | 95 | manifest := SignatureManifest{ 96 | Package: pkgManifest, 97 | Entries: make(map[string]File), 98 | } 99 | 100 | err = easyzip.ExtractZip(vsixFile, func(name string, reader io.Reader) error { 101 | fm, err := FileManifest(reader) 102 | if err != nil { 103 | return xerrors.Errorf("file %q: %w", name, err) 104 | } 105 | manifest.Entries[base64.StdEncoding.EncodeToString([]byte(name))] = fm 106 | return nil 107 | }) 108 | 109 | if err != nil { 110 | return SignatureManifest{}, err 111 | } 112 | 113 | return manifest, nil 114 | } 115 | -------------------------------------------------------------------------------- /extensionsign/sigzip.go: -------------------------------------------------------------------------------- 1 | package extensionsign 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "encoding/json" 7 | 8 | "golang.org/x/xerrors" 9 | 10 | "github.com/coder/code-marketplace/storage/easyzip" 11 | ) 12 | 13 | func ExtractSignatureManifest(zip []byte) (SignatureManifest, error) { 14 | r, err := easyzip.GetZipFileReader(zip, ".signature.manifest") 15 | if err != nil { 16 | return SignatureManifest{}, xerrors.Errorf("get manifest: %w", err) 17 | } 18 | 19 | defer r.Close() 20 | var manifest SignatureManifest 21 | err = json.NewDecoder(r).Decode(&manifest) 22 | if err != nil { 23 | return SignatureManifest{}, xerrors.Errorf("decode manifest: %w", err) 24 | } 25 | return manifest, nil 26 | } 27 | 28 | func IncludeEmptySignature() ([]byte, error) { 29 | var buf bytes.Buffer 30 | w := zip.NewWriter(&buf) 31 | 32 | manFile, err := w.Create(".signature.manifest") 33 | if err != nil { 34 | return nil, xerrors.Errorf("create manifest: %w", err) 35 | } 36 | 37 | _, err = manFile.Write([]byte{}) 38 | if err != nil { 39 | return nil, xerrors.Errorf("write manifest: %w", err) 40 | } 41 | 42 | // Empty file 43 | _, err = w.Create(".signature.p7s") 44 | if err != nil { 45 | return nil, xerrors.Errorf("create empty p7s signature: %w", err) 46 | } 47 | 48 | err = w.Close() 49 | if err != nil { 50 | return nil, xerrors.Errorf("close zip: %w", err) 51 | } 52 | 53 | return buf.Bytes(), nil 54 | } 55 | -------------------------------------------------------------------------------- /fixtures/generate.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Generate a ./extensions directory with names based on text files containing 3 | # randomly generated words. 4 | 5 | set -Eeuo pipefail 6 | 7 | dir=$(dirname "$0") 8 | 9 | # Pretty arbitrary but try to ensure we have a variety of extension kinds. 10 | kinds=("workspace", "ui,web", "workspace,web", "ui") 11 | 12 | while read -r publisher ; do 13 | i=0 14 | while read -r name ; do 15 | kind=${kinds[$i]-workspace} 16 | ((++i)) 17 | while read -r version ; do 18 | dest="./extensions/$publisher/$name/$version" 19 | mkdir -p "$dest/extension/images" 20 | cat< "$dest/extension.vsixmanifest" 21 | 22 | 23 | 24 | 25 | $name 26 | Mock extension for Visual Studio Code 27 | $name,mock,tag1 28 | category1,category2 29 | Public 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | extension/LICENSE.txt 50 | extension/images/icon.png 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | EOF 65 | cat< "$dest/extension/package.json" 66 | { 67 | "name": "$name", 68 | "displayName": "$name", 69 | "description": "Mock extension for Visual Studio Code", 70 | "version": "$version", 71 | "publisher": "$publisher", 72 | "author": { 73 | "name": "Coder" 74 | }, 75 | "license": "MIT", 76 | "homepage": "https://github.com/coder/code-marketplace", 77 | "repository": { 78 | "type": "git", 79 | "url": "https://github.com/coder/code-marketplace" 80 | }, 81 | "bugs": { 82 | "url": "https://github.com/coder/code-marketplace/issues" 83 | }, 84 | "qna": "https://github.com/coder/code-marketplace", 85 | "icon": "icon.png", 86 | "engines": { 87 | "vscode": "^1.57.0" 88 | }, 89 | "keywords": [ 90 | "$name", 91 | "mock", 92 | "coder", 93 | ], 94 | "categories": [ 95 | "Category1", 96 | "Category2" 97 | ], 98 | "activationEvents": [ 99 | "onStartupFinished" 100 | ], 101 | "main": "./extension", 102 | "browser": "./extension.browser.js" 103 | } 104 | EOF 105 | cat< "$dest/extension/extension.js" 106 | const vscode = require("vscode"); 107 | function activate(context) { 108 | vscode.window.showInformationMessage("mock extension $publisher.$name@$version loaded"); 109 | } 110 | exports.activate = activate; 111 | EOF 112 | cp "$dest/extension/extension.js" "$dest/extension/extension.browser.js" 113 | cat< "$dest/extension/LICENSE.txt" 114 | mock license 115 | EOF 116 | cat< "$dest/extension/README.md" 117 | mock readme 118 | EOF 119 | cat< "$dest/extension/CHANGELOG.md" 120 | mock changelog 121 | EOF 122 | cp "$dir/icon.png" "$dest/extension/images/icon.png" 123 | pushd "$dest" >/dev/null 124 | rm -f "$publisher.$name-$version.vsix" 125 | zip -r "$publisher.$name-$version.vsix" * -q 126 | popd >/dev/null 127 | done < "$dir/versions" 128 | done < "$dir/names" 129 | done < "$dir/publishers" 130 | -------------------------------------------------------------------------------- /fixtures/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/code-marketplace/785ded36ec4dda5fd060fdd8c301ffe4bdb79f8b/fixtures/icon.png -------------------------------------------------------------------------------- /fixtures/names: -------------------------------------------------------------------------------- 1 | needless 2 | lace 3 | healthy 4 | plants 5 | warlike 6 | -------------------------------------------------------------------------------- /fixtures/publishers: -------------------------------------------------------------------------------- 1 | brake 2 | frightened 3 | cooing 4 | lyrical 5 | zip 6 | introduce 7 | rail 8 | inexpensive 9 | spurious 10 | dear 11 | gruesome 12 | beginner 13 | shaggy 14 | minute 15 | group 16 | examine 17 | wipe 18 | guiltless 19 | remove 20 | pathetic 21 | structure 22 | spectacular 23 | house 24 | juice 25 | mate 26 | motionless 27 | distinct 28 | resonant 29 | wheel 30 | scene 31 | odd 32 | base 33 | whistle 34 | adhesive 35 | voracious 36 | hope 37 | unnatural 38 | brother 39 | groan 40 | false 41 | puzzling 42 | shop 43 | foamy 44 | better 45 | rest 46 | precious 47 | parallel 48 | stomach 49 | lacking 50 | load 51 | -------------------------------------------------------------------------------- /fixtures/upload.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Upload the extensions directory to Artifactory. 3 | 4 | set -Eeuo pipefail 5 | 6 | cd ./extensions 7 | find . -type f -exec curl -H "Authorization: Bearer $ARTIFACTORY_TOKEN" -T '{}' "$ARTIFACTORY_URI/$ARTIFACTORY_REPO/"'{}' \; 8 | -------------------------------------------------------------------------------- /fixtures/versions: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | 1.1.1 3 | 1.3.5 4 | 2.1.0 5 | 3.0.0 6 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1744932701, 24 | "narHash": "sha256-fusHbZCyv126cyArUwwKrLdCkgVAIaa/fQJYFlCEqiU=", 25 | "path": "/nix/store/isfbldda5j8j6x3nbv1zim0c0dpf90v8-source", 26 | "rev": "b024ced1aac25639f8ca8fdfc2f8c4fbd66c48ef", 27 | "type": "path" 28 | }, 29 | "original": { 30 | "id": "nixpkgs", 31 | "type": "indirect" 32 | } 33 | }, 34 | "root": { 35 | "inputs": { 36 | "flake-utils": "flake-utils", 37 | "nixpkgs": "nixpkgs" 38 | } 39 | }, 40 | "systems": { 41 | "locked": { 42 | "lastModified": 1681028828, 43 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 44 | "owner": "nix-systems", 45 | "repo": "default", 46 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "nix-systems", 51 | "repo": "default", 52 | "type": "github" 53 | } 54 | } 55 | }, 56 | "root": "root", 57 | "version": 7 58 | } 59 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Code extension marketplace"; 3 | 4 | inputs.flake-utils.url = "github:numtide/flake-utils"; 5 | 6 | outputs = { self, nixpkgs, flake-utils }: 7 | flake-utils.lib.eachDefaultSystem 8 | (system: 9 | let pkgs = nixpkgs.legacyPackages.${system}; 10 | in { 11 | devShells.default = pkgs.mkShell { 12 | buildInputs = with pkgs; [ 13 | go_1_24 14 | golangci-lint 15 | gotestsum 16 | kubernetes-helm 17 | ]; 18 | }; 19 | } 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/coder/code-marketplace 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | cdr.dev/slog v1.6.1 7 | github.com/go-chi/chi/v5 v5.2.1 8 | github.com/go-chi/cors v1.2.1 9 | github.com/go-chi/httprate v0.15.0 10 | github.com/google/uuid v1.6.0 11 | github.com/lithammer/fuzzysearch v1.1.8 12 | github.com/spf13/cobra v1.9.1 13 | github.com/stretchr/testify v1.10.0 14 | golang.org/x/mod v0.24.0 15 | golang.org/x/sync v0.14.0 16 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 17 | ) 18 | 19 | require ( 20 | cloud.google.com/go/logging v1.8.1 // indirect 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 22 | github.com/charmbracelet/lipgloss v0.7.1 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 25 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 26 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 27 | github.com/mattn/go-isatty v0.0.19 // indirect 28 | github.com/mattn/go-runewidth v0.0.15 // indirect 29 | github.com/muesli/reflow v0.3.0 // indirect 30 | github.com/muesli/termenv v0.15.2 // indirect 31 | github.com/pmezard/go-difflib v1.0.0 // indirect 32 | github.com/rivo/uniseg v0.4.4 // indirect 33 | github.com/spf13/pflag v1.0.6 // indirect 34 | github.com/zeebo/xxh3 v1.0.2 // indirect 35 | go.opentelemetry.io/otel v1.16.0 // indirect 36 | go.opentelemetry.io/otel/trace v1.16.0 // indirect 37 | golang.org/x/crypto v0.35.0 // indirect 38 | golang.org/x/sys v0.30.0 // indirect 39 | golang.org/x/term v0.29.0 // indirect 40 | golang.org/x/text v0.22.0 // indirect 41 | google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect 42 | google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect 43 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect 44 | google.golang.org/protobuf v1.33.0 // indirect 45 | gopkg.in/yaml.v3 v3.0.1 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cdr.dev/slog v1.6.1 h1:IQjWZD0x6//sfv5n+qEhbu3wBkmtBQY5DILXNvMaIv4= 2 | cdr.dev/slog v1.6.1/go.mod h1:eHEYQLaZvxnIAXC+XdTSNLb/kgA/X2RVSF72v5wsxEI= 3 | cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y= 4 | cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= 5 | cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= 6 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= 7 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 8 | cloud.google.com/go/logging v1.8.1 h1:26skQWPeYhvIasWKm48+Eq7oUqdcdbwsCVwz5Ys0FvU= 9 | cloud.google.com/go/logging v1.8.1/go.mod h1:TJjR+SimHwuC8MZ9cjByQulAMgni+RkXeI3wwctHJEI= 10 | cloud.google.com/go/longrunning v0.5.4 h1:w8xEcbZodnA2BbW6sVirkkoC+1gP8wS57EUUgGS0GVg= 11 | cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI= 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 14 | github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= 15 | github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 16 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 20 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 21 | github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= 22 | github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 23 | github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= 24 | github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= 25 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 26 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 27 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 28 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 29 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 30 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 31 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 32 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 33 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 34 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 35 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 36 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 37 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 38 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 39 | github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= 40 | github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= 41 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 42 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 43 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 44 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 45 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 46 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 47 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 48 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 49 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 50 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 51 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 52 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 53 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 54 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 55 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 56 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 57 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 58 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 59 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 60 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 61 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 62 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 63 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 64 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 65 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 66 | github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= 67 | github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 68 | github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= 69 | github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 70 | go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= 71 | go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= 72 | go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo= 73 | go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= 74 | go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE= 75 | go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4= 76 | go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= 77 | go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= 78 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 79 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 80 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 81 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 82 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 83 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 84 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 85 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 86 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 87 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 88 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 89 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 90 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 91 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 92 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 93 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 94 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 95 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 96 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 97 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 98 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 99 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 102 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 103 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 104 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 105 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 106 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 107 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 108 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 109 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 110 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 111 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 112 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 113 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 114 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 115 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 116 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 117 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 118 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 119 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 120 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 121 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 122 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 123 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= 124 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= 125 | google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= 126 | google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY= 127 | google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo= 128 | google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= 129 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= 130 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= 131 | google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= 132 | google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= 133 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 134 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 135 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 136 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 137 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 138 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 139 | -------------------------------------------------------------------------------- /helm/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: code-marketplace 3 | description: Open source extension marketplace for VS Code. 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 1.3.1 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "v2.3.1" 25 | -------------------------------------------------------------------------------- /helm/README.md: -------------------------------------------------------------------------------- 1 | # Code Extension Marketplace Helm Chart 2 | 3 | This directory contains the Helm chart used to deploy the marketplace onto a 4 | Kubernetes cluster. 5 | 6 | ## Quickstart 7 | 8 | ```console 9 | $ git clone --depth 1 https://github.com/coder/code-marketplace 10 | $ helm upgrade --install code-marketplace ./code-marketplace/helm 11 | ``` 12 | 13 | This deploys the marketplace on the default Kubernetes cluster. 14 | 15 | ## Ingress 16 | 17 | You will need to configure `ingress` in [values.yaml](./values.yaml) to expose the 18 | marketplace on an external domain or change `service.type` to get yourself an 19 | external IP address. 20 | 21 | The marketplace must be put behind TLS otherwise code-server will reject 22 | connecting to the API. This could mean configuring `ingress` with TLS or putting 23 | the external IP behind a TLS-terminating reverse proxy. 24 | 25 | More information can be found at these links: 26 | 27 | - https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types 28 | - https://kubernetes.io/docs/concepts/services-networking/ingress/ 29 | 30 | When hosting the marketplace behind a reverse proxy set either the `Forwarded` 31 | header or both the `X-Forwarded-Host` and `X-Forwarded-Proto` headers (the 32 | default `ingress` already takes care of this). These headers are used to 33 | generate absolute URIs to extension assets in API responses. One way to test 34 | this is to make a query and check one of the URIs in the response: 35 | 36 | ```console 37 | $ curl 'https://example.com/api/extensionquery' -H 'Accept: application/json;api-version=3.0-preview.1' --compressed -H 'Content-Type: application/json' --data-raw '{"filters":[{"criteria":[{"filterType":8,"value":"Microsoft.VisualStudio.Code"}],"pageSize":1}],"flags":439}' | jq .results[0].extensions[0].versions[0].assetUri 38 | "https://example.com/assets/vscodevim/vim/1.24.1" 39 | ``` 40 | 41 | The marketplace does not support being hosted behind a base path; it must be 42 | proxied at the root of your domain. 43 | 44 | ## Adding/removing extensions 45 | 46 | One way to get extensions added or removed is to exec into the pod and use the 47 | marketplace binary to add and remove them. 48 | 49 | ```console 50 | export POD_NAME=$(kubectl get pods -l "app.kubernetes.io/name=code-marketplace,app.kubernetes.io/instance=code-marketplace" -o jsonpath="{.items[0].metadata.name}") 51 | $ kubectl exec -it "$POD_NAME" -- /opt/code-marketplace add https://github.com/VSCodeVim/Vim/releases/download/v1.24.1/vim-1.24.1.vsix --extensions-dir /extensions 52 | ``` 53 | 54 | In the future it will be possible to use Artifactory for storing and retrieving 55 | extensions instead of a persistent volume. 56 | 57 | ## Uninstall 58 | 59 | To uninstall/delete the marketplace deployment: 60 | 61 | ```console 62 | $ helm delete code-marketplace 63 | ``` 64 | 65 | This removes all the Kubernetes components associated with the chart (including 66 | the persistent volume) and deletes the release. 67 | 68 | ## Configuration 69 | 70 | Please refer to [values.yaml](./values.yaml) for available Helm values and their 71 | defaults. 72 | 73 | Specify values using `--set`: 74 | 75 | ```console 76 | $ helm upgrade --install code-marketplace ./helm-chart \ 77 | --set persistence.size=10Gi 78 | ``` 79 | 80 | Or edit and use the YAML file: 81 | 82 | ```console 83 | $ helm upgrade --install code-marketplace ./helm-chart -f values.yaml 84 | ``` 85 | -------------------------------------------------------------------------------- /helm/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "code-marketplace.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "code-marketplace.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "code-marketplace.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "code-marketplace.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /helm/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "code-marketplace.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "code-marketplace.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "code-marketplace.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "code-marketplace.labels" -}} 37 | helm.sh/chart: {{ include "code-marketplace.chart" . }} 38 | {{ include "code-marketplace.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "code-marketplace.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "code-marketplace.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "code-marketplace.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "code-marketplace.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /helm/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "code-marketplace.fullname" . }} 5 | labels: 6 | {{- include "code-marketplace.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "code-marketplace.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "code-marketplace.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "code-marketplace.serviceAccountName" . }} 28 | {{- if or (.Values.volumes) (not .Values.persistence.artifactory.enabled) }} 29 | volumes: 30 | {{- if not .Values.persistence.artifactory.enabled }} 31 | - name: extensions 32 | persistentVolumeClaim: 33 | claimName: {{ include "code-marketplace.fullname" . }} 34 | {{- end }} 35 | {{- with .Values.volumes }} 36 | {{- toYaml . | nindent 8 }} 37 | {{- end }} 38 | {{- end }} 39 | securityContext: 40 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 41 | containers: 42 | - name: {{ .Chart.Name }} 43 | securityContext: 44 | {{- toYaml .Values.securityContext | nindent 12 }} 45 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 46 | imagePullPolicy: {{ .Values.image.pullPolicy }} 47 | env: 48 | {{- if .Values.persistence.artifactory.enabled }} 49 | - name: "ARTIFACTORY_TOKEN" 50 | valueFrom: 51 | secretKeyRef: 52 | name: artifactory 53 | key: token 54 | {{- end }} 55 | {{- with .Values.extraEnv }} 56 | {{- toYaml . | nindent 12 }} 57 | {{- end }} 58 | ports: 59 | - name: http 60 | containerPort: {{ .Values.service.port }} 61 | protocol: TCP 62 | args: 63 | - --address 64 | - "0.0.0.0:{{ .Values.service.port }}" 65 | {{- if .Values.persistence.artifactory.enabled }} 66 | - --artifactory 67 | - {{ .Values.persistence.artifactory.uri }} 68 | - --repo 69 | - {{ .Values.persistence.artifactory.repo }} 70 | {{- else }} 71 | - --extensions-dir 72 | - /extensions 73 | {{- end }} 74 | {{- if or (.Values.volumeMounts) (not .Values.persistence.artifactory.enabled) }} 75 | volumeMounts: 76 | {{- if not .Values.persistence.artifactory.enabled }} 77 | - name: extensions 78 | mountPath: /extensions 79 | {{- end }} 80 | {{- with .Values.volumeMounts }} 81 | {{- toYaml . | nindent 12 }} 82 | {{- end }} 83 | {{- end }} 84 | livenessProbe: 85 | httpGet: 86 | path: /healthz 87 | port: http 88 | readinessProbe: 89 | httpGet: 90 | path: /healthz 91 | port: http 92 | resources: 93 | {{- toYaml .Values.resources | nindent 12 }} 94 | {{- with .Values.nodeSelector }} 95 | nodeSelector: 96 | {{- toYaml . | nindent 8 }} 97 | {{- end }} 98 | {{- with .Values.affinity }} 99 | affinity: 100 | {{- toYaml . | nindent 8 }} 101 | {{- end }} 102 | {{- with .Values.tolerations }} 103 | tolerations: 104 | {{- toYaml . | nindent 8 }} 105 | {{- end }} 106 | -------------------------------------------------------------------------------- /helm/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "code-marketplace.fullname" . }} 6 | labels: 7 | {{- include "code-marketplace.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "code-marketplace.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | {{- end }} 22 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 23 | - type: Resource 24 | resource: 25 | name: memory 26 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /helm/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "code-marketplace.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "code-marketplace.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /helm/templates/pvc.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.persistence.artifactory.enabled }} 2 | apiVersion: v1 3 | kind: PersistentVolumeClaim 4 | metadata: 5 | name: {{ include "code-marketplace.fullname" . }} 6 | spec: 7 | accessModes: 8 | - ReadWriteOnce 9 | resources: 10 | requests: 11 | storage: {{ .Values.persistence.size | quote }} 12 | storageClassName: {{ .Values.persistence.storageClass | quote }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /helm/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "code-marketplace.fullname" . }} 5 | labels: 6 | {{- include "code-marketplace.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "code-marketplace.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /helm/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "code-marketplace.serviceAccountName" . }} 6 | labels: 7 | {{- include "code-marketplace.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "code-marketplace.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "code-marketplace.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "code-marketplace.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /helm/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for code-marketplace. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: "ghcr.io/coder/code-marketplace" 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "" 12 | 13 | extraEnv: [] 14 | 15 | imagePullSecrets: [] 16 | nameOverride: "" 17 | fullnameOverride: "" 18 | 19 | serviceAccount: 20 | # Specifies whether a service account should be created 21 | create: true 22 | # Annotations to add to the service account 23 | annotations: {} 24 | # The name of the service account to use. 25 | # If not set and create is true, a name is generated using the fullname template 26 | name: "" 27 | 28 | podAnnotations: {} 29 | 30 | podSecurityContext: {} 31 | # fsGroup: 2000 32 | 33 | securityContext: {} 34 | # capabilities: 35 | # drop: 36 | # - ALL 37 | # readOnlyRootFilesystem: true 38 | # runAsNonRoot: true 39 | # runAsUser: 1000 40 | 41 | service: 42 | # https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types 43 | type: ClusterIP 44 | port: 80 45 | 46 | # https://kubernetes.io/docs/concepts/services-networking/ingress/ 47 | ingress: 48 | enabled: false 49 | className: "" 50 | annotations: {} 51 | # kubernetes.io/ingress.class: nginx 52 | # kubernetes.io/tls-acme: "true" 53 | hosts: 54 | - host: chart-example.local 55 | paths: 56 | - path: / 57 | pathType: Prefix 58 | tls: [] 59 | # - secretName: chart-example-tls 60 | # hosts: 61 | # - chart-example.local 62 | 63 | resources: {} 64 | # We usually recommend not to specify default resources and to leave this as a conscious 65 | # choice for the user. This also increases chances charts run on environments with little 66 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 67 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 68 | # limits: 69 | # cpu: 100m 70 | # memory: 128Mi 71 | # requests: 72 | # cpu: 100m 73 | # memory: 128Mi 74 | 75 | autoscaling: 76 | enabled: false 77 | minReplicas: 1 78 | maxReplicas: 100 79 | targetCPUUtilizationPercentage: 80 80 | # targetMemoryUtilizationPercentage: 80 81 | 82 | # Additional volumes on the output Deployment definition. 83 | volumes: [] 84 | # - name: foo 85 | # secret: 86 | # secretName: mysecret 87 | # optional: false 88 | 89 | # Additional volumeMounts on the output Deployment definition. 90 | volumeMounts: [] 91 | # - name: foo 92 | # mountPath: "/etc/foo" 93 | # readOnly: true 94 | 95 | nodeSelector: {} 96 | 97 | tolerations: [] 98 | 99 | affinity: {} 100 | 101 | persistence: 102 | storageClass: standard 103 | artifactory: 104 | # Use Artifactory for extensions instead of a persistent volume. Make sure 105 | # to create an `artifactory` secret with a `token` key. 106 | enabled: false 107 | uri: https://artifactory.server/artifactory 108 | repo: extensions 109 | # Size is ignored when using Artifactory. 110 | size: 100Gi 111 | -------------------------------------------------------------------------------- /storage/artifactory_test.go: -------------------------------------------------------------------------------- 1 | package storage_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/http/httptest" 11 | "os" 12 | "path" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | "syscall" 17 | "testing" 18 | 19 | "github.com/stretchr/testify/require" 20 | 21 | "cdr.dev/slog" 22 | "cdr.dev/slog/sloggers/slogtest" 23 | "github.com/coder/code-marketplace/api/httpapi" 24 | "github.com/coder/code-marketplace/storage" 25 | ) 26 | 27 | const ArtifactoryURIEnvKey = "ARTIFACTORY_URI" 28 | const ArtifactoryRepoEnvKey = "ARTIFACTORY_REPO" 29 | 30 | func readFiles(depth int, root, current string) ([]storage.ArtifactoryFile, error) { 31 | files, err := os.ReadDir(filepath.FromSlash(path.Join(root, current))) 32 | if err != nil { 33 | return nil, err 34 | } 35 | var artifactoryFiles []storage.ArtifactoryFile 36 | for _, file := range files { 37 | current := path.Join(current, file.Name()) 38 | artifactoryFiles = append(artifactoryFiles, storage.ArtifactoryFile{ 39 | URI: current, 40 | Folder: file.IsDir(), 41 | }) 42 | if depth > 1 { 43 | files, err := readFiles(depth-1, root, current) 44 | if err != nil { 45 | return nil, err 46 | } 47 | artifactoryFiles = append(artifactoryFiles, files...) 48 | } 49 | } 50 | return artifactoryFiles, nil 51 | } 52 | 53 | func handleArtifactory(extdir, repo string, rw http.ResponseWriter, r *http.Request) error { 54 | if r.URL.Query().Has("list") { 55 | depth := 1 56 | if r.URL.Query().Has("depth") { 57 | var err error 58 | depth, err = strconv.Atoi(r.URL.Query().Get("depth")) 59 | if err != nil { 60 | return err 61 | } 62 | } 63 | files, err := readFiles(depth, filepath.Join(extdir, strings.TrimPrefix(r.URL.Path, "/api/storage")), "/") 64 | if err != nil { 65 | return err 66 | } 67 | httpapi.Write(rw, http.StatusOK, &storage.ArtifactoryList{Files: files}) 68 | } else if r.Method == http.MethodDelete { 69 | filename := filepath.Join(extdir, filepath.FromSlash(r.URL.Path)) 70 | _, err := os.Stat(filename) 71 | if err != nil { 72 | return err 73 | } 74 | err = os.RemoveAll(filename) 75 | if err != nil { 76 | return err 77 | } 78 | _, err = rw.Write([]byte("ok")) 79 | if err != nil { 80 | return err 81 | } 82 | } else if r.Method == http.MethodPut { 83 | b, err := io.ReadAll(r.Body) 84 | if err != nil { 85 | return err 86 | } 87 | filename := filepath.FromSlash(r.URL.Path) 88 | err = os.MkdirAll(filepath.Dir(filepath.Join(extdir, filename)), 0o755) 89 | if err != nil { 90 | return err 91 | } 92 | err = os.WriteFile(filepath.Join(extdir, filename), b, 0o644) 93 | if err != nil { 94 | return err 95 | } 96 | _, err = rw.Write([]byte("ok")) 97 | if err != nil { 98 | return err 99 | } 100 | } else if r.Method == http.MethodGet { 101 | filename := filepath.Join(extdir, filepath.FromSlash(r.URL.Path)) 102 | stat, err := os.Stat(filename) 103 | if err != nil { 104 | return err 105 | } 106 | if stat.IsDir() { 107 | // This is not the right response but we only use it in `exists` below to 108 | // check if a folder exists so it is good enough. 109 | httpapi.Write(rw, http.StatusOK, &storage.ArtifactoryList{}) 110 | return nil 111 | } 112 | b, err := os.ReadFile(filename) 113 | if err != nil { 114 | return err 115 | } 116 | _, err = rw.Write(b) 117 | if err != nil { 118 | return err 119 | } 120 | } else { 121 | http.Error(rw, "not implemented", http.StatusNotImplemented) 122 | } 123 | return nil 124 | } 125 | 126 | func artifactoryFactory(t *testing.T) testStorage { 127 | logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) 128 | token := os.Getenv(storage.ArtifactoryTokenEnvKey) 129 | repo := os.Getenv(ArtifactoryRepoEnvKey) 130 | uri := os.Getenv(ArtifactoryURIEnvKey) 131 | if uri == "" { 132 | // If no URL was specified use a mock. 133 | extdir := t.TempDir() 134 | server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 135 | err := handleArtifactory(extdir, repo, rw, r) 136 | if err != nil { 137 | code := http.StatusInternalServerError 138 | message := err.Error() 139 | if errors.Is(err, os.ErrNotExist) { 140 | code = http.StatusNotFound 141 | } else if errors.Is(err, syscall.EISDIR) { 142 | code = http.StatusConflict 143 | message = "Expected a file but found a folder" 144 | } 145 | httpapi.Write(rw, code, &storage.ArtifactoryResponse{ 146 | Errors: []storage.ArtifactoryError{{ 147 | Status: code, 148 | Message: message, 149 | }}, 150 | }) 151 | } 152 | })) 153 | uri = server.URL 154 | repo = "extensions" 155 | token = "mock" 156 | t.Cleanup(server.Close) 157 | } else { 158 | if token == "" { 159 | t.Fatalf("the %s environment variable must be set", storage.ArtifactoryTokenEnvKey) 160 | } 161 | if repo == "" { 162 | t.Fatalf("the %s environment variable must be set", ArtifactoryRepoEnvKey) 163 | } 164 | } 165 | // Since we only have one repo use sub-directories to prevent clashes. 166 | repo = path.Join(repo, t.Name()) 167 | s, err := storage.NewArtifactoryStorage(context.Background(), &storage.ArtifactoryOptions{ 168 | Logger: logger, 169 | Repo: repo, 170 | Token: token, 171 | URI: uri, 172 | }) 173 | require.NoError(t, err) 174 | t.Cleanup(func() { 175 | req, err := http.NewRequest(http.MethodDelete, uri+repo, nil) 176 | if err != nil { 177 | t.Log("Failed to clean up", err) 178 | return 179 | } 180 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 181 | res, err := http.DefaultClient.Do(req) 182 | if err != nil { 183 | t.Log("Failed to clean up", err) 184 | return 185 | } 186 | defer res.Body.Close() 187 | }) 188 | if !strings.HasSuffix(uri, "/") { 189 | uri = uri + "/" 190 | } 191 | return testStorage{ 192 | storage: s, 193 | write: func(content []byte, elem ...string) { 194 | req, err := http.NewRequest(http.MethodPut, uri+path.Join(repo, path.Join(elem...)), bytes.NewReader(content)) 195 | require.NoError(t, err) 196 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 197 | res, err := http.DefaultClient.Do(req) 198 | require.NoError(t, err) 199 | defer res.Body.Close() 200 | }, 201 | exists: func(elem ...string) bool { 202 | req, err := http.NewRequest(http.MethodGet, uri+path.Join(repo, path.Join(elem...)), nil) 203 | require.NoError(t, err) 204 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 205 | res, err := http.DefaultClient.Do(req) 206 | if err != nil { 207 | return false 208 | } 209 | defer res.Body.Close() 210 | return res.StatusCode == http.StatusOK 211 | }, 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /storage/easyzip/zip.go: -------------------------------------------------------------------------------- 1 | package easyzip 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "io" 7 | 8 | "golang.org/x/xerrors" 9 | ) 10 | 11 | // WalkZip applies a function over every file in the zip. If the function 12 | // returns true a reader for that file will be immediately returned. If it 13 | // returns an error the error will immediately be returned. Otherwise `nil` will 14 | // be returned once the archive's end is reached. 15 | func WalkZip(rawZip []byte, fn func(*zip.File) (bool, error)) (io.ReadCloser, error) { 16 | b := bytes.NewReader(rawZip) 17 | zr, err := zip.NewReader(b, b.Size()) 18 | if err != nil { 19 | return nil, err 20 | } 21 | for _, zf := range zr.File { 22 | stop, err := fn(zf) 23 | if err != nil { 24 | return nil, err 25 | } 26 | if stop { 27 | zfr, err := zf.Open() 28 | if err != nil { 29 | return nil, err 30 | } 31 | return zfr, nil 32 | } 33 | } 34 | return nil, nil 35 | } 36 | 37 | // GetZipFileReader returns a reader for a single file in a zip. 38 | func GetZipFileReader(rawZip []byte, filename string) (io.ReadCloser, error) { 39 | reader, err := WalkZip(rawZip, func(f *zip.File) (stop bool, err error) { 40 | return f.Name == filename, nil 41 | }) 42 | if err != nil { 43 | return nil, err 44 | } 45 | if reader == nil { 46 | return nil, xerrors.Errorf("%s not found", filename) 47 | } 48 | return reader, nil 49 | } 50 | 51 | // ExtractZip applies a function with a reader for every file in the zip. If 52 | // the function returns an error the walk is aborted. 53 | func ExtractZip(rawZip []byte, fn func(name string, reader io.Reader) error) error { 54 | _, err := WalkZip(rawZip, func(zf *zip.File) (stop bool, err error) { 55 | if !zf.FileInfo().IsDir() { 56 | zr, err := zf.Open() 57 | if err != nil { 58 | return false, err 59 | } 60 | defer zr.Close() 61 | return false, fn(zf.Name, zr) 62 | } 63 | return false, nil 64 | }) 65 | 66 | return err 67 | } 68 | -------------------------------------------------------------------------------- /storage/easyzip/zip_test.go: -------------------------------------------------------------------------------- 1 | package easyzip 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "errors" 7 | "io" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | var files = []struct { 14 | Name, Body string 15 | }{ 16 | {"alpha.txt", "Alpha content."}, 17 | {"beta.txt", "Beta content."}, 18 | {"charlie.txt", "Charlie content."}, 19 | {"delta/delta.txt", "Delta content."}, 20 | } 21 | 22 | func createZip() ([]byte, error) { 23 | buf := bytes.NewBuffer(nil) 24 | zw := zip.NewWriter(buf) 25 | for _, file := range files { 26 | fw, err := zw.Create(file.Name) 27 | if err != nil { 28 | return nil, err 29 | } 30 | if _, err := fw.Write([]byte(file.Body)); err != nil { 31 | return nil, err 32 | } 33 | } 34 | if err := zw.Close(); err != nil { 35 | return nil, err 36 | } 37 | return buf.Bytes(), nil 38 | } 39 | 40 | func TestGetZipFileReader(t *testing.T) { 41 | t.Parallel() 42 | 43 | buffer, err := createZip() 44 | require.NoError(t, err) 45 | 46 | for _, file := range files { 47 | reader, err := GetZipFileReader(buffer, file.Name) 48 | require.NoError(t, err) 49 | 50 | content, err := io.ReadAll(reader) 51 | require.NoError(t, err) 52 | require.Equal(t, file.Body, string(content)) 53 | } 54 | 55 | _, err = GetZipFileReader(buffer, "delta.txt") 56 | require.Error(t, err) 57 | } 58 | 59 | func TestExtract(t *testing.T) { 60 | t.Parallel() 61 | 62 | buffer, err := createZip() 63 | require.NoError(t, err) 64 | 65 | t.Run("Error", func(t *testing.T) { 66 | err := ExtractZip(buffer, func(name string, reader io.Reader) error { 67 | return errors.New("error") 68 | }) 69 | require.Error(t, err) 70 | }) 71 | 72 | t.Run("OK", func(t *testing.T) { 73 | called := []string{} 74 | err := ExtractZip(buffer, func(name string, reader io.Reader) error { 75 | called = append(called, name) 76 | return nil 77 | }) 78 | require.NoError(t, err) 79 | require.Equal(t, []string{"alpha.txt", "beta.txt", "charlie.txt", "delta/delta.txt"}, called) 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /storage/local.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | "sync" 12 | "time" 13 | 14 | "golang.org/x/xerrors" 15 | 16 | "cdr.dev/slog" 17 | "github.com/coder/code-marketplace/storage/easyzip" 18 | ) 19 | 20 | var _ Storage = (*Local)(nil) 21 | 22 | // Local implements Storage. It stores extensions locally on disk by both 23 | // copying the VSIX and extracting said VSIX to a tree structure in the form of 24 | // publisher/extension/version to easily serve individual assets via HTTP. 25 | type Local struct { 26 | listCache []extension 27 | listDuration time.Duration 28 | listExpiration time.Time 29 | listMutex sync.Mutex 30 | extdir string 31 | logger slog.Logger 32 | } 33 | 34 | type LocalOptions struct { 35 | // How long to cache the list of extensions with their manifests. Zero means 36 | // no cache. 37 | ListCacheDuration time.Duration 38 | ExtDir string 39 | } 40 | 41 | func NewLocalStorage(options *LocalOptions, logger slog.Logger) (*Local, error) { 42 | extdir, err := filepath.Abs(options.ExtDir) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return &Local{ 47 | // TODO: Eject the cache when adding/removing extensions and/or add a 48 | // command to eject the cache? 49 | extdir: extdir, 50 | listDuration: options.ListCacheDuration, 51 | logger: logger, 52 | }, nil 53 | } 54 | 55 | func (s *Local) list(ctx context.Context) []extension { 56 | var list []extension 57 | publishers, err := s.getDirNames(ctx, s.extdir) 58 | if err != nil { 59 | s.logger.Error(ctx, "Error reading publisher", slog.Error(err)) 60 | } 61 | for _, publisher := range publishers { 62 | ctx := slog.With(ctx, slog.F("publisher", publisher)) 63 | dir := filepath.Join(s.extdir, publisher) 64 | 65 | extensions, err := s.getDirNames(ctx, dir) 66 | if err != nil { 67 | s.logger.Error(ctx, "Error reading extensions", slog.Error(err)) 68 | } 69 | for _, name := range extensions { 70 | ctx := slog.With(ctx, slog.F("extension", name)) 71 | versions, err := s.Versions(ctx, publisher, name) 72 | if err != nil { 73 | s.logger.Error(ctx, "Error reading versions", slog.Error(err)) 74 | } 75 | if len(versions) == 0 { 76 | continue 77 | } 78 | 79 | // The manifest from the latest version is used for filtering. 80 | manifest, err := s.Manifest(ctx, publisher, name, versions[0]) 81 | if err != nil { 82 | s.logger.Error(ctx, "Unable to read extension manifest", slog.Error(err)) 83 | continue 84 | } 85 | 86 | list = append(list, extension{ 87 | manifest, 88 | name, 89 | publisher, 90 | versions, 91 | }) 92 | } 93 | } 94 | return list 95 | } 96 | 97 | func (s *Local) AddExtension(ctx context.Context, manifest *VSIXManifest, vsix []byte, extra ...File) (string, error) { 98 | // Extract the zip to the correct path. 99 | identity := manifest.Metadata.Identity 100 | dir := filepath.Join(s.extdir, identity.Publisher, identity.ID, Version{ 101 | Version: identity.Version, 102 | TargetPlatform: identity.TargetPlatform, 103 | }.String()) 104 | err := easyzip.ExtractZip(vsix, func(name string, r io.Reader) error { 105 | path := filepath.Join(dir, name) 106 | err := os.MkdirAll(filepath.Dir(path), 0o755) 107 | if err != nil { 108 | return err 109 | } 110 | w, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) 111 | if err != nil { 112 | return err 113 | } 114 | defer w.Close() 115 | _, err = io.Copy(w, r) 116 | return err 117 | }) 118 | if err != nil { 119 | return "", err 120 | } 121 | 122 | // Copy the VSIX itself as well. 123 | vsixPath := filepath.Join(dir, fmt.Sprintf("%s.vsix", ExtensionVSIXNameFromManifest(manifest))) 124 | err = os.WriteFile(vsixPath, vsix, 0o644) 125 | if err != nil { 126 | return "", err 127 | } 128 | 129 | for _, file := range extra { 130 | path := filepath.Join(dir, file.RelativePath) 131 | err := os.MkdirAll(filepath.Dir(path), 0o755) 132 | if err != nil { 133 | return "", err 134 | } 135 | err = os.WriteFile(path, file.Content, 0o644) 136 | if err != nil { 137 | return dir, xerrors.Errorf("write extra file %q: %w", path, err) 138 | } 139 | } 140 | 141 | return dir, nil 142 | } 143 | 144 | func (s *Local) FileServer() http.Handler { 145 | return http.FileServer(http.Dir(s.extdir)) 146 | } 147 | 148 | func (s *Local) Manifest(ctx context.Context, publisher, name string, version Version) (*VSIXManifest, error) { 149 | reader, err := os.Open(filepath.Join(s.extdir, publisher, name, version.String(), "extension.vsixmanifest")) 150 | if err != nil { 151 | return nil, err 152 | } 153 | defer reader.Close() 154 | 155 | // If the manifest is returned with an error that means it exists but is 156 | // invalid. We will still return it as a best-effort. 157 | manifest, err := parseVSIXManifest(reader) 158 | if manifest == nil && err != nil { 159 | return nil, err 160 | } else if err != nil { 161 | s.logger.Error(ctx, "Extension has invalid manifest", slog.Error(err)) 162 | } 163 | 164 | manifest.Assets.Asset = append(manifest.Assets.Asset, VSIXAsset{ 165 | Type: VSIXAssetType, 166 | Path: fmt.Sprintf("%s.vsix", ExtensionVSIXNameFromManifest(manifest)), 167 | Addressable: "true", 168 | }) 169 | 170 | return manifest, nil 171 | } 172 | 173 | func (s *Local) RemoveExtension(ctx context.Context, publisher, name string, version Version) error { 174 | dir := filepath.Join(s.extdir, publisher, name, version.String()) 175 | // RemoveAll() will not error if the directory does not exist so check first 176 | // as this function should error when removing versions that do not exist. 177 | _, err := os.Stat(dir) 178 | if err != nil { 179 | return err 180 | } 181 | return os.RemoveAll(dir) 182 | } 183 | 184 | func (s *Local) Versions(ctx context.Context, publisher, name string) ([]Version, error) { 185 | dir := filepath.Join(s.extdir, publisher, name) 186 | versionDirs, err := s.getDirNames(ctx, dir) 187 | var versions []Version 188 | for _, versionDir := range versionDirs { 189 | versions = append(versions, VersionFromString(versionDir)) 190 | } 191 | // Return anything we did get even if there was an error. 192 | sort.Sort(ByVersion(versions)) 193 | return versions, err 194 | } 195 | 196 | func (s *Local) listWithCache(ctx context.Context) []extension { 197 | s.listMutex.Lock() 198 | defer s.listMutex.Unlock() 199 | if s.listCache == nil || time.Now().After(s.listExpiration) { 200 | s.listExpiration = time.Now().Add(s.listDuration) 201 | s.listCache = s.list(ctx) 202 | } 203 | return s.listCache 204 | } 205 | 206 | func (s *Local) WalkExtensions(ctx context.Context, fn func(manifest *VSIXManifest, versions []Version) error) error { 207 | // Walking through directories on disk and parsing manifest files takes several 208 | // minutes with many extensions installed, so if we already did that within 209 | // a specified duration, just load extensions from the cache instead. 210 | for _, extension := range s.listWithCache(ctx) { 211 | if err := fn(extension.manifest, extension.versions); err != nil { 212 | return err 213 | } 214 | } 215 | return nil 216 | } 217 | 218 | // getDirNames get the names of directories in the provided directory. If an 219 | // error is occured it will be returned along with any directories that were 220 | // able to be read. 221 | func (s *Local) getDirNames(ctx context.Context, dir string) ([]string, error) { 222 | files, err := os.ReadDir(dir) 223 | names := []string{} 224 | for _, file := range files { 225 | if file.IsDir() { 226 | names = append(names, file.Name()) 227 | } 228 | } 229 | return names, err 230 | } 231 | -------------------------------------------------------------------------------- /storage/local_test.go: -------------------------------------------------------------------------------- 1 | package storage_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "cdr.dev/slog" 11 | "cdr.dev/slog/sloggers/slogtest" 12 | "github.com/coder/code-marketplace/storage" 13 | ) 14 | 15 | func localFactory(t *testing.T) testStorage { 16 | extdir := t.TempDir() 17 | logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) 18 | s, err := storage.NewLocalStorage(&storage.LocalOptions{ExtDir: extdir}, logger) 19 | require.NoError(t, err) 20 | return testStorage{ 21 | storage: s, 22 | write: func(content []byte, elem ...string) { 23 | dest := filepath.Join(extdir, filepath.Join(elem...)) 24 | err := os.MkdirAll(filepath.Dir(dest), 0o755) 25 | require.NoError(t, err) 26 | err = os.WriteFile(dest, content, 0o644) 27 | require.NoError(t, err) 28 | }, 29 | exists: func(elem ...string) bool { 30 | _, err := os.Stat(filepath.Join(extdir, filepath.Join(elem...))) 31 | return err == nil 32 | }, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /storage/signature.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | 9 | "cdr.dev/slog" 10 | "github.com/coder/code-marketplace/api/httpapi" 11 | "github.com/coder/code-marketplace/api/httpmw" 12 | 13 | "github.com/coder/code-marketplace/extensionsign" 14 | ) 15 | 16 | var _ Storage = (*Signature)(nil) 17 | 18 | const ( 19 | SigzipFileExtension = ".signature.p7s" 20 | sigManifestName = ".signature.manifest" 21 | ) 22 | 23 | func SignatureZipFilename(manifest *VSIXManifest) string { 24 | return ExtensionVSIXNameFromManifest(manifest) + SigzipFileExtension 25 | } 26 | 27 | // Signature is a storage wrapper that can sign extensions on demand. 28 | type Signature struct { 29 | Logger slog.Logger 30 | IncludeEmptySignatures bool 31 | Storage 32 | } 33 | 34 | func NewSignatureStorage(logger slog.Logger, includeEmptySignatures bool, s Storage) *Signature { 35 | if includeEmptySignatures { 36 | logger.Info(context.Background(), "Signature storage enabled, if using VS Code on Windows or macOS, this will not work.") 37 | } 38 | return &Signature{ 39 | Logger: logger, 40 | IncludeEmptySignatures: includeEmptySignatures, 41 | Storage: s, 42 | } 43 | } 44 | 45 | func (s *Signature) SigningEnabled() bool { 46 | return s.IncludeEmptySignatures 47 | } 48 | 49 | func (s *Signature) Manifest(ctx context.Context, publisher, name string, version Version) (*VSIXManifest, error) { 50 | manifest, err := s.Storage.Manifest(ctx, publisher, name, version) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | if s.SigningEnabled() { 56 | for _, asset := range manifest.Assets.Asset { 57 | if asset.Path == SignatureZipFilename(manifest) { 58 | // Already signed 59 | return manifest, nil 60 | } 61 | } 62 | manifest.Assets.Asset = append(manifest.Assets.Asset, VSIXAsset{ 63 | Type: VSIXSignatureType, 64 | Path: SignatureZipFilename(manifest), 65 | Addressable: "true", 66 | }) 67 | return manifest, nil 68 | } 69 | return manifest, nil 70 | } 71 | 72 | // FileServer will intercept requests for signed extensions payload. 73 | // It does this by looking for 'SigzipFileExtension' or p7s.sig. 74 | // 75 | // The signed payload is completely empty. Nothing it actually signed. 76 | // 77 | // Some notes: 78 | // 79 | // - VSCodium requires a signature to exist, but it does appear to actually read 80 | // the signature. Meaning the signature could be empty, incorrect, or a 81 | // picture of cat and it would work. There is no signature verification. 82 | // 83 | // - VSCode requires a signature payload to exist, but the content is optional 84 | // for linux users. 85 | // For windows users, the signature must be valid, and this implementation 86 | // will not work. 87 | func (s *Signature) FileServer() http.Handler { 88 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 89 | if s.SigningEnabled() && strings.HasSuffix(r.URL.Path, SigzipFileExtension) { 90 | // hijack this request, return an empty signature payload 91 | signed, err := extensionsign.IncludeEmptySignature() 92 | if err != nil { 93 | httpapi.Write(rw, http.StatusInternalServerError, httpapi.ErrorResponse{ 94 | Message: "Unable to generate empty signature for extension", 95 | Detail: err.Error(), 96 | RequestID: httpmw.RequestID(r), 97 | }) 98 | return 99 | } 100 | 101 | rw.Header().Set("Content-Length", strconv.FormatInt(int64(len(signed)), 10)) 102 | rw.WriteHeader(http.StatusOK) 103 | _, _ = rw.Write(signed) 104 | return 105 | } 106 | 107 | s.Storage.FileServer().ServeHTTP(rw, r) 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /storage/signature_test.go: -------------------------------------------------------------------------------- 1 | package storage_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "cdr.dev/slog" 7 | "github.com/coder/code-marketplace/storage" 8 | ) 9 | 10 | func expectSignature(manifest *storage.VSIXManifest) { 11 | manifest.Assets.Asset = append(manifest.Assets.Asset, storage.VSIXAsset{ 12 | Type: storage.VSIXSignatureType, 13 | Path: storage.SignatureZipFilename(manifest), 14 | Addressable: "true", 15 | }) 16 | } 17 | 18 | //nolint:revive // test control flag 19 | func signed(signer bool, factory func(t *testing.T) testStorage) func(t *testing.T) testStorage { 20 | return func(t *testing.T) testStorage { 21 | st := factory(t) 22 | key := false 23 | var exp func(*storage.VSIXManifest) 24 | if signer { 25 | key = true 26 | exp = expectSignature 27 | } 28 | 29 | return testStorage{ 30 | storage: storage.NewSignatureStorage(slog.Make(), key, st.storage), 31 | write: st.write, 32 | exists: st.exists, 33 | expectedManifest: exp, 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /testutil/extensions.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "encoding/json" 7 | "encoding/xml" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/coder/code-marketplace/storage" 13 | ) 14 | 15 | type Extension struct { 16 | Publisher string 17 | Name string 18 | Tags string 19 | Files []storage.VSIXAsset 20 | Properties []storage.VSIXProperty 21 | Description string 22 | Categories string 23 | Versions []storage.Version 24 | LatestVersion string 25 | Dependencies []string 26 | Pack []string 27 | } 28 | 29 | func (e Extension) Copy() Extension { 30 | var n Extension 31 | data, _ := json.Marshal(e) 32 | _ = json.Unmarshal(data, &n) 33 | return n 34 | } 35 | 36 | var Extensions = []Extension{ 37 | { 38 | Publisher: "foo", 39 | Name: "zany", 40 | Description: "foo bar baz qux", 41 | Tags: "tag1", 42 | Categories: "category1", 43 | Files: []storage.VSIXAsset{ 44 | {Type: "Microsoft.VisualStudio.Services.Icons.Default", Path: "icon.png", Addressable: "true"}, 45 | {Type: "Unaddressable", Path: "unaddressable.ext", Addressable: "false"}, 46 | }, 47 | Properties: []storage.VSIXProperty{ 48 | { 49 | ID: "Microsoft.VisualStudio.Code.ExtensionPack", 50 | Value: "a.b,b.c", 51 | }, 52 | { 53 | ID: "Microsoft.VisualStudio.Code.ExtensionDependencies", 54 | Value: "d.e", 55 | }, 56 | }, 57 | Versions: []storage.Version{ 58 | {Version: "1.0.0"}, 59 | {Version: "1.0.0", TargetPlatform: storage.PlatformWin32X64}, 60 | {Version: "2.0.0"}, 61 | {Version: "3.0.0"}, 62 | {Version: "3.0.0", TargetPlatform: storage.PlatformLinuxX64}, 63 | {Version: "3.0.0", TargetPlatform: storage.PlatformLinuxArm64}, 64 | {Version: "3.0.0", TargetPlatform: storage.PlatformWin32X64}, 65 | {Version: "3.0.0", TargetPlatform: storage.PlatformAlpineX64}, 66 | {Version: "3.0.0", TargetPlatform: storage.PlatformDarwinX64}, 67 | {Version: "1.5.2"}, 68 | {Version: "2.2.2"}, 69 | }, 70 | LatestVersion: "3.0.0", 71 | Dependencies: []string{"d.e"}, 72 | Pack: []string{"a.b", "b.c"}, 73 | }, 74 | { 75 | Publisher: "foo", 76 | Name: "buz", 77 | Description: "quix baz bar buz sitting", 78 | Tags: "tag2", 79 | Categories: "category2", 80 | Properties: []storage.VSIXProperty{ 81 | { 82 | ID: "Microsoft.VisualStudio.Code.ExtensionPack", 83 | Value: "", 84 | }, 85 | { 86 | ID: "Microsoft.VisualStudio.Code.ExtensionDependencies", 87 | Value: "", 88 | }, 89 | }, 90 | Versions: []storage.Version{{Version: "version1"}}, 91 | LatestVersion: "version1", 92 | }, 93 | { 94 | Publisher: "bar", 95 | Name: "squigly", 96 | Description: "squigly foo and more foo bar baz", 97 | Tags: "tag1,tag2", 98 | Categories: "category1,category2", 99 | Versions: []storage.Version{{Version: "version1"}, {Version: "version2"}}, 100 | LatestVersion: "version2", 101 | }, 102 | { 103 | Publisher: "fred", 104 | Name: "thud", 105 | Description: "frobbles the frobnozzle", 106 | Tags: "tag3,tag4,tag5", 107 | Categories: "category1", 108 | Versions: []storage.Version{{Version: "version1"}, {Version: "version2"}}, 109 | LatestVersion: "version2", 110 | }, 111 | { 112 | Publisher: "qqqqqqqqqqq", 113 | Name: "qqqqq", 114 | Description: "qqqqqqqqqqqqqqqqqqq", 115 | Tags: "qq,qqq,qqqq", 116 | Categories: "q", 117 | Versions: []storage.Version{{Version: "qqq"}, {Version: "q"}}, 118 | LatestVersion: "qqq", 119 | }, 120 | } 121 | 122 | func ConvertExtensionToManifest(ext Extension, version storage.Version) *storage.VSIXManifest { 123 | ext = ext.Copy() 124 | return &storage.VSIXManifest{ 125 | Metadata: storage.VSIXMetadata{ 126 | Identity: storage.VSIXIdentity{ 127 | ID: ext.Name, 128 | Version: version.Version, 129 | Publisher: ext.Publisher, 130 | TargetPlatform: version.TargetPlatform, 131 | }, 132 | Properties: storage.VSIXProperties{ 133 | Property: ext.Properties, 134 | }, 135 | Description: ext.Description, 136 | Tags: ext.Tags, 137 | Categories: ext.Categories, 138 | }, 139 | Assets: storage.VSIXAssets{ 140 | Asset: ext.Files, 141 | }, 142 | } 143 | } 144 | 145 | func ConvertExtensionToManifestBytes(t *testing.T, ext Extension, version storage.Version) []byte { 146 | manifestBytes, err := xml.Marshal(ConvertExtensionToManifest(ext, version)) 147 | require.NoError(t, err) 148 | return manifestBytes 149 | } 150 | 151 | type file struct { 152 | name string 153 | body []byte 154 | } 155 | 156 | // createVSIX returns the bytes for a VSIX file containing the provided raw 157 | // manifest and package.json bytes (if not nil) and an icon. 158 | func CreateVSIX(t *testing.T, manifestBytes []byte, packageJSONBytes []byte) []byte { 159 | files := []file{{"icon.png", []byte("fake icon")}} 160 | if manifestBytes != nil { 161 | files = append(files, file{"extension.vsixmanifest", manifestBytes}) 162 | } 163 | if packageJSONBytes != nil { 164 | files = append(files, file{"extension/package.json", packageJSONBytes}) 165 | } 166 | buf := bytes.NewBuffer(nil) 167 | zw := zip.NewWriter(buf) 168 | for _, file := range files { 169 | fw, err := zw.Create(file.name) 170 | require.NoError(t, err) 171 | _, err = fw.Write([]byte(file.body)) 172 | require.NoError(t, err) 173 | } 174 | err := zw.Close() 175 | require.NoError(t, err) 176 | return buf.Bytes() 177 | } 178 | 179 | // CreateVSIXFromManifest returns the bytes for a VSIX file containing the 180 | // provided manifest and an icon. 181 | func CreateVSIXFromManifest(t *testing.T, manifest *storage.VSIXManifest) []byte { 182 | manifestBytes, err := xml.Marshal(manifest) 183 | require.NoError(t, err) 184 | return CreateVSIX(t, manifestBytes, nil) 185 | } 186 | 187 | func CreateVSIXFromPackageJSON(t *testing.T, packageJSON *storage.VSIXPackageJSON) []byte { 188 | packageJSONBytes, err := json.Marshal(packageJSON) 189 | require.NoError(t, err) 190 | return CreateVSIX(t, nil, packageJSONBytes) 191 | } 192 | 193 | // CreateVSIXFromExtension returns the bytes for a VSIX file containing the 194 | // manifest for the provided test extension and an icon. 195 | func CreateVSIXFromExtension(t *testing.T, ext Extension, version storage.Version) []byte { 196 | return CreateVSIXFromManifest(t, ConvertExtensionToManifest(ext, version)) 197 | } 198 | -------------------------------------------------------------------------------- /testutil/extensions_test.go: -------------------------------------------------------------------------------- 1 | package testutil_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/coder/code-marketplace/storage" 9 | "github.com/coder/code-marketplace/testutil" 10 | ) 11 | 12 | func TestConvert(t *testing.T) { 13 | ext := testutil.Extensions[0] 14 | 15 | manifest := testutil.ConvertExtensionToManifest(ext, storage.Version{Version: "a"}) 16 | require.Equal(t, manifest.Metadata.Identity.ID, ext.Name) 17 | require.Equal(t, manifest.Metadata.Identity.Publisher, ext.Publisher) 18 | require.Equal(t, manifest.Metadata.Identity.Version, "a") 19 | require.Equal(t, manifest.Metadata.Identity.TargetPlatform, storage.Platform("")) 20 | 21 | manifest = testutil.ConvertExtensionToManifest(ext, storage.Version{Version: "a", TargetPlatform: storage.PlatformDarwinX64}) 22 | require.Equal(t, manifest.Metadata.Identity.ID, ext.Name) 23 | require.Equal(t, manifest.Metadata.Identity.Publisher, ext.Publisher) 24 | require.Equal(t, manifest.Metadata.Identity.Version, "a") 25 | require.Equal(t, manifest.Metadata.Identity.TargetPlatform, storage.PlatformDarwinX64) 26 | } 27 | -------------------------------------------------------------------------------- /testutil/mockdb.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/url" 7 | "os" 8 | "strings" 9 | 10 | "github.com/coder/code-marketplace/database" 11 | "github.com/coder/code-marketplace/storage" 12 | ) 13 | 14 | // MockDB implements database.Database for tests. 15 | type MockDB struct { 16 | exts []*database.Extension 17 | } 18 | 19 | func NewMockDB(exts []*database.Extension) *MockDB { 20 | return &MockDB{exts: exts} 21 | } 22 | 23 | func (db *MockDB) GetExtensionAssetPath(ctx context.Context, asset *database.Asset, baseURL url.URL) (string, error) { 24 | if asset.Publisher == "error" { 25 | return "", errors.New("fake error") 26 | } 27 | if asset.Publisher == "notexist" { 28 | return "", os.ErrNotExist 29 | } 30 | assetPath := "foo" 31 | if asset.Type == storage.VSIXAssetType { 32 | assetPath = "extension.vsix" 33 | } 34 | return strings.Join([]string{baseURL.Path, "files", asset.Publisher, asset.Extension, asset.Version.String(), assetPath}, "/"), nil 35 | } 36 | 37 | func (db *MockDB) GetExtensions(ctx context.Context, filter database.Filter, flags database.Flag, baseURL url.URL) ([]*database.Extension, int, error) { 38 | if flags&database.Unpublished != 0 { 39 | return nil, 0, errors.New("fake error") 40 | } 41 | if len(filter.Criteria) == 0 { 42 | return nil, 0, nil 43 | } 44 | return db.exts, len(db.exts), nil 45 | } 46 | -------------------------------------------------------------------------------- /testutil/mockstorage.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "os" 8 | "sort" 9 | 10 | "github.com/coder/code-marketplace/storage" 11 | ) 12 | 13 | var _ storage.Storage = (*MockStorage)(nil) 14 | 15 | // MockStorage implements storage.Storage for tests. 16 | type MockStorage struct{} 17 | 18 | func NewMockStorage() *MockStorage { 19 | return &MockStorage{} 20 | } 21 | 22 | func (s *MockStorage) AddExtension(ctx context.Context, manifest *storage.VSIXManifest, vsix []byte, extra ...storage.File) (string, error) { 23 | return "", errors.New("not implemented") 24 | } 25 | 26 | func (s *MockStorage) FileServer() http.Handler { 27 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 28 | if r.URL.Path == "/nonexistent" { 29 | http.Error(rw, "not found", http.StatusNotFound) 30 | } else { 31 | _, _ = rw.Write([]byte("foobar")) 32 | } 33 | }) 34 | } 35 | 36 | func (s *MockStorage) Manifest(ctx context.Context, publisher, name string, version storage.Version) (*storage.VSIXManifest, error) { 37 | for _, ext := range Extensions { 38 | if ext.Publisher == publisher && ext.Name == name { 39 | for _, ver := range ext.Versions { 40 | // Use the string encoding to match since that is how the real storage 41 | // implementations will do it too. 42 | if ver.String() == version.String() { 43 | return ConvertExtensionToManifest(ext, ver), nil 44 | } 45 | } 46 | break 47 | } 48 | } 49 | return nil, os.ErrNotExist 50 | } 51 | 52 | func (s *MockStorage) RemoveExtension(ctx context.Context, publisher, name string, version storage.Version) error { 53 | return errors.New("not implemented") 54 | } 55 | 56 | func (s *MockStorage) WalkExtensions(ctx context.Context, fn func(manifest *storage.VSIXManifest, versions []storage.Version) error) error { 57 | for _, ext := range Extensions { 58 | versions := make([]storage.Version, len(ext.Versions)) 59 | copy(versions, ext.Versions) 60 | sort.Sort(storage.ByVersion(versions)) 61 | if err := fn(ConvertExtensionToManifest(ext, versions[0]), versions); err != nil { 62 | return nil 63 | } 64 | } 65 | return nil 66 | } 67 | 68 | func (s *MockStorage) Versions(ctx context.Context, publisher, name string) ([]storage.Version, error) { 69 | return nil, errors.New("not implemented") 70 | } 71 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | func Plural(count int, singular, plural string) string { 8 | if plural == "" { 9 | plural = singular + "s" 10 | } 11 | if count == 1 { 12 | return strconv.Itoa(count) + " " + singular 13 | } 14 | return strconv.Itoa(count) + " " + plural 15 | } 16 | 17 | func ContainsCompare[T any](haystack []T, needle T, equal func(a, b T) bool) bool { 18 | for _, hay := range haystack { 19 | if equal(needle, hay) { 20 | return true 21 | } 22 | } 23 | return false 24 | } 25 | 26 | func Contains[T comparable](haystack []T, needle T) bool { 27 | return ContainsCompare(haystack, needle, func(a, b T) bool { 28 | return a == b 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /util/util_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/coder/code-marketplace/util" 9 | ) 10 | 11 | func TestPlural(t *testing.T) { 12 | t.Parallel() 13 | 14 | require.Equal(t, "0 versions", util.Plural(0, "version", "")) 15 | require.Equal(t, "1 version", util.Plural(1, "version", "")) 16 | require.Equal(t, "2 versions", util.Plural(2, "version", "")) 17 | 18 | require.Equal(t, "0 dependencies", util.Plural(0, "dependency", "dependencies")) 19 | require.Equal(t, "1 dependency", util.Plural(1, "dependency", "dependencies")) 20 | require.Equal(t, "2 dependencies", util.Plural(2, "dependency", "dependencies")) 21 | } 22 | 23 | func TestContains(t *testing.T) { 24 | t.Parallel() 25 | 26 | require.True(t, util.Contains([]string{"foo", "bar"}, "foo")) 27 | require.True(t, util.Contains([]string{"foo", "bar"}, "bar")) 28 | require.False(t, util.Contains([]string{"foo", "bar"}, "baz")) 29 | require.False(t, util.Contains([]string{"foo", "bar"}, "foobar")) 30 | } 31 | --------------------------------------------------------------------------------