Repository: charmbracelet/soft-serve Branch: main Commit: 80490de86ee9 Files: 298 Total size: 777.0 KB Directory structure: gitextract_twsjoh99/ ├── .editorconfig ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── build.yml │ ├── coverage.yml │ ├── dependabot-sync.yml │ ├── goreleaser.yml │ ├── lint-sync.yml │ ├── lint.yml │ └── nightly.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .nfpm/ │ ├── postinstall.sh │ ├── postremove.sh │ ├── soft-serve.conf │ ├── soft-serve.service │ ├── sysusers.conf │ └── tmpfiles.conf ├── Dockerfile ├── LICENSE ├── README.md ├── browse.tape ├── cmd/ │ ├── cmd.go │ └── soft/ │ ├── admin/ │ │ └── admin.go │ ├── browse/ │ │ └── browse.go │ ├── hook/ │ │ └── hook.go │ ├── main.go │ └── serve/ │ ├── certreloader.go │ ├── certreloader_test.go │ ├── serve.go │ └── server.go ├── codecov.yml ├── demo.tape ├── docker.md ├── git/ │ ├── attr.go │ ├── attr_test.go │ ├── command.go │ ├── commit.go │ ├── config.go │ ├── errors.go │ ├── patch.go │ ├── reference.go │ ├── repo.go │ ├── server.go │ ├── stash.go │ ├── tag.go │ ├── tree.go │ ├── types.go │ └── utils.go ├── go.mod ├── go.sum ├── pkg/ │ ├── access/ │ │ ├── access.go │ │ ├── access_test.go │ │ ├── context.go │ │ └── context_test.go │ ├── backend/ │ │ ├── access_token.go │ │ ├── auth.go │ │ ├── auth_test.go │ │ ├── backend.go │ │ ├── cache.go │ │ ├── collab.go │ │ ├── context.go │ │ ├── hooks.go │ │ ├── lfs.go │ │ ├── repo.go │ │ ├── settings.go │ │ ├── user.go │ │ ├── utils.go │ │ └── webhooks.go │ ├── config/ │ │ ├── config.go │ │ ├── config_test.go │ │ ├── context.go │ │ ├── context_test.go │ │ ├── file.go │ │ ├── file_test.go │ │ ├── ssh.go │ │ ├── ssh_test.go │ │ └── testdata/ │ │ ├── config.yaml │ │ └── k1.pub │ ├── cron/ │ │ ├── cron.go │ │ └── cron_test.go │ ├── daemon/ │ │ ├── conn.go │ │ ├── daemon.go │ │ └── daemon_test.go │ ├── db/ │ │ ├── context.go │ │ ├── context_test.go │ │ ├── db.go │ │ ├── db_test.go │ │ ├── errors.go │ │ ├── errors_test.go │ │ ├── handler.go │ │ ├── internal/ │ │ │ └── test/ │ │ │ └── test.go │ │ ├── logger.go │ │ ├── migrate/ │ │ │ ├── 0001_create_tables.go │ │ │ ├── 0001_create_tables_postgres.down.sql │ │ │ ├── 0001_create_tables_postgres.up.sql │ │ │ ├── 0001_create_tables_sqlite.down.sql │ │ │ ├── 0001_create_tables_sqlite.up.sql │ │ │ ├── 0002_webhooks.go │ │ │ ├── 0002_webhooks_postgres.down.sql │ │ │ ├── 0002_webhooks_postgres.up.sql │ │ │ ├── 0002_webhooks_sqlite.down.sql │ │ │ ├── 0002_webhooks_sqlite.up.sql │ │ │ ├── 0003_migrate_lfs_objects.go │ │ │ ├── migrate.go │ │ │ ├── migrate_test.go │ │ │ └── migrations.go │ │ └── models/ │ │ ├── access_token.go │ │ ├── collab.go │ │ ├── lfs.go │ │ ├── public_key.go │ │ ├── repo.go │ │ ├── settings.go │ │ ├── user.go │ │ └── webhook.go │ ├── git/ │ │ ├── errors.go │ │ ├── git.go │ │ ├── git_test.go │ │ ├── lfs.go │ │ ├── lfs_auth.go │ │ ├── lfs_log.go │ │ └── service.go │ ├── hooks/ │ │ ├── gen.go │ │ ├── gen_test.go │ │ └── hooks.go │ ├── jobs/ │ │ ├── jobs.go │ │ └── mirror.go │ ├── jwk/ │ │ ├── jwk.go │ │ └── jwk_test.go │ ├── lfs/ │ │ ├── basic_transfer.go │ │ ├── client.go │ │ ├── common.go │ │ ├── endpoint.go │ │ ├── http_client.go │ │ ├── pointer.go │ │ ├── pointer_test.go │ │ ├── scanner.go │ │ ├── ssh_client.go │ │ └── transfer.go │ ├── log/ │ │ ├── log.go │ │ └── log_test.go │ ├── proto/ │ │ ├── access_token.go │ │ ├── context.go │ │ ├── errors.go │ │ ├── repo.go │ │ └── user.go │ ├── ssh/ │ │ ├── cmd/ │ │ │ ├── blob.go │ │ │ ├── branch.go │ │ │ ├── cmd.go │ │ │ ├── collab.go │ │ │ ├── commit.go │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── description.go │ │ │ ├── git.go │ │ │ ├── hidden.go │ │ │ ├── import.go │ │ │ ├── info.go │ │ │ ├── jwt.go │ │ │ ├── list.go │ │ │ ├── mirror.go │ │ │ ├── private.go │ │ │ ├── project_name.go │ │ │ ├── pubkey.go │ │ │ ├── rename.go │ │ │ ├── repo.go │ │ │ ├── set_username.go │ │ │ ├── settings.go │ │ │ ├── tag.go │ │ │ ├── token.go │ │ │ ├── tree.go │ │ │ ├── user.go │ │ │ └── webhooks.go │ │ ├── middleware.go │ │ ├── middleware_test.go │ │ ├── session.go │ │ ├── session_test.go │ │ ├── ssh.go │ │ └── ui.go │ ├── sshutils/ │ │ ├── utils.go │ │ └── utils_test.go │ ├── ssrf/ │ │ ├── ssrf.go │ │ └── ssrf_test.go │ ├── stats/ │ │ └── stats.go │ ├── storage/ │ │ ├── local.go │ │ └── storage.go │ ├── store/ │ │ ├── access_token.go │ │ ├── collab.go │ │ ├── context.go │ │ ├── database/ │ │ │ ├── access_token.go │ │ │ ├── collab.go │ │ │ ├── database.go │ │ │ ├── lfs.go │ │ │ ├── repo.go │ │ │ ├── settings.go │ │ │ ├── user.go │ │ │ └── webhooks.go │ │ ├── lfs.go │ │ ├── repo.go │ │ ├── settings.go │ │ ├── store.go │ │ ├── user.go │ │ └── webhooks.go │ ├── sync/ │ │ ├── workqueue.go │ │ └── workqueue_test.go │ ├── task/ │ │ └── manager.go │ ├── test/ │ │ └── test.go │ ├── ui/ │ │ ├── common/ │ │ │ ├── common.go │ │ │ ├── common_test.go │ │ │ ├── component.go │ │ │ ├── error.go │ │ │ ├── format.go │ │ │ ├── style.go │ │ │ └── utils.go │ │ ├── components/ │ │ │ ├── code/ │ │ │ │ └── code.go │ │ │ ├── footer/ │ │ │ │ └── footer.go │ │ │ ├── header/ │ │ │ │ └── header.go │ │ │ ├── selector/ │ │ │ │ └── selector.go │ │ │ ├── statusbar/ │ │ │ │ └── statusbar.go │ │ │ ├── tabs/ │ │ │ │ └── tabs.go │ │ │ └── viewport/ │ │ │ └── viewport.go │ │ ├── keymap/ │ │ │ └── keymap.go │ │ ├── pages/ │ │ │ ├── repo/ │ │ │ │ ├── empty.go │ │ │ │ ├── files.go │ │ │ │ ├── filesitem.go │ │ │ │ ├── log.go │ │ │ │ ├── logitem.go │ │ │ │ ├── readme.go │ │ │ │ ├── refs.go │ │ │ │ ├── refsitem.go │ │ │ │ ├── repo.go │ │ │ │ ├── stash.go │ │ │ │ └── stashitem.go │ │ │ └── selection/ │ │ │ ├── item.go │ │ │ └── selection.go │ │ └── styles/ │ │ └── styles.go │ ├── utils/ │ │ ├── utils.go │ │ └── utils_test.go │ ├── version/ │ │ └── version.go │ ├── web/ │ │ ├── auth.go │ │ ├── context.go │ │ ├── git.go │ │ ├── git_lfs.go │ │ ├── goget.go │ │ ├── health.go │ │ ├── http.go │ │ ├── logging.go │ │ ├── server.go │ │ └── util.go │ └── webhook/ │ ├── branch_tag.go │ ├── collaborator.go │ ├── common.go │ ├── content_type.go │ ├── content_type_test.go │ ├── event.go │ ├── push.go │ ├── repository.go │ ├── ssrf_test.go │ ├── validator.go │ ├── validator_test.go │ └── webhook.go ├── systemd.md └── testscript/ ├── script_test.go └── testdata/ ├── anon-access.txtar ├── auth-bypass-regression.txtar ├── config-servers-git_disabled.txtar ├── config-servers-http_disabled.txtar ├── config-servers-ssh_disabled.txtar ├── config-servers-stats_disabled.txtar ├── help.txtar ├── http-cors.txtar ├── http.txtar ├── jwt.txtar ├── mirror.txtar ├── repo-blob.txtar ├── repo-collab.txtar ├── repo-commit.txtar ├── repo-create.txtar ├── repo-delete.txtar ├── repo-import-local-path.txtar ├── repo-import.txtar ├── repo-perms.txtar ├── repo-push.txtar ├── repo-tree.txtar ├── repo-webhook-ssrf.txtar ├── repo-webhooks.txtar ├── set-username.txtar ├── settings.txtar ├── soft-browse.txtar ├── soft-manpages.txtar ├── ssh-lfs.txtar ├── ssh.txtar ├── token.txtar ├── ui-home.txtar └── user_management.txtar ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset=utf-8 end_of_line=lf insert_final_newline=true trim_trailing_whitespace=true indent_size=2 indent_style=space [*.go] indent_size=4 indent_style=tab ================================================ FILE: .github/CODEOWNERS ================================================ * @aymanbagabas ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - OS: [e.g. Linux] - Terminal [e.g. kitty, iterm2, gnome-terminal] - Version [e.g. v0.4.0] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" ignore: - dependency-name: github.com/charmbracelet/bubbletea/v2 versions: - v2.0.0-beta1 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: push: branches: - "main" pull_request: jobs: build: uses: charmbracelet/meta/.github/workflows/build.yml@main snapshot: uses: charmbracelet/meta/.github/workflows/snapshot.yml@main secrets: goreleaser_key: ${{ secrets.GORELEASER_KEY }} test_postgres: services: postgres: image: postgres ports: - 5432:5432 env: POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Install Go uses: actions/setup-go@v6 with: go-version: ^1 cache: true - name: Download Go modules run: go mod download - name: Test run: go test ./... env: SOFT_SERVE_DB_DRIVER: postgres SOFT_SERVE_DB_DATA_SOURCE: postgres://postgres:postgres@localhost/postgres?sslmode=disable ================================================ FILE: .github/workflows/coverage.yml ================================================ name: coverage on: push: branches: - "main" pull_request: jobs: coverage: strategy: matrix: os: [ubuntu-latest] # TODO: add macos & windows services: postgres: image: postgres ports: - 5432:5432 env: POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: ^1 - name: Test run: | # We collect coverage data from two sources, # 1) unit tests 2) integration tests # # https://go.dev/testing/coverage/ # https://dustinspecker.com/posts/go-combined-unit-integration-code-coverage/ # https://github.com/golang/go/issues/51430#issuecomment-1344711300 mkdir -p coverage/unit mkdir -p coverage/int mkdir -p coverage/int2 # Collect unit tests coverage go test -failfast -race -timeout 5m -skip=^TestScript -cover ./... -args -test.gocoverdir=$PWD/coverage/unit # Collect integration tests coverage GOCOVERDIR=$PWD/coverage/int go test -failfast -race -timeout 5m -run=^TestScript ./... SOFT_SERVE_DB_DRIVER=postgres \ SOFT_SERVE_DB_DATA_SOURCE=postgres://postgres:postgres@localhost/postgres?sslmode=disable \ GOCOVERDIR=$PWD/coverage/int2 go test -failfast -race -timeout 5m -run=^TestScript ./... # Convert coverage data to legacy textfmt format to upload go tool covdata textfmt -i=coverage/unit,coverage/int,coverage/int2 -o=coverage.txt - uses: codecov/codecov-action@v5 with: file: ./coverage.txt ================================================ FILE: .github/workflows/dependabot-sync.yml ================================================ name: dependabot-sync on: schedule: - cron: "0 0 * * 0" # every Sunday at midnight workflow_dispatch: # allows manual triggering permissions: contents: write pull-requests: write jobs: dependabot-sync: uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main with: repo_name: ${{ github.event.repository.name }} secrets: gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} ================================================ FILE: .github/workflows/goreleaser.yml ================================================ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: goreleaser on: push: tags: - v*.*.* concurrency: group: goreleaser cancel-in-progress: true jobs: goreleaser: uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main secrets: docker_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_token: ${{ secrets.DOCKERHUB_TOKEN }} gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} goreleaser_key: ${{ secrets.GORELEASER_KEY }} fury_token: ${{ secrets.FURY_TOKEN }} nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }} macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }} macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }} ================================================ FILE: .github/workflows/lint-sync.yml ================================================ name: lint-sync on: # schedule: # # every Sunday at midnight # - cron: "0 0 * * 0" workflow_dispatch: # allows manual triggering permissions: contents: write pull-requests: write jobs: lint: uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main ================================================ FILE: .github/workflows/lint.yml ================================================ name: lint on: push: pull_request: jobs: lint: uses: charmbracelet/meta/.github/workflows/lint.yml@main with: golangci_path: .golangci.yml golangci_version: latest timeout: 10m ================================================ FILE: .github/workflows/nightly.yml ================================================ name: nightly on: push: branches: - main jobs: nightly: uses: charmbracelet/meta/.github/workflows/nightly.yml@main secrets: docker_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_token: ${{ secrets.DOCKERHUB_TOKEN }} goreleaser_key: ${{ secrets.GORELEASER_KEY }} macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }} macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }} macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }} ================================================ FILE: .gitignore ================================================ cmd/soft/soft ./soft .ssh .repos dist data/ completions/ manpages/ soft_serve_ed25519* ================================================ FILE: .golangci.yml ================================================ version: "2" linters: enable: - bodyclose # - exhaustive # - goconst # - godot # - godox # - gomoddirectives - goprintffuncname # - gosec - misspell # - nakedret # - nestif # - nilerr - noctx - nolintlint # - prealloc # - revive - rowserrcheck - sqlclosecheck - tparallel # - unconvert # - unparam - whitespace # - wrapcheck disable: - errcheck - ineffassign - unused - staticcheck exclusions: generated: lax presets: - common-false-positives rules: - text: '(slog|log)\.\w+' linters: - noctx issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - gofumpt - goimports exclusions: generated: lax ================================================ FILE: .goreleaser.yml ================================================ # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json version: 2 includes: - from_url: url: charmbracelet/meta/main/goreleaser-soft-serve.yaml variables: main: "./cmd/soft" binary_name: soft description: "A tasty, self-hostable Git server for the command line🍦" github_url: "https://github.com/charmbracelet/soft-serve" maintainer: "Ayman Bagabas " brew_commit_author_name: "Ayman Bagabas" brew_commit_author_email: "ayman@charm.sh" ================================================ FILE: .nfpm/postinstall.sh ================================================ #!/bin/sh set -e if ! command -V systemctl >/dev/null 2>&1; then echo "Not running SystemD, ignoring" exit 0 fi systemd-sysusers systemd-tmpfiles --create systemctl daemon-reload systemctl unmask soft-serve.service systemctl preset soft-serve.service ================================================ FILE: .nfpm/postremove.sh ================================================ #!/bin/sh set -e if ! command -V systemctl >/dev/null 2>&1; then echo "Not running SystemD, ignoring" exit 0 fi systemctl daemon-reload systemctl reset-failed echo "WARN: the soft-serve user/group and /var/lib/soft-serve directory were not removed" ================================================ FILE: .nfpm/soft-serve.conf ================================================ # Config defined here will override the config in /var/lib/soft-serve/config.yaml # Keys defined in `SOFT_SERVE_INITIAL_ADMIN_KEYS` will be merged with # the `initial_admin_keys` from /var/lib/soft-serve/config.yaml. # #SOFT_SERVE_GIT_LISTEN_ADDR=:9418 #SOFT_SERVE_HTTP_LISTEN_ADDR=:23232 #SOFT_SERVE_SSH_LISTEN_ADDR=:23231 #SOFT_SERVE_SSH_KEY_PATH=ssh/soft_serve_host_ed25519 #SOFT_SERVE_INITIAL_ADMIN_KEYS='ssh-ed25519 AAAAC3NzaC1lZDI1...' ================================================ FILE: .nfpm/soft-serve.service ================================================ [Unit] Description=Soft Serve git server 🍦 Documentation=https://github.com/charmbracelet/soft-serve Requires=network-online.target After=network-online.target [Service] Type=simple User=soft-serve Group=soft-serve Restart=always RestartSec=1 ExecStart=/usr/bin/soft serve Environment=SOFT_SERVE_DATA_PATH=/var/lib/soft-serve EnvironmentFile=-/etc/soft-serve.conf WorkingDirectory=/var/lib/soft-serve # Hardening ReadWritePaths=/var/lib/soft-serve UMask=0027 NoNewPrivileges=true LimitNOFILE=1048576 ProtectSystem=strict ProtectHome=true PrivateUsers=yes PrivateTmp=true PrivateDevices=true ProtectHostname=true ProtectClock=true ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true ProtectControlGroups=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictNamespaces=true LockPersonality=true MemoryDenyWriteExecute=true RestrictRealtime=true RestrictSUIDSGID=true RemoveIPC=true CapabilityBoundingSet= AmbientCapabilities= SystemCallFilter=@system-service SystemCallFilter=~@privileged @resources SystemCallArchitectures=native [Install] WantedBy=multi-user.target ================================================ FILE: .nfpm/sysusers.conf ================================================ u soft-serve - "Soft Serve daemon user" /var/lib/soft-serve ================================================ FILE: .nfpm/tmpfiles.conf ================================================ d /var/lib/soft-serve 0750 soft-serve soft-serve ================================================ FILE: Dockerfile ================================================ FROM alpine:latest # Create directories WORKDIR /soft-serve # Expose data volume VOLUME /soft-serve # Environment variables ENV SOFT_SERVE_DATA_PATH "/soft-serve" ENV SOFT_SERVE_INITIAL_ADMIN_KEYS "" # workaround to prevent slowness in docker when running with a tty ENV CI "1" # Expose ports # SSH EXPOSE 23231/tcp # HTTP EXPOSE 23232/tcp # Stats EXPOSE 23233/tcp # Git EXPOSE 9418/tcp # Set the default command ENTRYPOINT [ "/usr/local/bin/soft", "serve" ] RUN apk update && apk add --update git bash openssh && rm -rf /var/cache/apk/* COPY soft /usr/local/bin/soft ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021-2023 Charmbracelet, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Soft Serve

A nice rendering of some melting ice cream with the words ‘Charm Soft Serve’ next to it
Latest Release GoDoc Build Status

A tasty, self-hostable Git server for the command line. 🍦 Soft Serve screencast - Easy to navigate TUI available over SSH - Clone repos over SSH, HTTP, or Git protocol - Git LFS support with both HTTP and SSH backends - Manage repos with SSH - Create repos on demand with SSH or `git push` - Browse repos, files and commits with SSH-accessible UI - Print files over SSH with or without syntax highlighting and line numbers - Easy access control - SSH authentication using public keys - Allow/disallow anonymous access - Add collaborators with SSH public keys - Repos can be public or private - User access tokens ## Where can I see it? Just run `ssh git.charm.sh` for an example. You can also try some of the following commands: ```bash # Jump directly to a repo in the TUI ssh git.charm.sh -t soft-serve # Print out a directory tree for a repo ssh git.charm.sh repo tree soft-serve # Print a specific file ssh git.charm.sh repo blob soft-serve cmd/soft/main.go # Print a file with syntax highlighting and line numbers ssh git.charm.sh repo blob soft-serve cmd/soft/main.go -c -l ``` Or you can use Soft Serve to browse local repositories using `soft browse [directory]` or running `soft` within a Git repository. ## Installation Soft Serve is a single binary called `soft`. You can get it from a package manager: ```bash # macOS or Linux brew install charmbracelet/tap/soft-serve # Windows (with Winget) winget install charmbracelet.soft-serve # Arch Linux pacman -S soft-serve # Nix nix-env -iA nixpkgs.soft-serve # Debian/Ubuntu sudo mkdir -p /etc/apt/keyrings curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list sudo apt update && sudo apt install soft-serve # Fedora/RHEL echo '[charm] name=Charm baseurl=https://repo.charm.sh/yum/ enabled=1 gpgcheck=1 gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo sudo yum install soft-serve ``` You can also download a binary from the [releases][releases] page. Packages are available in Alpine, Debian, and RPM formats. Binaries are available for Linux, macOS, and Windows. [releases]: https://github.com/charmbracelet/soft-serve/releases Or just install it with `go`: ```bash go install github.com/charmbracelet/soft-serve/cmd/soft@latest ``` A [Docker image][docker] is also available. [docker]: https://github.com/charmbracelet/soft-serve/blob/main/docker.md ## Setting up a server Make sure `git` is installed, then run `soft serve`. That’s it. This will create a `data` directory that will store all the repos, ssh keys, and database. By default, program configuration is stored within the `data` directory. But, this can be overridden by setting a custom path to a config file with `SOFT_SERVE_CONFIG_LOCATION` that is pre-created. If a config file pointed to by `SOFT_SERVE_CONFIG_LOCATION`, the default location within the `data` dir is used for generating a default config. To change the default data path use `SOFT_SERVE_DATA_PATH` environment variable. ```sh SOFT_SERVE_DATA_PATH=/var/lib/soft-serve soft serve ``` When you run Soft Serve for the first time, make sure you have the `SOFT_SERVE_INITIAL_ADMIN_KEYS` environment variable is set to your ssh authorized key. Any added key to this variable will be treated as admin with full privileges. Using this environment variable, Soft Serve will create a new `admin` user that has full privileges. You can rename and change the user settings later. Check out [Systemd][systemd] on how to run Soft Serve as a service using Systemd. Soft Serve packages in our Apt/Yum repositories come with Systemd service units. [systemd]: https://github.com/charmbracelet/soft-serve/blob/main/systemd.md ### Server Configuration Once you start the server for the first time, the settings will be in `config.yaml` under your data directory. The default `config.yaml` is self-explanatory and will look like this: ```yaml # Soft Serve Server configurations # The name of the server. # This is the name that will be displayed in the UI. name: "Soft Serve" # Log format to use. Valid values are "json", "logfmt", and "text". log_format: "text" # The SSH server configuration. ssh: # The address on which the SSH server will listen. listen_addr: ":23231" # The public URL of the SSH server. # This is the address that will be used to clone repositories. public_url: "ssh://localhost:23231" # The path to the SSH server's private key. key_path: "ssh/soft_serve_host" # The path to the SSH server's client private key. # This key will be used to authenticate the server to make git requests to # ssh remotes. client_key_path: "ssh/soft_serve_client" # The maximum number of seconds a connection can take. # A value of 0 means no timeout. max_timeout: 0 # The number of seconds a connection can be idle before it is closed. idle_timeout: 120 # The Git daemon configuration. git: # The address on which the Git daemon will listen. listen_addr: ":9418" # The maximum number of seconds a connection can take. # A value of 0 means no timeout. max_timeout: 0 # The number of seconds a connection can be idle before it is closed. idle_timeout: 3 # The maximum number of concurrent connections. max_connections: 32 # The HTTP server configuration. http: # The address on which the HTTP server will listen. listen_addr: ":23232" # The path to the TLS private key. tls_key_path: "" # The path to the TLS certificate. tls_cert_path: "" # The public URL of the HTTP server. # This is the address that will be used to clone repositories. # Make sure to use https:// if you are using TLS. public_url: "http://localhost:23232" # The cross-origin request security options cors: # The allowed cross-origin headers allowed_headers: - "Accept" - "Accept-Language" - "Content-Language" - "Content-Type" - "Origin" - "X-Requested-With" - "User-Agent" - "Authorization" - "Access-Control-Request-Method" - "Access-Control-Allow-Origin" # The allowed cross-origin URLs allowed_origins: - "http://localhost:23232" # always allowed # - "https://example.com" # The allowed cross-origin methods allowed_methods: - "GET" - "HEAD" - "POST" - "PUT" - "OPTIONS" # The database configuration. db: # The database driver to use. # Valid values are "sqlite" and "postgres". driver: "sqlite" # The database data source name. # This is driver specific and can be a file path or connection string. # Make sure foreign key support is enabled when using SQLite. data_source: "soft-serve.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" # Git LFS configuration. lfs: # Enable Git LFS. enabled: true # Enable Git SSH transfer. ssh_enabled: false # Cron job configuration jobs: mirror_pull: "@every 10m" # The stats server configuration. stats: # The address on which the stats server will listen. listen_addr: ":23233" # Additional admin keys. #initial_admin_keys: # - "ssh-rsa AAAAB3NzaC1yc2..." ``` You can also use environment variables, to override these settings. All server settings environment variables start with `SOFT_SERVE_` followed by the setting name all in uppercase. Here are some examples: - `SOFT_SERVE_NAME`: The name of the server that will appear in the TUI - `SOFT_SERVE_SSH_LISTEN_ADDR`: SSH listen address - `SOFT_SERVE_SSH_KEY_PATH`: SSH host key-pair path - `SOFT_SERVE_HTTP_LISTEN_ADDR`: HTTP listen address - `SOFT_SERVE_HTTP_PUBLIC_URL`: HTTP public URL used for cloning - `SOFT_SERVE_GIT_MAX_CONNECTIONS`: The number of simultaneous connections to git daemon #### Database Configuration Soft Serve supports both SQLite and Postgres for its database. Like all other Soft Serve settings, you can change the database _driver_ and _data source_ using either `config.yaml` or environment variables. The default config uses SQLite as the default database driver. To use Postgres as your database, first create a Soft Serve database: ```sh psql -h -p -U -c 'CREATE DATABASE soft_serve' ``` Then set the database _data source_ to point to your Postgres database. For instance, if you're running Postgres locally, using the default user `postgres` and using a database name `soft_serve`, you would have this config in your config file or environment variable: ``` db: driver: "postgres" data_source: "postgres://postgres@localhost:5432/soft_serve?sslmode=disable" ``` Environment variables equivalent: ```sh SOFT_SERVE_DB_DRIVER=postgres \ SOFT_SERVE_DB_DATA_SOURCE="postgres://postgres@localhost:5432/soft_serve?sslmode=disable" \ soft serve ``` You can specify a database connection password in the _data source_ url. For example, `postgres://myuser:dbpass@localhost:5432/my_soft_serve_db`. #### LFS Configuration Soft Serve supports both Git LFS [HTTP](https://github.com/git-lfs/git-lfs/blob/main/docs/api/README.md) and [SSH](https://github.com/git-lfs/git-lfs/blob/main/docs/proposals/ssh_adapter.md) protocols out of the box, there is no need to do any extra set up. Use the `lfs` config section to customize your Git LFS server. > **Note**: The pure-SSH transfer is disabled by default. ## Server Access Soft Serve at its core manages your server authentication and authorization. Authentication verifies the identity of a user, while authorization determines their access rights to a repository. To manage the server users, access, and repos, you can use the SSH command line interface. Try `ssh localhost -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes -p 23231 help` for more info. Make sure you use your key here. > **Note** The `IdentitiesOnly` option is used to prevent SSH from using any > other keys in your `~/.ssh` directory. This is useful when you have multiple > keys, and you want to use a specific key for Soft Serve. For ease of use, instead of specifying the key, port, and hostname every time you SSH into Soft Serve, add your own Soft Serve instance entry to your SSH config. For instance, to use `ssh soft` instead of typing `ssh localhost -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes -p 23231`, we can define a `soft` entry in our SSH config file `~/.ssh/config`. ```conf Host soft HostName localhost Port 23231 IdentityFile ~/.ssh/id_ed25519 IdentitiesOnly yes ``` Now, we can do `ssh soft` to SSH into Soft Serve. Since `git` is also aware of this config, you can use `soft` as the hostname for your clone commands. ```sh git clone ssh://soft/dotfiles # make changes # add & commit git push origin main ``` > **Note** The `-i` and `-o` parts will be omitted in the examples below for brevity. You > can add your server settings to your sshconfig for quicker access. ### Authentication Everything that needs authentication is done using SSH. Make sure you have added an entry for your Soft Serve instance in your `~/.ssh/config` file. By default, Soft Serve gives read-only permission to anonymous connections to any of the above protocols. This is controlled by two settings `anon-access` and `allow-keyless`. - `anon-access`: Defines the access level for anonymous users. Available options are `no-access`, `read-only`, `read-write`, and `admin-access`. Default is `read-only`. - `allow-keyless`: Whether to allow connections that doesn't use keys to pass. Setting this to `false` would disable access to SSH keyboard-interactive, HTTP, and Git protocol connections. Default is `true`. ```sh $ ssh -p 23231 localhost settings Manage server settings Usage: ssh -p 23231 localhost settings [command] Available Commands: allow-keyless Set or get allow keyless access to repositories anon-access Set or get the default access level for anonymous users Flags: -h, --help help for settings Use "ssh -p 23231 localhost settings [command] --help" for more information about a command. ``` > **Note** These settings can only be changed by admins. When `allow-keyless` is disabled, connections that don't use SSH Public Key authentication will get denied. This means cloning repos over HTTP(s) or git:// will get denied. Meanwhile, `anon-access` controls the access level granted to connections that use SSH Public Key authentication but are not registered users. The default setting for this is `read-only`. This will grant anonymous connections that use SSH Public Key authentication `read-only` access to public repos. `anon-access` is also used in combination with `allow-keyless` to determine the access level for HTTP(s) and git:// clone requests. #### SSH Soft Serve doesn't allow duplicate SSH public keys for users. A public key can be associated with one user only. This makes SSH authentication simple and straight forward, add your public key to your Soft Serve user to be able to access Soft Serve. #### HTTP You can generate user access tokens through the SSH command line interface. Access tokens can have an optional expiration date. Use your access token as the basic auth user to access your Soft Serve repos through HTTP. ```sh # Create a user token ssh -p 23231 localhost token create 'my new token' ss_1234abc56789012345678901234de246d798fghi # Or with an expiry date ssh -p 23231 localhost token create --expires-in 1y 'my other token' ss_98fghi1234abc56789012345678901234de246d7 ``` Now you can access to repos that require `read-write` access. ```sh git clone http://ss_98fghi1234abc56789012345678901234de246d7@localhost:23232/my-private-repo.git my-private-repo # Make changes and push ``` ### Authorization Soft Serve offers a simple access control. There are four access levels, no-access, read-only, read-write, and admin-access. `admin-access` has full control of the server and can make changes to users and repos. `read-write` access gets full control of repos. `read-only` can read public repos. `no-access` denies access to all repos. ## User Management Admins can manage users and their keys using the `user` command. Once a user is created and has access to the server, they can manage their own keys and settings. To create a new user simply use `user create`: ```sh # Create a new user ssh -p 23231 localhost user create beatrice # Add user keys ssh -p 23231 localhost user add-pubkey beatrice ssh-rsa AAAAB3Nz... ssh -p 23231 localhost user add-pubkey beatrice ssh-ed25519 AAAA... # Create another user with public key ssh -p 23231 localhost user create frankie '-k "ssh-ed25519 AAAATzN..."' # Need help? ssh -p 23231 localhost user help ``` Once a user is created, they get `read-only` access to public repositories. They can also create new repositories on the server. Users can manage their keys using the `pubkey` command: ```sh # List user keys ssh -p 23231 localhost pubkey list # Add key ssh -p 23231 localhost pubkey add ssh-ed25519 AAAA... # Wanna change your username? ssh -p 23231 localhost set-username yolo # To display user info ssh -p 23231 localhost info ``` ## Repositories You can manage repositories using the `repo` command. ```sh # Run repo help $ ssh -p 23231 localhost repo help Manage repositories Usage: ssh -p 23231 localhost repo [command] Aliases: repo, repos, repository, repositories Available Commands: blob Print out the contents of file at path branch Manage repository branches collab Manage collaborators create Create a new repository delete Delete a repository description Set or get the description for a repository hide Hide or unhide a repository import Import a new repository from remote info Get information about a repository is-mirror Whether a repository is a mirror list List repositories private Set or get a repository private property project-name Set or get the project name for a repository rename Rename an existing repository tag Manage repository tags tree Print repository tree at path Flags: -h, --help help for repo Use "ssh -p 23231 localhost repo [command] --help" for more information about a command. ``` To use any of the above `repo` commands, a user must be a collaborator in the repository. More on this below. ### Creating Repositories To create a repository, first make sure you are a registered user. Use the `repo create ` command to create a new repository: ```sh # Create a new repository ssh -p 23231 localhost repo create icecream # Create a repo with description ssh -p 23231 localhost repo create icecream '-d "This is an Ice Cream description"' # ... and project name ssh -p 23231 localhost repo create icecream '-d "This is an Ice Cream description"' '-n "Ice Cream"' # I need my repository private! ssh -p 23231 localhost repo create icecream -p '-d "This is an Ice Cream description"' '-n "Ice Cream"' # Help? ssh -p 23231 localhost repo create -h ``` Or you can add your Soft Serve server as a remote to any existing repo, given you have write access, and push to remote: ``` git remote add origin ssh://localhost:23231/icecream ``` After you’ve added the remote just go ahead and push. If the repo doesn’t exist on the server it’ll be created. ``` git push origin main ``` ### Nested Repositories Repositories can be nested too: ```sh # Create a new nested repository ssh -p 23231 localhost repo create charmbracelet/icecream # Or ... git remote add charm ssh://localhost:23231/charmbracelet/icecream git push charm main ``` ### Mirrors You can also *import* repositories from any public remote. Use the `repo import` command. ```sh ssh -p 23231 localhost repo import soft-serve https://github.com/charmbracelet/soft-serve ``` Use `--mirror` or `-m` to mark the repository as a *pull* mirror. ### Deleting Repositories You can delete repositories using the `repo delete ` command. ```sh ssh -p 23231 localhost repo delete icecream ``` ### Renaming Repositories Use the `repo rename ` command to rename existing repositories. ```sh ssh -p 23231 localhost repo rename icecream vanilla ``` ### Repository Collaborators Sometimes you want to restrict write access to certain repositories. This can be achieved by adding a collaborator to your repository. Use the `repo collab ` command to manage repo collaborators. ```sh # Add collaborator to soft-serve ssh -p 23231 localhost repo collab add soft-serve frankie # Add collaborator with a specific access level ssh -p 23231 localhost repo collab add soft-serve beatrice read-only # Remove collaborator ssh -p 23231 localhost repo collab remove soft-serve beatrice # List collaborators ssh -p 23231 localhost repo collab list soft-serve ``` ### Repository Metadata You can also change the repo's description, project name, whether it's private, etc using the `repo ` command. ```sh # Set description for repo ssh -p 23231 localhost repo description icecream "This is a new description" # Hide repo from listing ssh -p 23231 localhost repo hidden icecream true # List repository info (branches, tags, description, etc) ssh -p 23231 localhost repo icecream info ``` To make a repository private, use `repo private [true|false]`. Private repos can only be accessed by admins and collaborators. ```sh ssh -p 23231 localhost repo private icecream true ``` ### Repository Branches & Tags Use `repo branch` and `repo tag` to list, and delete branches or tags. You can also use `repo branch default` to set or get the repository default branch. ### Repository Tree To print a file tree for the project, just use the `repo tree` command along with the repo name as the SSH command to your Soft Serve server: ```sh ssh -p 23231 localhost repo tree soft-serve ``` You can also specify the sub-path and a specific reference or branch. ```sh ssh -p 23231 localhost repo tree soft-serve server/config ssh -p 23231 localhost repo tree soft-serve main server/config ``` From there, you can print individual files using the `repo blob` command: ```sh ssh -p 23231 localhost repo blob soft-serve cmd/soft/main.go ``` You can add the `-c` flag to enable syntax coloring and `-l` to print line numbers: ```sh ssh -p 23231 localhost repo blob soft-serve cmd/soft/main.go -c -l ``` Use `--raw` to print raw file contents. This is useful for dumping binary data. ### Repository webhooks Soft Serve supports repository webhooks using the `repo webhook` command. You can create and manage webhooks for different repository events such as _push_, _collaborators_, and _branch_tag_create_ events. ``` Manage repository webhooks Usage: ssh -p 23231 localhost repo webhook [command] Aliases: webhook, webhooks Available Commands: create Create a repository webhook delete Delete a repository webhook deliveries Manage webhook deliveries list List repository webhooks update Update a repository webhook Flags: -h, --help help for webhook ``` ## The Soft Serve TUI TUI example showing a diff Soft Serve TUI is mainly used to browse repos over SSH. You can also use it to browse local repositories with `soft browse` or running `soft` within a Git repository. ```sh ssh localhost -p 23231 ``` It's also possible to “link” to a specific repo: ```sh ssh -p 23231 localhost -t soft-serve ``` You can copy text to your clipboard over SSH. For instance, you can press c on the highlighted repo in the menu to copy the clone command [^osc52]. [^osc52]: Copying over SSH depends on your terminal support of OSC52. Refer to [go-osc52](https://github.com/aymanbagabas/go-osc52) for more information. ## Hooks Soft Serve supports git server-side hooks `pre-receive`, `update`, `post-update`, and `post-receive`. This means you can define your own hooks to run on repository push events. Hooks can be defined as a per-repository hook, and/or global hooks that run for all repositories. You can find per-repository hooks under the repository `hooks` directory. Globs hooks can be found in your `SOFT_SERVE_DATA_PATH` directory under `hooks`. Defining global hooks is useful if you want to run CI/CD for example. Here's an example of sending a message after receiving a push event. Create an executable file `/hooks/update`: ```sh #!/bin/sh # # An example hook script to echo information about the push # and send it to the client. refname="$1" oldrev="$2" newrev="$3" # Safety check if [ -z "$GIT_DIR" ]; then echo "Don't run this script from the command line." >&2 echo " (if you want, you could supply GIT_DIR then run" >&2 echo " $0 )" >&2 exit 1 fi if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then echo "usage: $0 " >&2 exit 1 fi # Check types # if $newrev is 0000...0000, it's a commit to delete a ref. zero=$(git hash-object --stdin The Charm logo Charm热爱开源 • Charm loves open source ================================================ FILE: browse.tape ================================================ Set Width 1600 Set Height 900 Set FontSize 22 Output soft-serve-browse.gif Output soft-serve-frames/ Type@300ms "soft" Enter Sleep 2s Type@1s "ddd" Sleep 2s Type@1s "uuu" Sleep 2s Tab@1s Sleep 1s Down@300ms 4 Enter Sleep 1s Down@300ms 13 Enter Sleep 1s Down@300ms 5 Enter Down@300ms 20 Sleep 2s Type@500ms "b" Sleep 2.5s Down@300ms 50 Sleep 2.5s Tab@1s Down@500ms 4 Up@500ms 2 Enter Down@250ms 50 Sleep 1s Tab@1s Down@500ms 8 Enter Down@250ms 30 Tab@2s Down@500ms 5 Up@500ms 2 Sleep 2.5s Tab@2s Down@500ms 8 Sleep 2s ================================================ FILE: cmd/cmd.go ================================================ package cmd import ( "context" "errors" "fmt" "io/fs" "os" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/hooks" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/store/database" "github.com/spf13/cobra" ) // InitBackendContext initializes the backend context. func InitBackendContext(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() cfg := config.FromContext(ctx) if _, err := os.Stat(cfg.DataPath); errors.Is(err, fs.ErrNotExist) { if err := os.MkdirAll(cfg.DataPath, os.ModePerm); err != nil { return fmt.Errorf("create data directory: %w", err) } } dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource) if err != nil { return fmt.Errorf("open database: %w", err) } ctx = db.WithContext(ctx, dbx) dbstore := database.New(ctx, dbx) ctx = store.WithContext(ctx, dbstore) be := backend.New(ctx, cfg, dbx, dbstore) ctx = backend.WithContext(ctx, be) cmd.SetContext(ctx) return nil } // CloseDBContext closes the database context. func CloseDBContext(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() dbx := db.FromContext(ctx) if dbx != nil { if err := dbx.Close(); err != nil { return fmt.Errorf("close database: %w", err) } } return nil } // InitializeHooks initializes the hooks. func InitializeHooks(ctx context.Context, cfg *config.Config, be *backend.Backend) error { repos, err := be.Repositories(ctx) if err != nil { return err } for _, repo := range repos { if err := hooks.GenerateHooks(ctx, cfg, repo.Name()); err != nil { return err } } return nil } ================================================ FILE: cmd/soft/admin/admin.go ================================================ package admin import ( "fmt" "github.com/charmbracelet/soft-serve/cmd" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/migrate" "github.com/spf13/cobra" ) var ( // Command is the admin command. Command = &cobra.Command{ Use: "admin", Short: "Administrate the server", } migrateCmd = &cobra.Command{ Use: "migrate", Short: "Migrate the database to the latest version", PersistentPreRunE: cmd.InitBackendContext, PersistentPostRunE: cmd.CloseDBContext, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() db := db.FromContext(ctx) if err := migrate.Migrate(ctx, db); err != nil { return fmt.Errorf("migration: %w", err) } return nil }, } rollbackCmd = &cobra.Command{ Use: "rollback", Short: "Rollback the database to the previous version", PersistentPreRunE: cmd.InitBackendContext, PersistentPostRunE: cmd.CloseDBContext, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() db := db.FromContext(ctx) if err := migrate.Rollback(ctx, db); err != nil { return fmt.Errorf("rollback: %w", err) } return nil }, } syncHooksCmd = &cobra.Command{ Use: "sync-hooks", Short: "Update repository hooks", PersistentPreRunE: cmd.InitBackendContext, PersistentPostRunE: cmd.CloseDBContext, RunE: func(c *cobra.Command, _ []string) error { ctx := c.Context() cfg := config.FromContext(ctx) be := backend.FromContext(ctx) if err := cmd.InitializeHooks(ctx, cfg, be); err != nil { return fmt.Errorf("initialize hooks: %w", err) } return nil }, } ) func init() { Command.AddCommand( syncHooksCmd, migrateCmd, rollbackCmd, ) } ================================================ FILE: cmd/soft/browse/browse.go ================================================ package browse import ( "fmt" "path/filepath" "time" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/footer" "github.com/charmbracelet/soft-serve/pkg/ui/pages/repo" "github.com/spf13/cobra" ) // Command is the browse command. var Command = &cobra.Command{ Use: "browse PATH", Short: "Browse a repository", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rp := "." if len(args) > 0 { rp = args[0] } abs, err := filepath.Abs(rp) if err != nil { return err } r, err := git.Open(abs) if err != nil { return fmt.Errorf("failed to open repository: %w", err) } // Bubble Tea uses Termenv default output so we have to use the same // thing here. ctx := cmd.Context() c := common.NewCommon(ctx, 0, 0) c.HideCloneCmd = true comps := []common.TabComponent{ repo.NewReadme(c), repo.NewFiles(c), repo.NewLog(c), } if !r.IsBare { comps = append(comps, repo.NewStash(c)) } comps = append(comps, repo.NewRefs(c, git.RefsHeads), repo.NewRefs(c, git.RefsTags)) m := &model{ model: repo.New(c, comps...), repo: repository{r}, common: c, } m.footer = footer.New(c, m) p := tea.NewProgram(m) _, err = p.Run() return err }, } type state int const ( startState state = iota errorState ) type model struct { model *repo.Repo footer *footer.Footer repo proto.Repository common common.Common state state showFooter bool error error } var _ tea.Model = &model{} func (m *model) SetSize(w, h int) { m.common.SetSize(w, h) style := m.common.Styles.App wm := style.GetHorizontalFrameSize() hm := style.GetVerticalFrameSize() if m.showFooter { hm += m.footer.Height() } m.footer.SetSize(w-wm, h-hm) m.model.SetSize(w-wm, h-hm) } // ShortHelp implements help.KeyMap. func (m model) ShortHelp() []key.Binding { switch m.state { case errorState: return []key.Binding{ m.common.KeyMap.Back, m.common.KeyMap.Quit, m.common.KeyMap.Help, } default: return m.model.ShortHelp() } } // FullHelp implements help.KeyMap. func (m model) FullHelp() [][]key.Binding { switch m.state { case errorState: return [][]key.Binding{ { m.common.KeyMap.Back, }, { m.common.KeyMap.Quit, m.common.KeyMap.Help, }, } default: return m.model.FullHelp() } } // Init implements tea.Model. func (m *model) Init() tea.Cmd { return tea.Batch( m.model.Init(), m.footer.Init(), func() tea.Msg { return repo.RepoMsg(m.repo) }, repo.UpdateRefCmd(m.repo), ) } // Update implements tea.Model. func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.common.Logger.Debugf("msg received: %T", msg) cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.WindowSizeMsg: m.SetSize(msg.Width, msg.Height) case tea.KeyPressMsg: switch { case key.Matches(msg, m.common.KeyMap.Back) && m.error != nil: m.error = nil m.state = startState // Always show the footer on error. m.showFooter = m.footer.ShowAll() case key.Matches(msg, m.common.KeyMap.Help): cmds = append(cmds, footer.ToggleFooterCmd) case key.Matches(msg, m.common.KeyMap.Quit): // Stop bubblezone background workers. m.common.Zone.Close() return m, tea.Quit } case tea.MouseClickMsg: mouse := msg.Mouse() switch mouse.Button { case tea.MouseLeft: switch { case m.common.Zone.Get("footer").InBounds(msg): cmds = append(cmds, footer.ToggleFooterCmd) } } case footer.ToggleFooterMsg: m.footer.SetShowAll(!m.footer.ShowAll()) m.showFooter = !m.showFooter case common.ErrorMsg: m.error = msg m.state = errorState m.showFooter = true } f, cmd := m.footer.Update(msg) m.footer = f.(*footer.Footer) if cmd != nil { cmds = append(cmds, cmd) } r, cmd := m.model.Update(msg) m.model = r.(*repo.Repo) if cmd != nil { cmds = append(cmds, cmd) } // This fixes determining the height margin of the footer. m.SetSize(m.common.Width, m.common.Height) return m, tea.Batch(cmds...) } // View implements tea.Model. func (m *model) View() tea.View { var v tea.View v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion style := m.common.Styles.App wm, hm := style.GetHorizontalFrameSize(), style.GetVerticalFrameSize() if m.showFooter { hm += m.footer.Height() } var view string switch m.state { case startState: view = m.model.View() case errorState: err := m.common.Styles.ErrorTitle.Render("Bummer") err += m.common.Styles.ErrorBody.Render(m.error.Error()) view = m.common.Styles.Error. Width(m.common.Width - wm - m.common.Styles.ErrorBody.GetHorizontalFrameSize()). Height(m.common.Height - hm - m.common.Styles.Error.GetVerticalFrameSize()). Render(err) } if m.showFooter { view = lipgloss.JoinVertical(lipgloss.Left, view, m.footer.View()) } v.Content = m.common.Zone.Scan(style.Render(view)) return v } type repository struct { r *git.Repository } var _ proto.Repository = repository{} // Description implements proto.Repository. func (r repository) Description() string { return "" } // ID implements proto.Repository. func (r repository) ID() int64 { return 0 } // IsHidden implements proto.Repository. func (repository) IsHidden() bool { return false } // IsMirror implements proto.Repository. func (repository) IsMirror() bool { return false } // IsPrivate implements proto.Repository. func (repository) IsPrivate() bool { return false } // Name implements proto.Repository. func (r repository) Name() string { return filepath.Base(r.r.Path) } // Open implements proto.Repository. func (r repository) Open() (*git.Repository, error) { return r.r, nil } // ProjectName implements proto.Repository. func (r repository) ProjectName() string { return r.Name() } // UpdatedAt implements proto.Repository. func (r repository) UpdatedAt() time.Time { t, err := r.r.LatestCommitTime() if err != nil { return time.Time{} } return t } // UserID implements proto.Repository. func (r repository) UserID() int64 { return 0 } // CreatedAt implements proto.Repository. func (r repository) CreatedAt() time.Time { return time.Time{} } ================================================ FILE: cmd/soft/hook/hook.go ================================================ package hook import ( "bufio" "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/cmd" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/hooks" "github.com/spf13/cobra" ) var ( // ErrInternalServerError indicates that an internal server error occurred. ErrInternalServerError = errors.New("internal server error") // Deprecated: this flag is ignored. configPath string // Command is the hook command. Command = &cobra.Command{ Use: "hook", Short: "Run git server hooks", Long: "Handles Soft Serve git server hooks.", Hidden: true, PersistentPreRunE: func(c *cobra.Command, args []string) error { logger := log.FromContext(c.Context()) if err := cmd.InitBackendContext(c, args); err != nil { logger.Error("failed to initialize backend context", "err", err) return ErrInternalServerError } return nil }, PersistentPostRunE: func(c *cobra.Command, args []string) error { logger := log.FromContext(c.Context()) if err := cmd.CloseDBContext(c, args); err != nil { logger.Error("failed to close backend", "err", err) return ErrInternalServerError } return nil }, } // Git hooks read the config from the environment, based on // $SOFT_SERVE_DATA_PATH. We already parse the config when the binary // starts, so we don't need to do it again. // The --config flag is now deprecated. hooksRunE = func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() hks := backend.FromContext(ctx) cfg := config.FromContext(ctx) // This is set in the server before invoking git-receive-pack/git-upload-pack repoName := os.Getenv("SOFT_SERVE_REPO_NAME") logger := log.FromContext(ctx).With("repo", repoName) stdin := cmd.InOrStdin() stdout := cmd.OutOrStdout() stderr := cmd.ErrOrStderr() cmdName := cmd.Name() customHookPath := filepath.Join(cfg.DataPath, "hooks", cmdName) var buf bytes.Buffer opts := make([]hooks.HookArg, 0) switch cmdName { case hooks.PreReceiveHook, hooks.PostReceiveHook: scanner := bufio.NewScanner(stdin) for scanner.Scan() { buf.Write(scanner.Bytes()) buf.WriteByte('\n') fields := strings.Fields(scanner.Text()) if len(fields) != 3 { logger.Error(fmt.Sprintf("invalid %s hook input", cmdName), "input", scanner.Text()) continue } opts = append(opts, hooks.HookArg{ OldSha: fields[0], NewSha: fields[1], RefName: fields[2], }) } switch cmdName { case hooks.PreReceiveHook: hks.PreReceive(ctx, stdout, stderr, repoName, opts) case hooks.PostReceiveHook: hks.PostReceive(ctx, stdout, stderr, repoName, opts) } case hooks.UpdateHook: if len(args) != 3 { logger.Error("invalid update hook input", "input", args) break } hks.Update(ctx, stdout, stderr, repoName, hooks.HookArg{ RefName: args[0], OldSha: args[1], NewSha: args[2], }) case hooks.PostUpdateHook: hks.PostUpdate(ctx, stdout, stderr, repoName, args...) } // Custom hooks if stat, err := os.Stat(customHookPath); err == nil && !stat.IsDir() && stat.Mode()&0o111 != 0 { // If the custom hook is executable, run it if err := runCommand(ctx, &buf, stdout, stderr, customHookPath, args...); err != nil { logger.Error("failed to run custom hook", "err", err) } } return nil } preReceiveCmd = &cobra.Command{ Use: "pre-receive", Short: "Run git pre-receive hook", RunE: hooksRunE, } updateCmd = &cobra.Command{ Use: "update", Short: "Run git update hook", Args: cobra.ExactArgs(3), RunE: hooksRunE, } postReceiveCmd = &cobra.Command{ Use: "post-receive", Short: "Run git post-receive hook", RunE: hooksRunE, } postUpdateCmd = &cobra.Command{ Use: "post-update", Short: "Run git post-update hook", RunE: hooksRunE, } ) func init() { Command.PersistentFlags().StringVar(&configPath, "config", "", "path to config file (deprecated)") Command.AddCommand( preReceiveCmd, updateCmd, postReceiveCmd, postUpdateCmd, ) } func runCommand(ctx context.Context, in io.Reader, out io.Writer, err io.Writer, name string, args ...string) error { cmd := exec.CommandContext(ctx, name, args...) cmd.Stdin = in cmd.Stdout = out cmd.Stderr = err return cmd.Run() } ================================================ FILE: cmd/soft/main.go ================================================ package main import ( "context" "fmt" "os" "runtime/debug" "strconv" "charm.land/log/v2" "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/soft-serve/cmd/soft/admin" "github.com/charmbracelet/soft-serve/cmd/soft/browse" "github.com/charmbracelet/soft-serve/cmd/soft/hook" "github.com/charmbracelet/soft-serve/cmd/soft/serve" "github.com/charmbracelet/soft-serve/pkg/config" logr "github.com/charmbracelet/soft-serve/pkg/log" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/version" mcobra "github.com/muesli/mango-cobra" "github.com/muesli/roff" "github.com/spf13/cobra" "go.uber.org/automaxprocs/maxprocs" ) var ( // Version contains the application version number. It's set via ldflags // when building. Version = "" // CommitSHA contains the SHA of the commit that this application was built // against. It's set via ldflags when building. CommitSHA = "" // CommitDate contains the date of the commit that this application was // built against. It's set via ldflags when building. CommitDate = "" rootCmd = &cobra.Command{ Use: "soft", Short: "A self-hostable Git server for the command line", Long: "Soft Serve is a self-hostable Git server for the command line.", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { return browse.Command.RunE(cmd, args) }, } manCmd = &cobra.Command{ Use: "man", Short: "Generate man pages", Args: cobra.NoArgs, Hidden: true, RunE: func(_ *cobra.Command, _ []string) error { manPage, err := mcobra.NewManPage(1, rootCmd) //. if err != nil { return err } manPage = manPage.WithSection("Copyright", "(C) 2021-2023 Charmbracelet, Inc.\n"+ "Released under MIT license.") fmt.Println(manPage.Build(roff.NewDocument())) return nil }, } ) func init() { if noColor, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_NO_COLOR")); noColor { common.DefaultColorProfile = colorprofile.NoTTY } rootCmd.AddCommand( manCmd, serve.Command, hook.Command, admin.Command, browse.Command, ) rootCmd.CompletionOptions.HiddenDefaultCmd = true if len(CommitSHA) >= 7 { vt := rootCmd.VersionTemplate() rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") } if Version == "" { if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" { Version = info.Main.Version } else { Version = "unknown (built from source)" } } rootCmd.Version = Version version.Version = Version version.CommitSHA = CommitSHA version.CommitDate = CommitDate } func main() { ctx := context.Background() cfg := config.DefaultConfig() if cfg.Exist() { if err := cfg.Parse(); err != nil { log.Fatal(err) } } if err := cfg.ParseEnv(); err != nil { log.Fatal(err) } ctx = config.WithContext(ctx, cfg) logger, f, err := logr.NewLogger(cfg) if err != nil { log.Errorf("failed to create logger: %v", err) } ctx = log.WithContext(ctx, logger) if f != nil { defer f.Close() //nolint: errcheck } // Set global logger log.SetDefault(logger) var opts []maxprocs.Option if config.IsVerbose() { opts = append(opts, maxprocs.Logger(log.Debugf)) } // Set the max number of processes to the number of CPUs // This is useful when running soft serve in a container if _, err := maxprocs.Set(opts...); err != nil { log.Warn("couldn't set automaxprocs", "error", err) } if err := rootCmd.ExecuteContext(ctx); err != nil { os.Exit(1) } } ================================================ FILE: cmd/soft/serve/certreloader.go ================================================ package serve import ( "crypto/tls" "sync" "charm.land/log/v2" ) // CertReloader is responsible for reloading TLS certificates when a SIGHUP signal is received. type CertReloader struct { certMu sync.RWMutex cert *tls.Certificate certPath string keyPath string } // NewCertReloader creates a new CertReloader that watches for SIGHUP signals. func NewCertReloader(certPath, keyPath string, logger *log.Logger) (*CertReloader, error) { reloader := &CertReloader{ certPath: certPath, keyPath: keyPath, } cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { return nil, err } reloader.cert = &cert return reloader, nil } // Reload attempts to reload the certificate and key. func (cr *CertReloader) Reload() error { newCert, err := tls.LoadX509KeyPair(cr.certPath, cr.keyPath) if err != nil { return err } cr.certMu.Lock() defer cr.certMu.Unlock() cr.cert = &newCert return nil } // GetCertificateFunc returns a function that can be used with tls.Config.GetCertificate. func (cr *CertReloader) GetCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { cr.certMu.RLock() defer cr.certMu.RUnlock() return cr.cert, nil } } ================================================ FILE: cmd/soft/serve/certreloader_test.go ================================================ //go:build unix package serve import ( "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "os" "os/signal" "path/filepath" "syscall" "testing" "time" "charm.land/log/v2" ) func generateTestCert(t *testing.T, certPath, keyPath, cn string) { t.Helper() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } template := x509.Certificate{ SerialNumber: nil, Subject: pkix.Name{ CommonName: cn, }, NotBefore: time.Now(), NotAfter: time.Now().Add(time.Hour), } certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) if err != nil { t.Fatal(err) } certFile, err := os.Create(certPath) if err != nil { t.Fatal(err) } defer certFile.Close() pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) keyFile, err := os.Create(keyPath) if err != nil { t.Fatal(err) } defer keyFile.Close() pem.Encode(keyFile, &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey), }) } func TestCertReloader(t *testing.T) { dir := t.TempDir() certPath := filepath.Join(dir, "/cert.pem") keyPath := filepath.Join(dir, "/key.pem") // Initial cert generateTestCert(t, certPath, keyPath, "cert-v1") logger := log.New(os.Stderr) certReloader, err := NewCertReloader(certPath, keyPath, logger) if err != nil { t.Fatalf("failed to create reloader: %v", err) } go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGHUP) for range sigCh { if err := certReloader.Reload(); err != nil { logger.Error("failed to reload certificate", "err", err) } else { logger.Info("certificate reloaded successfully") } } }() getCert := certReloader.GetCertificateFunc() cert1, err := getCert(nil) if err != nil { t.Fatal(err) } // Replace cert on disk generateTestCert(t, certPath, keyPath, "cert-v2") // Trigger reload if err := syscall.Kill(os.Getpid(), syscall.SIGHUP); err != nil { t.Fatalf("failed to send SIGHUP: %v", err) } // Allow async goroutine to reload time.Sleep(100 * time.Millisecond) cert2, err := getCert(nil) if err != nil { t.Fatal(err) } if cert1 == cert2 { t.Fatal("certificate was not reloaded after SIGHUP") } } ================================================ FILE: cmd/soft/serve/serve.go ================================================ package serve import ( "context" "fmt" "net/http" "os" "os/signal" "path/filepath" "strconv" "sync" "syscall" "time" "github.com/charmbracelet/soft-serve/cmd" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/migrate" "github.com/spf13/cobra" ) var ( syncHooks bool // Command is the serve command. Command = &cobra.Command{ Use: "serve", Short: "Start the server", Args: cobra.NoArgs, PersistentPreRunE: cmd.InitBackendContext, PersistentPostRunE: cmd.CloseDBContext, RunE: func(c *cobra.Command, _ []string) error { ctx := c.Context() cfg := config.DefaultConfig() if cfg.Exist() { if err := cfg.ParseFile(); err != nil { return fmt.Errorf("parse config file: %w", err) } } else { if err := cfg.WriteConfig(); err != nil { return fmt.Errorf("write config file: %w", err) } } if err := cfg.ParseEnv(); err != nil { return fmt.Errorf("parse environment variables: %w", err) } // Create custom hooks directory if it doesn't exist customHooksPath := filepath.Join(cfg.DataPath, "hooks") if _, err := os.Stat(customHooksPath); err != nil && os.IsNotExist(err) { os.MkdirAll(customHooksPath, os.ModePerm) //nolint: errcheck // Generate update hook example without executable permissions hookPath := filepath.Join(customHooksPath, "update.sample") //nolint: gosec if err := os.WriteFile(hookPath, []byte(updateHookExample), 0o744); err != nil { return fmt.Errorf("failed to generate update hook example: %w", err) } } // Create log directory if it doesn't exist logPath := filepath.Join(cfg.DataPath, "log") if _, err := os.Stat(logPath); err != nil && os.IsNotExist(err) { os.MkdirAll(logPath, os.ModePerm) //nolint: errcheck } db := db.FromContext(ctx) if err := migrate.Migrate(ctx, db); err != nil { return fmt.Errorf("migration error: %w", err) } s, err := NewServer(ctx) if err != nil { return fmt.Errorf("start server: %w", err) } if syncHooks { be := backend.FromContext(ctx) if err := cmd.InitializeHooks(ctx, cfg, be); err != nil { return fmt.Errorf("initialize hooks: %w", err) } } lch := make(chan error, 1) done := make(chan os.Signal, 1) doneOnce := sync.OnceFunc(func() { close(done) }) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) // This endpoint is added for testing purposes // It allows us to stop the server from the test suite. // This is needed since Windows doesn't support signals. if testRun, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_TESTRUN")); testRun { h := s.HTTPServer.Server.Handler s.HTTPServer.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/__stop" && r.Method == http.MethodHead { doneOnce() return } h.ServeHTTP(w, r) }) } go func() { lch <- s.Start() doneOnce() }() for { select { case err := <-lch: if err != nil { return fmt.Errorf("server error: %w", err) } case sig := <-done: if sig == syscall.SIGHUP { s.logger.Info("received SIGHUP signal, reloading TLS certificates if enabled") if err := s.ReloadCertificates(); err != nil { s.logger.Error("failed to reload TLS certificates", "err", err) } continue } } break } ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := s.Shutdown(ctx); err != nil { return err } return nil }, } ) func init() { Command.Flags().BoolVarP(&syncHooks, "sync-hooks", "", false, "synchronize hooks for all repositories before running the server") } const updateHookExample = `#!/bin/sh # # An example hook script to echo information about the push # and send it to the client. # # To enable this hook, rename this file to "update" and make it executable. refname="$1" oldrev="$2" newrev="$3" # Safety check if [ -z "$GIT_DIR" ]; then echo "Don't run this script from the command line." >&2 echo " (if you want, you could supply GIT_DIR then run" >&2 echo " $0 )" >&2 exit 1 fi if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then echo "usage: $0 " >&2 exit 1 fi # Check types # if $newrev is 0000...0000, it's a commit to delete a ref. zero=$(git hash-object --stdin **Warning** > > Make sure to run the image without a TTY, i.e.: do not use the `--tty`/`-t` > flags. *** Part of [Charm](https://charm.sh). The Charm logo Charm热爱开源 • Charm loves open source ================================================ FILE: git/attr.go ================================================ package git import ( "math/rand" "os" "path/filepath" "strconv" "strings" "time" ) // Attribute represents a Git attribute. type Attribute struct { Name string Value string } // CheckAttributes checks the attributes of the given ref and path. func (r *Repository) CheckAttributes(ref *Reference, path string) ([]Attribute, error) { rnd := rand.NewSource(time.Now().UnixNano()) fn := "soft-serve-index-" + strconv.Itoa(rand.New(rnd).Int()) //nolint: gosec tmpindex := filepath.Join(os.TempDir(), fn) defer os.Remove(tmpindex) //nolint: errcheck readTree := NewCommand("read-tree", "--reset", "-i", ref.Name().String()). AddEnvs("GIT_INDEX_FILE=" + tmpindex) if _, err := readTree.RunInDir(r.Path); err != nil { return nil, err } checkAttr := NewCommand("check-attr", "--cached", "-a", "--", path). AddEnvs("GIT_INDEX_FILE=" + tmpindex) out, err := checkAttr.RunInDir(r.Path) if err != nil { return nil, err } return parseAttributes(path, out), nil } func parseAttributes(path string, buf []byte) []Attribute { attrs := make([]Attribute, 0) for _, line := range strings.Split(string(buf), "\n") { if line == "" { continue } line = strings.TrimPrefix(line, path+": ") parts := strings.SplitN(line, ": ", 2) if len(parts) != 2 { continue } attrs = append(attrs, Attribute{ Name: parts[0], Value: parts[1], }) } return attrs } ================================================ FILE: git/attr_test.go ================================================ package git import ( "testing" "github.com/matryer/is" ) func TestParseAttr(t *testing.T) { cases := []struct { in string file string want []Attribute }{ { in: "org/example/MyClass.java: diff: java\n", file: "org/example/MyClass.java", want: []Attribute{ { Name: "diff", Value: "java", }, }, }, { in: `org/example/MyClass.java: crlf: unset org/example/MyClass.java: diff: java org/example/MyClass.java: myAttr: set`, file: "org/example/MyClass.java", want: []Attribute{ { Name: "crlf", Value: "unset", }, { Name: "diff", Value: "java", }, { Name: "myAttr", Value: "set", }, }, }, { in: `org/example/MyClass.java: diff: java org/example/MyClass.java: myAttr: set`, file: "org/example/MyClass.java", want: []Attribute{ { Name: "diff", Value: "java", }, { Name: "myAttr", Value: "set", }, }, }, { in: `README: caveat: unspecified`, file: "README", want: []Attribute{ { Name: "caveat", Value: "unspecified", }, }, }, { in: "", file: "foo", want: []Attribute{}, }, { in: "\n", file: "foo", want: []Attribute{}, }, } is := is.New(t) for _, c := range cases { attrs := parseAttributes(c.file, []byte(c.in)) if len(attrs) != len(c.want) { t.Fatalf("parseAttributes(%q, %q) = %v, want %v", c.file, c.in, attrs, c.want) } is.Equal(attrs, c.want) } } ================================================ FILE: git/command.go ================================================ package git import "github.com/aymanbagabas/git-module" // RunInDirOptions are options for RunInDir. type RunInDirOptions = git.RunInDirOptions // NewCommand creates a new git command. func NewCommand(args ...string) *git.Command { return git.NewCommand(args...) } ================================================ FILE: git/commit.go ================================================ package git import ( "regexp" "github.com/aymanbagabas/git-module" ) // ZeroID is the zero hash. const ZeroID = git.EmptyID // IsZeroHash returns whether the hash is a zero hash. func IsZeroHash(h string) bool { pattern := regexp.MustCompile(`^0{40,}$`) return pattern.MatchString(h) } // Commit is a wrapper around git.Commit with helper methods. type Commit = git.Commit // Commits is a list of commits. type Commits []*Commit // Len implements sort.Interface. func (cl Commits) Len() int { return len(cl) } // Swap implements sort.Interface. func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } // Less implements sort.Interface. func (cl Commits) Less(i, j int) bool { return cl[i].Author.When.After(cl[j].Author.When) } ================================================ FILE: git/config.go ================================================ package git import ( "os" "path/filepath" gcfg "github.com/go-git/go-git/v5/plumbing/format/config" ) // Config returns the repository Git configuration. func (r *Repository) Config() (*gcfg.Config, error) { cp := filepath.Join(r.Path, "config") f, err := os.Open(cp) if err != nil { return nil, err } defer f.Close() //nolint: errcheck d := gcfg.NewDecoder(f) cfg := gcfg.New() if err := d.Decode(cfg); err != nil { return nil, err } return cfg, nil } // SetConfig sets the repository Git configuration. func (r *Repository) SetConfig(cfg *gcfg.Config) error { cp := filepath.Join(r.Path, "config") f, err := os.Create(cp) if err != nil { return err } defer f.Close() //nolint: errcheck e := gcfg.NewEncoder(f) return e.Encode(cfg) } ================================================ FILE: git/errors.go ================================================ package git import ( "errors" "github.com/aymanbagabas/git-module" ) var ( // ErrFileNotFound is returned when a file is not found. ErrFileNotFound = errors.New("file not found") // ErrDirectoryNotFound is returned when a directory is not found. ErrDirectoryNotFound = errors.New("directory not found") // ErrReferenceNotExist is returned when a reference does not exist. ErrReferenceNotExist = git.ErrReferenceNotExist // ErrRevisionNotExist is returned when a revision is not found. ErrRevisionNotExist = git.ErrRevisionNotExist // ErrNotAGitRepository is returned when the given path is not a Git repository. ErrNotAGitRepository = errors.New("not a git repository") ) ================================================ FILE: git/patch.go ================================================ package git import ( "bytes" "fmt" "math" "strings" "sync" "github.com/aymanbagabas/git-module" "github.com/dustin/go-humanize/english" "github.com/sergi/go-diff/diffmatchpatch" ) // DiffSection is a wrapper to git.DiffSection with helper methods. type DiffSection struct { *git.DiffSection initOnce sync.Once dmp *diffmatchpatch.DiffMatchPatch } // diffFor computes inline diff for the given line. func (s *DiffSection) diffFor(line *git.DiffLine) string { fallback := line.Content // Find equivalent diff line, ignore when not found. var diff1, diff2 string switch line.Type { case git.DiffLineAdd: compareLine := s.Line(git.DiffLineDelete, line.RightLine) if compareLine == nil { return fallback } diff1 = compareLine.Content diff2 = line.Content case git.DiffLineDelete: compareLine := s.Line(git.DiffLineAdd, line.LeftLine) if compareLine == nil { return fallback } diff1 = line.Content diff2 = compareLine.Content default: return fallback } s.initOnce.Do(func() { s.dmp = diffmatchpatch.New() s.dmp.DiffEditCost = 100 }) diffs := s.dmp.DiffMain(diff1[1:], diff2[1:], true) diffs = s.dmp.DiffCleanupEfficiency(diffs) return diffsToString(diffs, line.Type) } func diffsToString(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) string { buf := bytes.NewBuffer(nil) // Reproduce signs which are cutted for inline diff before. switch lineType { case git.DiffLineAdd: buf.WriteByte('+') case git.DiffLineDelete: buf.WriteByte('-') } for i := range diffs { switch { case diffs[i].Type == diffmatchpatch.DiffInsert && lineType == git.DiffLineAdd: buf.WriteString(diffs[i].Text) case diffs[i].Type == diffmatchpatch.DiffDelete && lineType == git.DiffLineDelete: buf.WriteString(diffs[i].Text) case diffs[i].Type == diffmatchpatch.DiffEqual: buf.WriteString(diffs[i].Text) } } return buf.String() } // DiffFile is a wrapper to git.DiffFile with helper methods. type DiffFile struct { *git.DiffFile Sections []*DiffSection } // DiffFileChange represents a file diff. type DiffFileChange struct { hash string name string mode git.EntryMode } // Hash returns the diff file hash. func (f *DiffFileChange) Hash() string { return f.hash } // Name returns the diff name. func (f *DiffFileChange) Name() string { return f.name } // Mode returns the diff file mode. func (f *DiffFileChange) Mode() git.EntryMode { return f.mode } // Files returns the diff files. func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) { if f.OldIndex != ZeroID { from = &DiffFileChange{ hash: f.OldIndex, name: f.OldName(), mode: f.OldMode(), } } if f.Index != ZeroID { to = &DiffFileChange{ hash: f.Index, name: f.Name, mode: f.Mode(), } } return } // FileStats type FileStats []*DiffFile // String returns a string representation of file stats. func (fs FileStats) String() string { return printStats(fs) } func printStats(stats FileStats) string { padLength := float64(len(" ")) newlineLength := float64(len("\n")) separatorLength := float64(len("|")) // Soft line length limit. The text length calculation below excludes // length of the change number. Adding that would take it closer to 80, // but probably not more than 80, until it's a huge number. lineLength := 72.0 // Get the longest filename and longest total change. var longestLength float64 var longestTotalChange float64 for _, fs := range stats { if int(longestLength) < len(fs.Name) { longestLength = float64(len(fs.Name)) } totalChange := fs.NumAdditions() + fs.NumDeletions() if int(longestTotalChange) < totalChange { longestTotalChange = float64(totalChange) } } // Parts of the output: // |<+++/---> // example: " main.go | 10 +++++++--- " // leftTextLength := padLength + longestLength + padLength // <+++++/-----> // Excluding number length here. rightTextLength := padLength + padLength + newlineLength totalTextArea := leftTextLength + separatorLength + rightTextLength heightOfHistogram := lineLength - totalTextArea // Scale the histogram. var scaleFactor float64 if longestTotalChange > heightOfHistogram { // Scale down to heightOfHistogram. scaleFactor = longestTotalChange / heightOfHistogram } else { scaleFactor = 1.0 } taddc := 0 tdelc := 0 output := strings.Builder{} for _, fs := range stats { taddc += fs.NumAdditions() tdelc += fs.NumDeletions() addn := float64(fs.NumAdditions()) deln := float64(fs.NumDeletions()) addc := int(math.Floor(addn / scaleFactor)) delc := int(math.Floor(deln / scaleFactor)) if addc < 0 { addc = 0 } if delc < 0 { delc = 0 } adds := strings.Repeat("+", addc) dels := strings.Repeat("-", delc) diffLines := fmt.Sprint(fs.NumAdditions() + fs.NumDeletions()) totalDiffLines := fmt.Sprint(int(longestTotalChange)) fmt.Fprintf(&output, "%s | %s %s%s\n", fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)), strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines, adds, dels) } files := len(stats) fc := fmt.Sprintf("%s changed", english.Plural(files, "file", "")) ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", "")) dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", "")) fmt.Fprint(&output, fc) if taddc > 0 { fmt.Fprintf(&output, ", %s", ins) } if tdelc > 0 { fmt.Fprintf(&output, ", %s", dels) } fmt.Fprint(&output, "\n") return output.String() } // Diff is a wrapper around git.Diff with helper methods. type Diff struct { *git.Diff Files []*DiffFile } // FileStats returns the diff file stats. func (d *Diff) Stats() FileStats { return d.Files } const ( dstPrefix = "b/" srcPrefix = "a/" ) func appendPathLines(lines []string, fromPath, toPath string, isBinary bool) []string { if isBinary { return append(lines, fmt.Sprintf("Binary files %s and %s differ", fromPath, toPath), ) } return append(lines, fmt.Sprintf("--- %s", fromPath), fmt.Sprintf("+++ %s", toPath), ) } func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) { from, to := filePatch.Files() if from == nil && to == nil { return } isBinary := filePatch.IsBinary() var lines []string switch { case from != nil && to != nil: hashEquals := from.Hash() == to.Hash() lines = append(lines, fmt.Sprintf("diff --git %s%s %s%s", srcPrefix, from.Name(), dstPrefix, to.Name()), ) if from.Mode() != to.Mode() { lines = append(lines, fmt.Sprintf("old mode %o", from.Mode()), fmt.Sprintf("new mode %o", to.Mode()), ) } if from.Name() != to.Name() { lines = append(lines, fmt.Sprintf("rename from %s", from.Name()), fmt.Sprintf("rename to %s", to.Name()), ) } if from.Mode() != to.Mode() && !hashEquals { lines = append(lines, fmt.Sprintf("index %s..%s", from.Hash(), to.Hash()), ) } else if !hashEquals { lines = append(lines, fmt.Sprintf("index %s..%s %o", from.Hash(), to.Hash(), from.Mode()), ) } if !hashEquals { lines = appendPathLines(lines, srcPrefix+from.Name(), dstPrefix+to.Name(), isBinary) } case from == nil: lines = append(lines, fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()), fmt.Sprintf("new file mode %o", to.Mode()), fmt.Sprintf("index %s..%s", ZeroID, to.Hash()), ) lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary) case to == nil: lines = append(lines, fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()), fmt.Sprintf("deleted file mode %o", from.Mode()), fmt.Sprintf("index %s..%s", from.Hash(), ZeroID), ) lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary) } sb.WriteString(lines[0]) for _, line := range lines[1:] { sb.WriteByte('\n') sb.WriteString(line) } sb.WriteByte('\n') } // Patch returns the diff as a patch. func (d *Diff) Patch() string { var p strings.Builder for _, f := range d.Files { writeFilePatchHeader(&p, f) for _, s := range f.Sections { for _, l := range s.Lines { p.WriteString(s.diffFor(l)) p.WriteString("\n") } } } return p.String() } func toDiff(ddiff *git.Diff) *Diff { files := make([]*DiffFile, 0, len(ddiff.Files)) for _, df := range ddiff.Files { sections := make([]*DiffSection, 0, len(df.Sections)) for _, ds := range df.Sections { sections = append(sections, &DiffSection{ DiffSection: ds, }) } files = append(files, &DiffFile{ DiffFile: df, Sections: sections, }) } diff := &Diff{ Diff: ddiff, Files: files, } return diff } ================================================ FILE: git/reference.go ================================================ package git import ( "strings" "github.com/aymanbagabas/git-module" ) const ( // HEAD represents the name of the HEAD reference. HEAD = "HEAD" // RefsHeads represents the prefix for branch references. RefsHeads = git.RefsHeads // RefsTags represents the prefix for tag references. RefsTags = git.RefsTags ) // Reference is a wrapper around git.Reference with helper methods. type Reference struct { *git.Reference path string // repo path } // ReferenceName is a Refspec wrapper. type ReferenceName string // String returns the reference name i.e. refs/heads/master. func (r ReferenceName) String() string { return string(r) } // Short returns the short name of the reference i.e. master. func (r ReferenceName) Short() string { return git.RefShortName(string(r)) } // Name returns the reference name i.e. refs/heads/master. func (r *Reference) Name() ReferenceName { return ReferenceName(r.Refspec) } // IsBranch returns true if the reference is a branch. func (r *Reference) IsBranch() bool { return strings.HasPrefix(r.Refspec, git.RefsHeads) } // IsTag returns true if the reference is a tag. func (r *Reference) IsTag() bool { return strings.HasPrefix(r.Refspec, git.RefsTags) } ================================================ FILE: git/repo.go ================================================ package git import ( "path/filepath" "strings" "github.com/aymanbagabas/git-module" ) var ( // DiffMaxFile is the maximum number of files to show in a diff. DiffMaxFiles = 1000 // DiffMaxFileLines is the maximum number of lines to show in a file diff. DiffMaxFileLines = 1000 // DiffMaxLineChars is the maximum number of characters to show in a line diff. DiffMaxLineChars = 1000 ) // Repository is a wrapper around git.Repository with helper methods. type Repository struct { *git.Repository Path string IsBare bool } // Clone clones a repository. func Clone(src, dst string, opts ...git.CloneOptions) error { return git.Clone(src, dst, opts...) } // Init initializes and opens a new git repository. func Init(path string, bare bool) (*Repository, error) { if bare { path = strings.TrimSuffix(path, ".git") + ".git" } err := git.Init(path, git.InitOptions{Bare: bare}) if err != nil { return nil, err } return Open(path) } func gitDir(r *git.Repository) (string, error) { return r.RevParse("--git-dir") } // Open opens a git repository at the given path. func Open(path string) (*Repository, error) { repo, err := git.Open(path) if err != nil { return nil, err } gp, err := gitDir(repo) if err != nil || (gp != "." && gp != ".git") { return nil, ErrNotAGitRepository } return &Repository{ Repository: repo, Path: path, IsBare: gp == ".", }, nil } // HEAD returns the HEAD reference for a repository. func (r *Repository) HEAD() (*Reference, error) { rn, err := r.Repository.SymbolicRef(git.SymbolicRefOptions{Name: "HEAD"}) if err != nil { return nil, err } hash, err := r.ShowRefVerify(rn) if err != nil { return nil, err } return &Reference{ Reference: &git.Reference{ ID: hash, Refspec: rn, }, path: r.Path, }, nil } // References returns the references for a repository. func (r *Repository) References() ([]*Reference, error) { refs, err := r.ShowRef() if err != nil { return nil, err } rrefs := make([]*Reference, 0, len(refs)) for _, ref := range refs { rrefs = append(rrefs, &Reference{ Reference: ref, path: r.Path, }) } return rrefs, nil } // LsTree returns the tree for the given reference. func (r *Repository) LsTree(ref string) (*Tree, error) { tree, err := r.Repository.LsTree(ref) if err != nil { return nil, err } return &Tree{ Tree: tree, Path: "", Repository: r, }, nil } // Tree returns the tree for the given reference. func (r *Repository) Tree(ref *Reference) (*Tree, error) { if ref == nil { rref, err := r.HEAD() if err != nil { return nil, err } ref = rref } return r.LsTree(ref.ID) } // TreePath returns the tree for the given path. func (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) { path = filepath.Clean(path) if path == "." { path = "" } if path == "" { return r.Tree(ref) } t, err := r.Tree(ref) if err != nil { return nil, err } return t.SubTree(path) } // Diff returns the diff for the given commit. func (r *Repository) Diff(commit *Commit) (*Diff, error) { diff, err := r.Repository.Diff(commit.ID.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{ CommandOptions: git.CommandOptions{ Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"}, }, }) if err != nil { return nil, err } return toDiff(diff), nil } // Patch returns the patch for the given reference. func (r *Repository) Patch(commit *Commit) (string, error) { diff, err := r.Diff(commit) if err != nil { return "", err } return diff.Patch(), err } // CountCommits returns the number of commits in the repository. func (r *Repository) CountCommits(ref *Reference) (int64, error) { return r.RevListCount([]string{ref.Name().String()}) } // CommitsByPage returns the commits for a given page and size. func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, error) { cs, err := r.Repository.CommitsByPage(ref.Name().String(), page, size) if err != nil { return nil, err } commits := make(Commits, len(cs)) copy(commits, cs) return commits, nil } // SymbolicRef returns or updates the symbolic reference for the given name. // Both name and ref can be empty. func (r *Repository) SymbolicRef(name string, ref string, opts ...git.SymbolicRefOptions) (string, error) { var opt git.SymbolicRefOptions if len(opts) > 0 { opt = opts[0] } opt.Name = name opt.Ref = ref return r.Repository.SymbolicRef(opt) } ================================================ FILE: git/server.go ================================================ package git import ( "context" "github.com/aymanbagabas/git-module" ) // UpdateServerInfo updates the server info file for the given repo path. func UpdateServerInfo(ctx context.Context, path string) error { if !isGitDir(path) { return ErrNotAGitRepository } cmd := git.NewCommand("update-server-info").WithContext(ctx).WithTimeout(-1) _, err := cmd.RunInDir(path) return err } ================================================ FILE: git/stash.go ================================================ package git import "github.com/aymanbagabas/git-module" // StashDiff returns the diff of the given stash index. func (r *Repository) StashDiff(index int) (*Diff, error) { diff, err := r.Repository.StashDiff(index, DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{ CommandOptions: git.CommandOptions{ Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"}, }, }) if err != nil { return nil, err } return toDiff(diff), nil } ================================================ FILE: git/tag.go ================================================ package git import "github.com/aymanbagabas/git-module" // Tag is a git tag. type Tag = git.Tag ================================================ FILE: git/tree.go ================================================ package git import ( "bufio" "bytes" "io" "io/fs" "path/filepath" "sort" "github.com/aymanbagabas/git-module" ) // Tree is a wrapper around git.Tree with helper methods. type Tree struct { *git.Tree Path string Repository *Repository } // TreeEntry is a wrapper around git.TreeEntry with helper methods. type TreeEntry struct { *git.TreeEntry // path is the full path of the file path string } // Entries is a wrapper around git.Entries. type Entries []*TreeEntry var sorters = []func(t1, t2 *TreeEntry) bool{ func(t1, t2 *TreeEntry) bool { return (t1.IsTree() || t1.IsCommit()) && !t2.IsTree() && !t2.IsCommit() }, func(t1, t2 *TreeEntry) bool { return t1.Name() < t2.Name() }, } // Len implements sort.Interface. func (es Entries) Len() int { return len(es) } // Swap implements sort.Interface. func (es Entries) Swap(i, j int) { es[i], es[j] = es[j], es[i] } // Less implements sort.Interface. func (es Entries) Less(i, j int) bool { t1, t2 := es[i], es[j] var k int for k = 0; k < len(sorters)-1; k++ { sorter := sorters[k] switch { case sorter(t1, t2): return true case sorter(t2, t1): return false } } return sorters[k](t1, t2) } // Sort sorts the entries in the tree. func (es Entries) Sort() { sort.Sort(es) } // File is a wrapper around git.Blob with helper methods. type File struct { *git.Blob Entry *TreeEntry } // Name returns the name of the file. func (f *File) Name() string { return f.Entry.Name() } // Path returns the full path of the file. func (f *File) Path() string { return f.Entry.path } // SubTree returns the sub-tree at the given path. func (t *Tree) SubTree(path string) (*Tree, error) { tree, err := t.Subtree(path) if err != nil { return nil, err } return &Tree{ Tree: tree, Path: path, Repository: t.Repository, }, nil } // Entries returns the entries in the tree. func (t *Tree) Entries() (Entries, error) { entries, err := t.Tree.Entries() if err != nil { return nil, err } ret := make(Entries, len(entries)) for i, e := range entries { ret[i] = &TreeEntry{ TreeEntry: e, path: filepath.Join(t.Path, e.Name()), } } return ret, nil } // TreeEntry returns the TreeEntry for the file path. func (t *Tree) TreeEntry(path string) (*TreeEntry, error) { entry, err := t.Tree.TreeEntry(path) if err != nil { return nil, err } return &TreeEntry{ TreeEntry: entry, path: filepath.Join(t.Path, entry.Name()), }, nil } const sniffLen = 8000 // IsBinary detects if data is a binary value based on: // http://git.kernel.org/cgit/git/git.git/tree/xdiff-interface.c?id=HEAD#n198 func IsBinary(r io.Reader) (bool, error) { reader := bufio.NewReader(r) c := 0 for c < sniffLen { b, err := reader.ReadByte() if err == io.EOF { break } if err != nil { return false, err } if b == byte(0) { return true, nil } c++ } return false, nil } // IsBinary returns true if the file is binary. func (f *File) IsBinary() (bool, error) { stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) err := f.Pipeline(stdout, stderr) if err != nil { return false, err } r := bufio.NewReader(stdout) return IsBinary(r) } // Mode returns the mode of the file in fs.FileMode format. func (e *TreeEntry) Mode() fs.FileMode { m := e.Blob().Mode() switch m { case git.EntryTree: return fs.ModeDir | fs.ModePerm default: return fs.FileMode(m) //nolint:gosec } } // File returns the file for the TreeEntry. func (e *TreeEntry) File() *File { b := e.Blob() return &File{ Blob: b, Entry: e, } } // Contents returns the contents of the file. func (e *TreeEntry) Contents() ([]byte, error) { return e.File().Contents() } // Contents returns the contents of the file. func (f *File) Contents() ([]byte, error) { return f.Blob.Bytes() } ================================================ FILE: git/types.go ================================================ package git import "github.com/aymanbagabas/git-module" // CommandOptions contain options for running a git command. type CommandOptions = git.CommandOptions // CloneOptions contain options for cloning a repository. type CloneOptions = git.CloneOptions ================================================ FILE: git/utils.go ================================================ package git import ( "os" "path/filepath" "github.com/gobwas/glob" ) // LatestFile returns the contents of the first file at the specified path pattern in the repository and its file path. func LatestFile(repo *Repository, ref *Reference, pattern string) (string, string, error) { g := glob.MustCompile(pattern) dir := filepath.Dir(pattern) if ref == nil { head, err := repo.HEAD() if err != nil { return "", "", err } ref = head } t, err := repo.TreePath(ref, dir) if err != nil { return "", "", err } ents, err := t.Entries() if err != nil { return "", "", err } for _, e := range ents { te := e fp := filepath.Join(dir, te.Name()) if te.IsTree() { continue } if g.Match(fp) { if te.IsSymlink() { bts, err := te.Contents() if err != nil { return "", "", err } fp = string(bts) te, err = t.TreeEntry(fp) if err != nil { return "", "", err } } bts, err := te.Contents() if err != nil { return "", "", err } return string(bts), fp, nil } } return "", "", ErrFileNotFound } // Returns true if path is a directory containing an `objects` directory and a // `HEAD` file. func isGitDir(path string) bool { stat, err := os.Stat(filepath.Join(path, "objects")) if err != nil { return false } if !stat.IsDir() { return false } stat, err = os.Stat(filepath.Join(path, "HEAD")) if err != nil { return false } if stat.IsDir() { return false } return true } ================================================ FILE: go.mod ================================================ module github.com/charmbracelet/soft-serve go 1.25.8 require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.2 charm.land/glamour/v2 v2.0.0 charm.land/lipgloss/v2 v2.0.2 charm.land/log/v2 v2.0.0 charm.land/wish/v2 v2.0.0 github.com/alecthomas/chroma/v2 v2.23.1 github.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53 github.com/caarlos0/duration v0.0.0-20240108180406-5d492514f3c7 github.com/caarlos0/env/v11 v11.4.0 github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20240708204110-bacbfdb68d92 github.com/charmbracelet/keygen v0.5.4 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/x/ansi v0.11.6 github.com/dustin/go-humanize v1.0.1 github.com/go-git/go-git/v5 v5.17.0 github.com/go-jose/go-jose/v3 v3.0.4 github.com/gobwas/glob v0.2.3 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-querystring v1.2.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.11.2 github.com/lrstanley/bubblezone/v2 v2.0.0 github.com/matryer/is v1.4.1 github.com/muesli/mango-cobra v1.3.0 github.com/muesli/reflow v0.3.0 github.com/muesli/roff v0.1.0 github.com/prometheus/client_golang v1.23.2 github.com/robfig/cron/v3 v3.0.1 github.com/rogpeppe/go-internal v1.14.1 github.com/sergi/go-diff v1.4.0 github.com/spf13/cobra v1.10.2 go.uber.org/automaxprocs v1.6.0 golang.org/x/crypto v0.49.0 golang.org/x/sync v0.20.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.46.1 ) require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect github.com/charmbracelet/x/conpty v0.1.1 // indirect github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) ================================================ FILE: go.sum ================================================ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= charm.land/wish/v2 v2.0.0 h1:0vryoDz6G1SdJNIWSkExy88dLAs7H/w0x9y/cay1vno= charm.land/wish/v2 v2.0.0/go.mod h1:B42DmuVdvQxz215H9aCsbrXVSuAInAqkHAnmwg0nKs8= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53 h1:KfKp+gVsQtuM9qb8Putvkx1jjAWqlvI1vdv5x9hdFoQ= github.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53/go.mod h1:d4gQ7/3/S2sPq4NnKdtAgUOVr6XtLpWFtxyVV5/+76U= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/caarlos0/duration v0.0.0-20240108180406-5d492514f3c7 h1:kJP/C2eL9DCKrCOlX6lPVmAUAb6U4u9xllgws1kP9ds= github.com/caarlos0/duration v0.0.0-20240108180406-5d492514f3c7/go.mod h1:mSkwb/eZEwOJJJ4tqAKiuhLIPe0e9+FKhlU0oMCpbf8= github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20240708204110-bacbfdb68d92 h1:KtQlsiHfY3K4AoIEh0yUE/wCLHteZ9EzV1hKmx+p7U8= github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20240708204110-bacbfdb68d92/go.mod h1:UrXUCm3xLQkq15fu7qlXHUMlrhdlXHoi13KH2Dfiits= github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM= github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY= github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0= github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA= github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 h1:YocNLcTBdEdvY3iDK6jfWXvEaM5OCKkjxPKoJRdB3Gg= github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= ================================================ FILE: pkg/access/access.go ================================================ package access import ( "encoding" "errors" ) // AccessLevel is the level of access allowed to a repo. type AccessLevel int //nolint: revive const ( // NoAccess does not allow access to the repo. NoAccess AccessLevel = iota // ReadOnlyAccess allows read-only access to the repo. ReadOnlyAccess // ReadWriteAccess allows read and write access to the repo. ReadWriteAccess // AdminAccess allows read, write, and admin access to the repo. AdminAccess ) // String returns the string representation of the access level. func (a AccessLevel) String() string { switch a { case NoAccess: return "no-access" case ReadOnlyAccess: return "read-only" case ReadWriteAccess: return "read-write" case AdminAccess: return "admin-access" default: return "unknown" } } // ParseAccessLevel parses an access level string. func ParseAccessLevel(s string) AccessLevel { switch s { case "no-access": return NoAccess case "read-only": return ReadOnlyAccess case "read-write": return ReadWriteAccess case "admin-access": return AdminAccess default: return AccessLevel(-1) } } var ( _ encoding.TextMarshaler = AccessLevel(0) _ encoding.TextUnmarshaler = (*AccessLevel)(nil) ) // ErrInvalidAccessLevel is returned when an invalid access level is provided. var ErrInvalidAccessLevel = errors.New("invalid access level") // UnmarshalText implements encoding.TextUnmarshaler. func (a *AccessLevel) UnmarshalText(text []byte) error { l := ParseAccessLevel(string(text)) if l < 0 { return ErrInvalidAccessLevel } *a = l return nil } // MarshalText implements encoding.TextMarshaler. func (a AccessLevel) MarshalText() (text []byte, err error) { return []byte(a.String()), nil } ================================================ FILE: pkg/access/access_test.go ================================================ package access import "testing" func TestParseAccessLevel(t *testing.T) { cases := []struct { in string out AccessLevel }{ {"", -1}, {"foo", -1}, {AdminAccess.String(), AdminAccess}, {ReadOnlyAccess.String(), ReadOnlyAccess}, {ReadWriteAccess.String(), ReadWriteAccess}, {NoAccess.String(), NoAccess}, } for _, c := range cases { out := ParseAccessLevel(c.in) if out != c.out { t.Errorf("ParseAccessLevel(%q) => %d, want %d", c.in, out, c.out) } } } ================================================ FILE: pkg/access/context.go ================================================ package access import "context" // ContextKey is the context key for the access level. var ContextKey = &struct{ string }{"access"} // FromContext returns the access level from the context. func FromContext(ctx context.Context) AccessLevel { if ac, ok := ctx.Value(ContextKey).(AccessLevel); ok { return ac } return -1 } // WithContext returns a new context with the access level. func WithContext(ctx context.Context, ac AccessLevel) context.Context { return context.WithValue(ctx, ContextKey, ac) } ================================================ FILE: pkg/access/context_test.go ================================================ package access import ( "context" "testing" ) func TestGoodFromContext(t *testing.T) { ctx := WithContext(context.TODO(), AdminAccess) if ac := FromContext(ctx); ac != AdminAccess { t.Errorf("FromContext(ctx) => %d, want %d", ac, AdminAccess) } } func TestBadFromContext(t *testing.T) { ctx := context.TODO() if ac := FromContext(ctx); ac != -1 { t.Errorf("FromContext(ctx) => %d, want %d", ac, -1) } } ================================================ FILE: pkg/backend/access_token.go ================================================ package backend import ( "context" "errors" "time" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/utils" ) // CreateAccessToken creates an access token for user. func (b *Backend) CreateAccessToken(ctx context.Context, user proto.User, name string, expiresAt time.Time) (string, error) { token := GenerateToken() tokenHash := HashToken(token) name = utils.Sanitize(name) if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { _, err := b.store.CreateAccessToken(ctx, tx, name, user.ID(), tokenHash, expiresAt) if err != nil { return db.WrapError(err) } return nil }); err != nil { return "", err } return token, nil } // DeleteAccessToken deletes an access token for a user. func (b *Backend) DeleteAccessToken(ctx context.Context, user proto.User, id int64) error { err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { _, err := b.store.GetAccessToken(ctx, tx, id) if err != nil { return db.WrapError(err) } if err := b.store.DeleteAccessTokenForUser(ctx, tx, user.ID(), id); err != nil { return db.WrapError(err) } return nil }) if err != nil { if errors.Is(err, db.ErrRecordNotFound) { return proto.ErrTokenNotFound } return err } return nil } // ListAccessTokens lists access tokens for a user. func (b *Backend) ListAccessTokens(ctx context.Context, user proto.User) ([]proto.AccessToken, error) { accessTokens, err := b.store.GetAccessTokensByUserID(ctx, b.db, user.ID()) if err != nil { return nil, db.WrapError(err) } var tokens []proto.AccessToken for _, t := range accessTokens { token := proto.AccessToken{ ID: t.ID, Name: t.Name, TokenHash: t.Token, UserID: t.UserID, CreatedAt: t.CreatedAt, } if t.ExpiresAt.Valid { token.ExpiresAt = t.ExpiresAt.Time } tokens = append(tokens, token) } return tokens, nil } ================================================ FILE: pkg/backend/auth.go ================================================ package backend import ( "crypto/rand" "crypto/sha256" "encoding/hex" "charm.land/log/v2" "golang.org/x/crypto/bcrypt" ) const saltySalt = "salty-soft-serve" // HashPassword hashes the password using bcrypt. func HashPassword(password string) (string, error) { crypt, err := bcrypt.GenerateFromPassword([]byte(password+saltySalt), bcrypt.DefaultCost) if err != nil { return "", err } return string(crypt), nil } // VerifyPassword verifies the password against the hash. func VerifyPassword(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+saltySalt)) return err == nil } // GenerateToken returns a random unique token. func GenerateToken() string { buf := make([]byte, 20) if _, err := rand.Read(buf); err != nil { log.Error("unable to generate access token") return "" } return "ss_" + hex.EncodeToString(buf) } // HashToken hashes the token using sha256. func HashToken(token string) string { sum := sha256.Sum256([]byte(token + saltySalt)) return hex.EncodeToString(sum[:]) } ================================================ FILE: pkg/backend/auth_test.go ================================================ package backend import "testing" func TestHashPassword(t *testing.T) { hash, err := HashPassword("password") if err != nil { t.Fatal(err) } if hash == "" { t.Fatal("hash is empty") } } func TestVerifyPassword(t *testing.T) { hash, err := HashPassword("password") if err != nil { t.Fatal(err) } if !VerifyPassword("password", hash) { t.Fatal("password did not verify") } } func TestGenerateToken(t *testing.T) { token := GenerateToken() if token == "" { t.Fatal("token is empty") } } func TestHashToken(t *testing.T) { token := GenerateToken() hash := HashToken(token) if hash == "" { t.Fatal("hash is empty") } } ================================================ FILE: pkg/backend/backend.go ================================================ package backend import ( "context" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/task" ) // Backend is the Soft Serve backend that handles users, repositories, and // server settings management and operations. type Backend struct { ctx context.Context cfg *config.Config db *db.DB store store.Store logger *log.Logger cache *cache manager *task.Manager } // New returns a new Soft Serve backend. func New(ctx context.Context, cfg *config.Config, db *db.DB, st store.Store) *Backend { logger := log.FromContext(ctx).WithPrefix("backend") b := &Backend{ ctx: ctx, cfg: cfg, db: db, store: st, logger: logger, manager: task.NewManager(ctx), } // TODO: implement a proper caching interface cache := newCache(b, 1000) b.cache = cache return b } ================================================ FILE: pkg/backend/cache.go ================================================ package backend import lru "github.com/hashicorp/golang-lru/v2" // TODO: implement a caching interface. type cache struct { b *Backend repos *lru.Cache[string, *repo] } func newCache(b *Backend, size int) *cache { if size <= 0 { size = 1 } c := &cache{b: b} cache, _ := lru.New[string, *repo](size) c.repos = cache return c } func (c *cache) Get(repo string) (*repo, bool) { return c.repos.Get(repo) } func (c *cache) Set(repo string, r *repo) { c.repos.Add(repo, r) } func (c *cache) Delete(repo string) { c.repos.Remove(repo) } func (c *cache) Len() int { return c.repos.Len() } ================================================ FILE: pkg/backend/collab.go ================================================ package backend import ( "context" "errors" "strings" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/charmbracelet/soft-serve/pkg/webhook" ) // AddCollaborator adds a collaborator to a repository. // // It implements backend.Backend. func (d *Backend) AddCollaborator(ctx context.Context, repo string, username string, level access.AccessLevel) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } repo = utils.SanitizeRepo(repo) r, err := d.Repository(ctx, repo) if err != nil { return err } if err := db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo, level) }), ); err != nil { if errors.Is(err, db.ErrDuplicateKey) { return proto.ErrCollaboratorExist } return err } wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventAdded) if err != nil { return err } return webhook.SendEvent(ctx, wh) } // Collaborators returns a list of collaborators for a repository. // // It implements backend.Backend. func (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, error) { repo = utils.SanitizeRepo(repo) var users []models.User if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error users, err = d.store.ListCollabsByRepoAsUsers(ctx, tx, repo) return err }); err != nil { return nil, db.WrapError(err) } var usernames []string for _, u := range users { usernames = append(usernames, u.Username) } return usernames, nil } // IsCollaborator returns the access level and true if the user is a collaborator of the repository. // // It implements backend.Backend. func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (access.AccessLevel, bool, error) { if username == "" { return -1, false, nil } repo = utils.SanitizeRepo(repo) var m models.Collab if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error m, err = d.store.GetCollabByUsernameAndRepo(ctx, tx, username, repo) return err }); err != nil { return -1, false, db.WrapError(err) } return m.AccessLevel, m.ID > 0, nil } // RemoveCollaborator removes a collaborator from a repository. // // It implements backend.Backend. func (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error { repo = utils.SanitizeRepo(repo) r, err := d.Repository(ctx, repo) if err != nil { return err } wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventRemoved) if err != nil { return err } if err := db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.RemoveCollabByUsernameAndRepo(ctx, tx, username, repo) }), ); err != nil { if errors.Is(err, db.ErrRecordNotFound) { return proto.ErrCollaboratorNotFound } return err } return webhook.SendEvent(ctx, wh) } ================================================ FILE: pkg/backend/context.go ================================================ package backend import "context" // ContextKey is the key for the backend in the context. var ContextKey = &struct{ string }{"backend"} // FromContext returns the backend from a context. func FromContext(ctx context.Context) *Backend { if b, ok := ctx.Value(ContextKey).(*Backend); ok { return b } return nil } // WithContext returns a new context with the backend attached. func WithContext(ctx context.Context, b *Backend) context.Context { return context.WithValue(ctx, ContextKey, b) } ================================================ FILE: pkg/backend/hooks.go ================================================ package backend import ( "context" "io" "os" "sync" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/hooks" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/charmbracelet/soft-serve/pkg/webhook" ) var _ hooks.Hooks = (*Backend)(nil) // PostReceive is called by the git post-receive hook. // // It implements Hooks. func (d *Backend) PostReceive(_ context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) { d.logger.Debug("post-receive hook called", "repo", repo, "args", args) } // PreReceive is called by the git pre-receive hook. // // It implements Hooks. func (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) { d.logger.Debug("pre-receive hook called", "repo", repo, "args", args) } // Update is called by the git update hook. // // It implements Hooks. func (d *Backend) Update(ctx context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) { d.logger.Debug("update hook called", "repo", repo, "arg", arg) // Find user var user proto.User if pubkey := os.Getenv("SOFT_SERVE_PUBLIC_KEY"); pubkey != "" { pk, _, err := sshutils.ParseAuthorizedKey(pubkey) if err != nil { d.logger.Error("error parsing public key", "err", err) return } user, err = d.UserByPublicKey(ctx, pk) if err != nil { d.logger.Error("error finding user from public key", "key", pubkey, "err", err) return } } else if username := os.Getenv("SOFT_SERVE_USERNAME"); username != "" { var err error user, err = d.User(ctx, username) if err != nil { d.logger.Error("error finding user from username", "username", username, "err", err) return } } else { d.logger.Error("error finding user") return } // Get repo r, err := d.Repository(ctx, repo) if err != nil { d.logger.Error("error finding repository", "repo", repo, "err", err) return } // TODO: run this async // This would probably need something like an RPC server to communicate with the hook process. if git.IsZeroHash(arg.OldSha) || git.IsZeroHash(arg.NewSha) { wh, err := webhook.NewBranchTagEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha) if err != nil { d.logger.Error("error creating branch_tag webhook", "err", err) } else if err := webhook.SendEvent(ctx, wh); err != nil { d.logger.Error("error sending branch_tag webhook", "err", err) } } wh, err := webhook.NewPushEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha) if err != nil { d.logger.Error("error creating push webhook", "err", err) } else if err := webhook.SendEvent(ctx, wh); err != nil { d.logger.Error("error sending push webhook", "err", err) } } // PostUpdate is called by the git post-update hook. // // It implements Hooks. func (d *Backend) PostUpdate(ctx context.Context, _ io.Writer, _ io.Writer, repo string, args ...string) { d.logger.Debug("post-update hook called", "repo", repo, "args", args) var wg sync.WaitGroup // Populate last-modified file. wg.Add(1) go func() { defer wg.Done() if err := populateLastModified(ctx, d, repo); err != nil { d.logger.Error("error populating last-modified", "repo", repo, "err", err) return } }() wg.Wait() } func populateLastModified(ctx context.Context, d *Backend, name string) error { var rr *repo _rr, err := d.Repository(ctx, name) if err != nil { return err } if r, ok := _rr.(*repo); ok { rr = r } else { return proto.ErrRepoNotFound } r, err := rr.Open() if err != nil { return err } c, err := r.LatestCommitTime() if err != nil { return err } return rr.writeLastModified(c) } ================================================ FILE: pkg/backend/lfs.go ================================================ package backend import ( "context" "errors" "io" "path" "path/filepath" "strconv" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/lfs" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/storage" "github.com/charmbracelet/soft-serve/pkg/store" ) // StoreRepoMissingLFSObjects stores missing LFS objects for a repository. func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx *db.DB, store store.Store, lfsClient lfs.Client) error { cfg := config.FromContext(ctx) repoID := strconv.FormatInt(repo.ID(), 10) lfsRoot := filepath.Join(cfg.DataPath, "lfs", repoID) // TODO: support S3 storage strg := storage.NewLocalStorage(lfsRoot) pointerChan := make(chan lfs.PointerBlob) errChan := make(chan error, 1) r, err := repo.Open() if err != nil { return err } go lfs.SearchPointerBlobs(ctx, r, pointerChan, errChan) download := func(pointers []lfs.Pointer) error { return lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error { if objectError != nil { return objectError } defer content.Close() //nolint: errcheck return dbx.TransactionContext(ctx, func(tx *db.Tx) error { if err := store.CreateLFSObject(ctx, tx, repo.ID(), p.Oid, p.Size); err != nil { return db.WrapError(err) } _, err := strg.Put(path.Join("objects", p.RelativePath()), content) return err }) }) } var batch []lfs.Pointer for pointer := range pointerChan { obj, err := store.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid) if err != nil && !errors.Is(err, db.ErrRecordNotFound) { return db.WrapError(err) } exist, err := strg.Exists(path.Join("objects", pointer.RelativePath())) if err != nil { return err } if exist && obj.ID == 0 { if err := store.CreateLFSObject(ctx, dbx, repo.ID(), pointer.Oid, pointer.Size); err != nil { return db.WrapError(err) } } else { batch = append(batch, pointer.Pointer) // Limit batch requests to 20 objects if len(batch) >= 20 { if err := download(batch); err != nil { return err } batch = nil } } } if err, ok := <-errChan; ok { return err } return nil } ================================================ FILE: pkg/backend/repo.go ================================================ package backend import ( "bufio" "context" "errors" "fmt" "io/fs" "os" "path" "path/filepath" "strconv" "strings" "time" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/hooks" "github.com/charmbracelet/soft-serve/pkg/lfs" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/storage" "github.com/charmbracelet/soft-serve/pkg/task" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/charmbracelet/soft-serve/pkg/webhook" ) func validateImportRemote(remote string) error { endpoint, err := lfs.NewEndpoint(remote) if err != nil || endpoint.Host == "" { return proto.ErrInvalidRemote } return nil } // CreateRepository creates a new repository. // // It implements backend.Backend. func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) { name = utils.SanitizeRepo(name) if err := utils.ValidateRepo(name); err != nil { return nil, err } rp := filepath.Join(d.repoPath(name)) var userID int64 if user != nil { userID = user.ID() } if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { if err := d.store.CreateRepo( ctx, tx, name, userID, opts.ProjectName, opts.Description, opts.Private, opts.Hidden, opts.Mirror, ); err != nil { return err } _, err := git.Init(rp, true) if err != nil { d.logger.Debug("failed to create repository", "err", err) return err } if err := os.WriteFile(filepath.Join(rp, "description"), []byte(opts.Description), fs.ModePerm); err != nil { d.logger.Error("failed to write description", "repo", name, "err", err) return err } if !opts.Private { if err := os.WriteFile(filepath.Join(rp, "git-daemon-export-ok"), []byte{}, fs.ModePerm); err != nil { d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err) return err } } return hooks.GenerateHooks(ctx, d.cfg, name) }); err != nil { d.logger.Debug("failed to create repository in database", "err", err) err = db.WrapError(err) if errors.Is(err, db.ErrDuplicateKey) { return nil, proto.ErrRepoExist } return nil, err } return d.Repository(ctx, name) } // ImportRepository imports a repository from remote. // XXX: This a expensive operation and should be run in a goroutine. func (d *Backend) ImportRepository(_ context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) { name = utils.SanitizeRepo(name) if err := utils.ValidateRepo(name); err != nil { return nil, err } remote = utils.Sanitize(remote) if err := validateImportRemote(remote); err != nil { return nil, err } rp := filepath.Join(d.repoPath(name)) tid := "import:" + name if d.manager.Exists(tid) { return nil, task.ErrAlreadyStarted } if _, err := os.Stat(rp); err == nil || os.IsExist(err) { return nil, proto.ErrRepoExist } done := make(chan error, 1) repoc := make(chan proto.Repository, 1) d.logger.Info("importing repository", "name", name, "remote", remote, "path", rp) d.manager.Add(tid, func(ctx context.Context) (err error) { ctx = proto.WithUserContext(ctx, user) copts := git.CloneOptions{ Bare: true, Mirror: opts.Mirror, Quiet: true, CommandOptions: git.CommandOptions{ Timeout: -1, Context: ctx, Envs: []string{ fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`, filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"), d.cfg.SSH.ClientKeyPath, ), }, }, } if err := git.Clone(remote, rp, copts); err != nil { d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp) // Cleanup the mess! if rerr := os.RemoveAll(rp); rerr != nil { err = errors.Join(err, rerr) } return err } r, err := d.CreateRepository(ctx, name, user, opts) if err != nil { d.logger.Error("failed to create repository", "err", err, "name", name) return err } defer func() { if err != nil { if rerr := d.DeleteRepository(ctx, name); rerr != nil { d.logger.Error("failed to delete repository", "err", rerr, "name", name) } } }() rr, err := r.Open() if err != nil { d.logger.Error("failed to open repository", "err", err, "path", rp) return err } repoc <- r rcfg, err := rr.Config() if err != nil { d.logger.Error("failed to get repository config", "err", err, "path", rp) return err } endpoint := remote if opts.LFSEndpoint != "" { endpoint = opts.LFSEndpoint } rcfg.Section("lfs").SetOption("url", endpoint) if err := rr.SetConfig(rcfg); err != nil { d.logger.Error("failed to set repository config", "err", err, "path", rp) return err } ep, err := lfs.NewEndpoint(endpoint) if err != nil { d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp) return err } client := lfs.NewClient(ep) if client == nil { d.logger.Warn("failed to create lfs client: unsupported endpoint", "endpoint", endpoint) return nil } if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil { d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp) return err } return nil }) go func() { d.logger.Info("running import", "name", name) d.manager.Run(tid, done) }() return <-repoc, <-done } // DeleteRepository deletes a repository. // // It implements backend.Backend. func (d *Backend) DeleteRepository(ctx context.Context, name string) error { name = utils.SanitizeRepo(name) rp := filepath.Join(d.repoPath(name)) user := proto.UserFromContext(ctx) r, err := d.Repository(ctx, name) if err != nil { return err } // We create the webhook event before deleting the repository so we can // send the event after deleting the repository. wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete) if err != nil { return err } if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { // Delete repo from cache defer d.cache.Delete(name) repom, dberr := d.store.GetRepoByName(ctx, tx, name) _, ferr := os.Stat(rp) if dberr != nil && ferr != nil { return proto.ErrRepoNotFound } // If the repo is not in the database but the directory exists, remove it if dberr != nil && ferr == nil { return os.RemoveAll(rp) } else if dberr != nil { return db.WrapError(dberr) } repoID := strconv.FormatInt(repom.ID, 10) strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs", repoID)) objs, err := d.store.GetLFSObjectsByName(ctx, tx, name) if err != nil { return db.WrapError(err) } for _, obj := range objs { p := lfs.Pointer{ Oid: obj.Oid, Size: obj.Size, } d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid) if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil { d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid) } } if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil { return db.WrapError(err) } return os.RemoveAll(rp) }); err != nil { if errors.Is(err, db.ErrRecordNotFound) { return proto.ErrRepoNotFound } return db.WrapError(err) } return webhook.SendEvent(ctx, wh) } // DeleteUserRepositories deletes all user repositories. func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error { if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { user, err := d.store.FindUserByUsername(ctx, tx, username) if err != nil { return err } repos, err := d.store.GetUserRepos(ctx, tx, user.ID) if err != nil { return err } for _, repo := range repos { if err := d.DeleteRepository(ctx, repo.Name); err != nil { return err } } return nil }); err != nil { return db.WrapError(err) } return nil } // RenameRepository renames a repository. // // It implements backend.Backend. func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error { oldName = utils.SanitizeRepo(oldName) if err := utils.ValidateRepo(oldName); err != nil { return err } newName = utils.SanitizeRepo(newName) if err := utils.ValidateRepo(newName); err != nil { return err } if oldName == newName { return nil } op := filepath.Join(d.repoPath(oldName)) np := filepath.Join(d.repoPath(newName)) if _, err := os.Stat(op); err != nil { return proto.ErrRepoNotFound } if _, err := os.Stat(np); err == nil { return proto.ErrRepoExist } if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { // Delete cache defer d.cache.Delete(oldName) if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil { return err } // Make sure the new repository parent directory exists. if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil { return err } return os.Rename(op, np) }); err != nil { return db.WrapError(err) } user := proto.UserFromContext(ctx) repo, err := d.Repository(ctx, newName) if err != nil { return err } wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename) if err != nil { return err } return webhook.SendEvent(ctx, wh) } // Repositories returns a list of repositories per page. // // It implements backend.Backend. func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) { repos := make([]proto.Repository, 0) if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { ms, err := d.store.GetAllRepos(ctx, tx) if err != nil { return err } for _, m := range ms { r := &repo{ name: m.Name, path: filepath.Join(d.repoPath(m.Name)), repo: m, } // Cache repositories d.cache.Set(m.Name, r) repos = append(repos, r) } return nil }); err != nil { return nil, db.WrapError(err) } return repos, nil } // Repository returns a repository by name. // // It implements backend.Backend. func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) { var m models.Repo name = utils.SanitizeRepo(name) if r, ok := d.cache.Get(name); ok && r != nil { return r, nil } rp := filepath.Join(d.repoPath(name)) if _, err := os.Stat(rp); err != nil { if !errors.Is(err, fs.ErrNotExist) { d.logger.Errorf("failed to stat repository path: %v", err) } return nil, proto.ErrRepoNotFound } if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error m, err = d.store.GetRepoByName(ctx, tx, name) return db.WrapError(err) }); err != nil { if errors.Is(err, db.ErrRecordNotFound) { return nil, proto.ErrRepoNotFound } return nil, db.WrapError(err) } r := &repo{ name: name, path: rp, repo: m, } // Add to cache d.cache.Set(name, r) return r, nil } // Description returns the description of a repository. // // It implements backend.Backend. func (d *Backend) Description(ctx context.Context, name string) (string, error) { name = utils.SanitizeRepo(name) var desc string if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name) return err }); err != nil { return "", db.WrapError(err) } return desc, nil } // IsMirror returns true if the repository is a mirror. // // It implements backend.Backend. func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) { name = utils.SanitizeRepo(name) var mirror bool if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name) return err }); err != nil { return false, db.WrapError(err) } return mirror, nil } // IsPrivate returns true if the repository is private. // // It implements backend.Backend. func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) { name = utils.SanitizeRepo(name) var private bool if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name) return err }); err != nil { return false, db.WrapError(err) } return private, nil } // IsHidden returns true if the repository is hidden. // // It implements backend.Backend. func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) { name = utils.SanitizeRepo(name) var hidden bool if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name) return err }); err != nil { return false, db.WrapError(err) } return hidden, nil } // ProjectName returns the project name of a repository. // // It implements backend.Backend. func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) { name = utils.SanitizeRepo(name) var pname string if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name) return err }); err != nil { return "", db.WrapError(err) } return pname, nil } // SetHidden sets the hidden flag of a repository. // // It implements backend.Backend. func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error { name = utils.SanitizeRepo(name) // Delete cache d.cache.Delete(name) return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden) })) } // SetDescription sets the description of a repository. // // It implements backend.Backend. func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error { name = utils.SanitizeRepo(name) desc = utils.Sanitize(desc) rp := filepath.Join(d.repoPath(name)) // Delete cache d.cache.Delete(name) return d.db.TransactionContext(ctx, func(tx *db.Tx) error { if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil { d.logger.Error("failed to write description", "repo", name, "err", err) return err } return d.store.SetRepoDescriptionByName(ctx, tx, name, desc) }) } // SetPrivate sets the private flag of a repository. // // It implements backend.Backend. func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error { name = utils.SanitizeRepo(name) rp := filepath.Join(d.repoPath(name)) // Delete cache d.cache.Delete(name) if err := db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { fp := filepath.Join(rp, "git-daemon-export-ok") if !private { if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil { d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err) return err } } else { if _, err := os.Stat(fp); err == nil { if err := os.Remove(fp); err != nil { d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err) return err } } } return d.store.SetRepoIsPrivateByName(ctx, tx, name, private) }), ); err != nil { return err } user := proto.UserFromContext(ctx) repo, err := d.Repository(ctx, name) if err != nil { return err } if repo.IsPrivate() != !private { wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange) if err != nil { return err } if err := webhook.SendEvent(ctx, wh); err != nil { return err } } return nil } // SetProjectName sets the project name of a repository. // // It implements backend.Backend. func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error { repo = utils.SanitizeRepo(repo) name = utils.Sanitize(name) // Delete cache d.cache.Delete(repo) return db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.SetRepoProjectNameByName(ctx, tx, repo, name) }), ) } // repoPath returns the path to a repository. func (d *Backend) repoPath(name string) string { name = utils.SanitizeRepo(name) rn := strings.ReplaceAll(name, "/", string(os.PathSeparator)) return filepath.Join(filepath.Join(d.cfg.DataPath, "repos"), rn+".git") } var _ proto.Repository = (*repo)(nil) // repo is a Git repository with metadata stored in a SQLite database. type repo struct { name string path string repo models.Repo } // ID returns the repository's ID. // // It implements proto.Repository. func (r *repo) ID() int64 { return r.repo.ID } // UserID returns the repository's owner's user ID. // If the repository is not owned by anyone, it returns 0. // // It implements proto.Repository. func (r *repo) UserID() int64 { if r.repo.UserID.Valid { return r.repo.UserID.Int64 } return 0 } // Description returns the repository's description. // // It implements backend.Repository. func (r *repo) Description() string { return r.repo.Description } // IsMirror returns whether the repository is a mirror. // // It implements backend.Repository. func (r *repo) IsMirror() bool { return r.repo.Mirror } // IsPrivate returns whether the repository is private. // // It implements backend.Repository. func (r *repo) IsPrivate() bool { return r.repo.Private } // Name returns the repository's name. // // It implements backend.Repository. func (r *repo) Name() string { return r.name } // Open opens the repository. // // It implements backend.Repository. func (r *repo) Open() (*git.Repository, error) { return git.Open(r.path) } // ProjectName returns the repository's project name. // // It implements backend.Repository. func (r *repo) ProjectName() string { return r.repo.ProjectName } // IsHidden returns whether the repository is hidden. // // It implements backend.Repository. func (r *repo) IsHidden() bool { return r.repo.Hidden } // CreatedAt returns the repository's creation time. func (r *repo) CreatedAt() time.Time { return r.repo.CreatedAt } // UpdatedAt returns the repository's last update time. func (r *repo) UpdatedAt() time.Time { // Try to read the last modified time from the info directory. if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil { if t, err := time.Parse(time.RFC3339, t); err == nil { return t } } rr, err := git.Open(r.path) if err == nil { t, err := rr.LatestCommitTime() if err == nil { return t } } return r.repo.UpdatedAt } func (r *repo) writeLastModified(t time.Time) error { fp := filepath.Join(r.path, "info", "last-modified") if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil { return err } return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) //nolint:gosec } func readOneline(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() //nolint: errcheck s := bufio.NewScanner(f) s.Scan() return s.Text(), s.Err() } ================================================ FILE: pkg/backend/settings.go ================================================ package backend import ( "context" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/db" ) // AllowKeyless returns whether or not keyless access is allowed. // // It implements backend.Backend. func (b *Backend) AllowKeyless(ctx context.Context) bool { var allow bool if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error allow, err = b.store.GetAllowKeylessAccess(ctx, tx) return err }); err != nil { return false } return allow } // SetAllowKeyless sets whether or not keyless access is allowed. // // It implements backend.Backend. func (b *Backend) SetAllowKeyless(ctx context.Context, allow bool) error { return b.db.TransactionContext(ctx, func(tx *db.Tx) error { return b.store.SetAllowKeylessAccess(ctx, tx, allow) }) } // AnonAccess returns the level of anonymous access. // // It implements backend.Backend. func (b *Backend) AnonAccess(ctx context.Context) access.AccessLevel { var level access.AccessLevel if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error level, err = b.store.GetAnonAccess(ctx, tx) return err }); err != nil { return access.NoAccess } return level } // SetAnonAccess sets the level of anonymous access. // // It implements backend.Backend. func (b *Backend) SetAnonAccess(ctx context.Context, level access.AccessLevel) error { return b.db.TransactionContext(ctx, func(tx *db.Tx) error { return b.store.SetAnonAccess(ctx, tx, level) }) } ================================================ FILE: pkg/backend/user.go ================================================ package backend import ( "context" "errors" "strings" "time" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/charmbracelet/soft-serve/pkg/utils" "golang.org/x/crypto/ssh" ) // AccessLevel returns the access level of a user for a repository. // // It implements backend.Backend. func (d *Backend) AccessLevel(ctx context.Context, repo string, username string) access.AccessLevel { user, _ := d.User(ctx, username) return d.AccessLevelForUser(ctx, repo, user) } // AccessLevelByPublicKey returns the access level of a user's public key for a repository. // // It implements backend.Backend. func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ssh.PublicKey) access.AccessLevel { for _, k := range d.cfg.AdminKeys() { if sshutils.KeysEqual(pk, k) { return access.AdminAccess } } user, _ := d.UserByPublicKey(ctx, pk) if user != nil { return d.AccessLevel(ctx, repo, user.Username()) } return d.AccessLevel(ctx, repo, "") } // AccessLevelForUser returns the access level of a user for a repository. // TODO: user repository ownership func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user proto.User) access.AccessLevel { var username string anon := d.AnonAccess(ctx) if user != nil { username = user.Username() } // If the user is an admin, they have admin access. if user != nil && user.IsAdmin() { return access.AdminAccess } // If the repository exists, check if the user is a collaborator. r := proto.RepositoryFromContext(ctx) if r == nil { r, _ = d.Repository(ctx, repo) } if r != nil { if user != nil { // If the user is the owner, they have admin access. if r.UserID() == user.ID() { return access.AdminAccess } } // If the user is a collaborator, they have return their access level. collabAccess, isCollab, _ := d.IsCollaborator(ctx, repo, username) if isCollab { if anon > collabAccess { return anon } return collabAccess } // If the repository is private, the user has no access. if r.IsPrivate() { return access.NoAccess } // Otherwise, the user has read-only access. if user == nil { return anon } return access.ReadOnlyAccess } if user != nil { // If the repository doesn't exist, the user has read/write access. if anon > access.ReadWriteAccess { return anon } return access.ReadWriteAccess } // If the user doesn't exist, give them the anonymous access level. return anon } // User finds a user by username. // // It implements backend.Backend. func (d *Backend) User(ctx context.Context, username string) (proto.User, error) { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return nil, err } var m models.User var pks []ssh.PublicKey if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error m, err = d.store.FindUserByUsername(ctx, tx, username) if err != nil { return err } pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) return err }); err != nil { err = db.WrapError(err) if errors.Is(err, db.ErrRecordNotFound) { return nil, proto.ErrUserNotFound } d.logger.Error("error finding user", "username", username, "error", err) return nil, err } return &user{ user: m, publicKeys: pks, }, nil } // UserByID finds a user by ID. func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) { var m models.User var pks []ssh.PublicKey if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error m, err = d.store.GetUserByID(ctx, tx, id) if err != nil { return err } pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) return err }); err != nil { err = db.WrapError(err) if errors.Is(err, db.ErrRecordNotFound) { return nil, proto.ErrUserNotFound } d.logger.Error("error finding user", "id", id, "error", err) return nil, err } return &user{ user: m, publicKeys: pks, }, nil } // UserByPublicKey finds a user by public key. // // It implements backend.Backend. func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.User, error) { var m models.User var pks []ssh.PublicKey if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error m, err = d.store.FindUserByPublicKey(ctx, tx, pk) if err != nil { return db.WrapError(err) } pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) return err }); err != nil { err = db.WrapError(err) if errors.Is(err, db.ErrRecordNotFound) { return nil, proto.ErrUserNotFound } d.logger.Error("error finding user", "pk", sshutils.MarshalAuthorizedKey(pk), "error", err) return nil, err } return &user{ user: m, publicKeys: pks, }, nil } // UserByAccessToken finds a user by access token. // This also validates the token for expiration and returns proto.ErrTokenExpired. func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.User, error) { var m models.User var pks []ssh.PublicKey token = HashToken(token) if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { t, err := d.store.GetAccessTokenByToken(ctx, tx, token) if err != nil { return db.WrapError(err) } if t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now()) { return proto.ErrTokenExpired } m, err = d.store.FindUserByAccessToken(ctx, tx, token) if err != nil { return db.WrapError(err) } pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID) return err }); err != nil { err = db.WrapError(err) if errors.Is(err, db.ErrRecordNotFound) { return nil, proto.ErrUserNotFound } d.logger.Error("failed to find user by access token", "err", err, "token", token) return nil, err } return &user{ user: m, publicKeys: pks, }, nil } // Users returns all users. // // It implements backend.Backend. func (d *Backend) Users(ctx context.Context) ([]string, error) { var users []string if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { ms, err := d.store.GetAllUsers(ctx, tx) if err != nil { return err } for _, m := range ms { users = append(users, m.Username) } return nil }); err != nil { return nil, db.WrapError(err) } return users, nil } // AddPublicKey adds a public key to a user. // // It implements backend.Backend. func (d *Backend) AddPublicKey(ctx context.Context, username string, pk ssh.PublicKey) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } return db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.AddPublicKeyByUsername(ctx, tx, username, pk) }), ) } // CreateUser creates a new user. // // It implements backend.Backend. func (d *Backend) CreateUser(ctx context.Context, username string, opts proto.UserOptions) (proto.User, error) { username = utils.Sanitize(username) username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return nil, err } if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.CreateUser(ctx, tx, username, opts.Admin, opts.PublicKeys) }); err != nil { return nil, db.WrapError(err) } return d.User(ctx, username) } // DeleteUser deletes a user. // // It implements backend.Backend. func (d *Backend) DeleteUser(ctx context.Context, username string) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } return d.db.TransactionContext(ctx, func(tx *db.Tx) error { if err := d.store.DeleteUserByUsername(ctx, tx, username); err != nil { return db.WrapError(err) } return d.DeleteUserRepositories(ctx, username) }) } // RemovePublicKey removes a public key from a user. // // It implements backend.Backend. func (d *Backend) RemovePublicKey(ctx context.Context, username string, pk ssh.PublicKey) error { return db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.RemovePublicKeyByUsername(ctx, tx, username, pk) }), ) } // ListPublicKeys lists the public keys of a user. func (d *Backend) ListPublicKeys(ctx context.Context, username string) ([]ssh.PublicKey, error) { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return nil, err } var keys []ssh.PublicKey if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error { var err error keys, err = d.store.ListPublicKeysByUsername(ctx, tx, username) return err }); err != nil { return nil, db.WrapError(err) } return keys, nil } // SetUsername sets the username of a user. // // It implements backend.Backend. func (d *Backend) SetUsername(ctx context.Context, username string, newUsername string) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } return db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.SetUsernameByUsername(ctx, tx, username, newUsername) }), ) } // SetAdmin sets the admin flag of a user. // // It implements backend.Backend. func (d *Backend) SetAdmin(ctx context.Context, username string, admin bool) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } return db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.SetAdminByUsername(ctx, tx, username, admin) }), ) } // SetPassword sets the password of a user. func (d *Backend) SetPassword(ctx context.Context, username string, rawPassword string) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } password, err := HashPassword(rawPassword) if err != nil { return err } return db.WrapError( d.db.TransactionContext(ctx, func(tx *db.Tx) error { return d.store.SetUserPasswordByUsername(ctx, tx, username, password) }), ) } type user struct { user models.User publicKeys []ssh.PublicKey } var _ proto.User = (*user)(nil) // IsAdmin implements proto.User func (u *user) IsAdmin() bool { return u.user.Admin } // PublicKeys implements proto.User func (u *user) PublicKeys() []ssh.PublicKey { return u.publicKeys } // Username implements proto.User func (u *user) Username() string { return u.user.Username } // ID implements proto.User. func (u *user) ID() int64 { return u.user.ID } // Password implements proto.User. func (u *user) Password() string { if u.user.Password.Valid { return u.user.Password.String } return "" } ================================================ FILE: pkg/backend/utils.go ================================================ package backend import ( "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/proto" ) // LatestFile returns the contents of the latest file at the specified path in // the repository and its file path. func LatestFile(r proto.Repository, ref *git.Reference, pattern string) (string, string, error) { repo, err := r.Open() if err != nil { return "", "", err } return git.LatestFile(repo, ref, pattern) } // Readme returns the repository's README. func Readme(r proto.Repository, ref *git.Reference) (readme string, path string, err error) { pattern := "[rR][eE][aA][dD][mM][eE]*" readme, path, err = LatestFile(r, ref, pattern) return } ================================================ FILE: pkg/backend/webhooks.go ================================================ package backend import ( "context" "encoding/json" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/charmbracelet/soft-serve/pkg/webhook" "github.com/google/uuid" ) // CreateWebhook creates a webhook for a repository. func (b *Backend) CreateWebhook(ctx context.Context, repo proto.Repository, url string, contentType webhook.ContentType, secret string, events []webhook.Event, active bool) error { dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) url = utils.Sanitize(url) // Validate webhook URL to prevent SSRF attacks if err := webhook.ValidateWebhookURL(url); err != nil { return err //nolint:wrapcheck } return dbx.TransactionContext(ctx, func(tx *db.Tx) error { lastID, err := datastore.CreateWebhook(ctx, tx, repo.ID(), url, secret, int(contentType), active) if err != nil { return db.WrapError(err) } evs := make([]int, len(events)) for i, e := range events { evs[i] = int(e) } if err := datastore.CreateWebhookEvents(ctx, tx, lastID, evs); err != nil { return db.WrapError(err) } return nil }) } // Webhook returns a webhook for a repository. func (b *Backend) Webhook(ctx context.Context, repo proto.Repository, id int64) (webhook.Hook, error) { dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) var wh webhook.Hook if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { h, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id) if err != nil { return db.WrapError(err) } events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id) if err != nil { return db.WrapError(err) } wh = webhook.Hook{ Webhook: h, ContentType: webhook.ContentType(h.ContentType), //nolint:gosec Events: make([]webhook.Event, len(events)), } for i, e := range events { wh.Events[i] = webhook.Event(e.Event) } return nil }); err != nil { return webhook.Hook{}, db.WrapError(err) } return wh, nil } // ListWebhooks lists webhooks for a repository. func (b *Backend) ListWebhooks(ctx context.Context, repo proto.Repository) ([]webhook.Hook, error) { dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) var webhooks []models.Webhook webhookEvents := map[int64][]models.WebhookEvent{} if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { var err error webhooks, err = datastore.GetWebhooksByRepoID(ctx, tx, repo.ID()) if err != nil { return err } for _, h := range webhooks { events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, h.ID) if err != nil { return err } webhookEvents[h.ID] = events } return nil }); err != nil { return nil, db.WrapError(err) } hooks := make([]webhook.Hook, len(webhooks)) for i, h := range webhooks { events := make([]webhook.Event, len(webhookEvents[h.ID])) for i, e := range webhookEvents[h.ID] { events[i] = webhook.Event(e.Event) } hooks[i] = webhook.Hook{ Webhook: h, ContentType: webhook.ContentType(h.ContentType), //nolint:gosec Events: events, } } return hooks, nil } // UpdateWebhook updates a webhook. func (b *Backend) UpdateWebhook(ctx context.Context, repo proto.Repository, id int64, url string, contentType webhook.ContentType, secret string, updatedEvents []webhook.Event, active bool) error { dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) // Validate webhook URL to prevent SSRF attacks if err := webhook.ValidateWebhookURL(url); err != nil { return err } return dbx.TransactionContext(ctx, func(tx *db.Tx) error { if err := datastore.UpdateWebhookByID(ctx, tx, repo.ID(), id, url, secret, int(contentType), active); err != nil { return db.WrapError(err) } currentEvents, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id) if err != nil { return db.WrapError(err) } // Delete events that are no longer in the list. toBeDeleted := make([]int64, 0) for _, e := range currentEvents { found := false for _, ne := range updatedEvents { if int(ne) == e.Event { found = true break } } if !found { toBeDeleted = append(toBeDeleted, e.ID) } } if err := datastore.DeleteWebhookEventsByID(ctx, tx, toBeDeleted); err != nil { return db.WrapError(err) } // Prune events that are already in the list. newEvents := make([]int, 0) for _, e := range updatedEvents { found := false for _, ne := range currentEvents { if int(e) == ne.Event { found = true break } } if !found { newEvents = append(newEvents, int(e)) } } if err := datastore.CreateWebhookEvents(ctx, tx, id, newEvents); err != nil { return db.WrapError(err) } return nil }) } // DeleteWebhook deletes a webhook for a repository. func (b *Backend) DeleteWebhook(ctx context.Context, repo proto.Repository, id int64) error { dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) return dbx.TransactionContext(ctx, func(tx *db.Tx) error { _, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id) if err != nil { return db.WrapError(err) } if err := datastore.DeleteWebhookForRepoByID(ctx, tx, repo.ID(), id); err != nil { return db.WrapError(err) } return nil }) } // ListWebhookDeliveries lists webhook deliveries for a webhook. func (b *Backend) ListWebhookDeliveries(ctx context.Context, id int64) ([]webhook.Delivery, error) { dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) var deliveries []models.WebhookDelivery if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { var err error deliveries, err = datastore.ListWebhookDeliveriesByWebhookID(ctx, tx, id) if err != nil { return db.WrapError(err) } return nil }); err != nil { return nil, db.WrapError(err) } ds := make([]webhook.Delivery, len(deliveries)) for i, d := range deliveries { ds[i] = webhook.Delivery{ WebhookDelivery: d, Event: webhook.Event(d.Event), } } return ds, nil } // RedeliverWebhookDelivery redelivers a webhook delivery. func (b *Backend) RedeliverWebhookDelivery(ctx context.Context, repo proto.Repository, id int64, delID uuid.UUID) error { dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) var delivery models.WebhookDelivery var wh models.Webhook if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { var err error wh, err = datastore.GetWebhookByID(ctx, tx, repo.ID(), id) if err != nil { log.Errorf("error getting webhook: %v", err) return db.WrapError(err) } delivery, err = datastore.GetWebhookDeliveryByID(ctx, tx, id, delID) if err != nil { return db.WrapError(err) } return nil }); err != nil { return db.WrapError(err) } log.Infof("redelivering webhook delivery %s for webhook %d\n\n%s\n\n", delID, id, delivery.RequestBody) var payload json.RawMessage if err := json.Unmarshal([]byte(delivery.RequestBody), &payload); err != nil { log.Errorf("error unmarshaling webhook payload: %v", err) return err } return webhook.SendWebhook(ctx, wh, webhook.Event(delivery.Event), payload) } // WebhookDelivery returns a webhook delivery. func (b *Backend) WebhookDelivery(ctx context.Context, webhookID int64, id uuid.UUID) (webhook.Delivery, error) { dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) var delivery webhook.Delivery if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error { d, err := datastore.GetWebhookDeliveryByID(ctx, tx, webhookID, id) if err != nil { return db.WrapError(err) } delivery = webhook.Delivery{ WebhookDelivery: d, Event: webhook.Event(d.Event), } return nil }); err != nil { return webhook.Delivery{}, db.WrapError(err) } return delivery, nil } ================================================ FILE: pkg/config/config.go ================================================ package config import ( "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/caarlos0/env/v11" "github.com/charmbracelet/soft-serve/pkg/sshutils" "golang.org/x/crypto/ssh" "gopkg.in/yaml.v3" ) var binPath = "soft" // SSHConfig is the configuration for the SSH server. type SSHConfig struct { // Enabled toggles the SSH server on/off Enabled bool `env:"ENABLED" yaml:"enabled"` // ListenAddr is the address on which the SSH server will listen. ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"` // PublicURL is the public URL of the SSH server. PublicURL string `env:"PUBLIC_URL" yaml:"public_url"` // KeyPath is the path to the SSH server's private key. KeyPath string `env:"KEY_PATH" yaml:"key_path"` // ClientKeyPath is the path to the server's client private key. ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"` // MaxTimeout is the maximum number of seconds a connection can take. MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"` // IdleTimeout is the number of seconds a connection can be idle before it is closed. IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"` } // GitConfig is the Git daemon configuration for the server. type GitConfig struct { // Enabled toggles the Git daemon on/off Enabled bool `env:"ENABLED" yaml:"enabled"` // ListenAddr is the address on which the Git daemon will listen. ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"` // PublicURL is the public URL of the Git daemon server. PublicURL string `env:"PUBLIC_URL" yaml:"public_url"` // MaxTimeout is the maximum number of seconds a connection can take. MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"` // IdleTimeout is the number of seconds a connection can be idle before it is closed. IdleTimeout int `env:"IDLE_TIMEOUT" yaml:"idle_timeout"` // MaxConnections is the maximum number of concurrent connections. MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"` } // CORSConfig is the CORS configuration for the server. type CORSConfig struct { AllowedHeaders []string `env:"ALLOWED_HEADERS" yaml:"allowed_headers"` AllowedOrigins []string `env:"ALLOWED_ORIGINS" yaml:"allowed_origins"` AllowedMethods []string `env:"ALLOWED_METHODS" yaml:"allowed_methods"` } // HTTPConfig is the HTTP configuration for the server. type HTTPConfig struct { // Enabled toggles the HTTP server on/off Enabled bool `env:"ENABLED" yaml:"enabled"` // ListenAddr is the address on which the HTTP server will listen. ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"` // TLSKeyPath is the path to the TLS private key. TLSKeyPath string `env:"TLS_KEY_PATH" yaml:"tls_key_path"` // TLSCertPath is the path to the TLS certificate. TLSCertPath string `env:"TLS_CERT_PATH" yaml:"tls_cert_path"` // PublicURL is the public URL of the HTTP server. PublicURL string `env:"PUBLIC_URL" yaml:"public_url"` // CORS is the cross-origin configuration for the HTTP server. CORS CORSConfig `envPrefix:"CORS_" yaml:"cors"` } // StatsConfig is the configuration for the stats server. type StatsConfig struct { // Enabled toggles the Stats server on/off Enabled bool `env:"ENABLED" yaml:"enabled"` // ListenAddr is the address on which the stats server will listen. ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"` } // LogConfig is the logger configuration. type LogConfig struct { // Format is the format of the logs. // Valid values are "json", "logfmt", and "text". Format string `env:"FORMAT" yaml:"format"` // Time format for the log `ts` field. // Format must be described in Golang's time format. TimeFormat string `env:"TIME_FORMAT" yaml:"time_format"` // Path to a file to write logs to. // If not set, logs will be written to stderr. Path string `env:"PATH" yaml:"path"` } // DBConfig is the database connection configuration. type DBConfig struct { // Driver is the driver for the database. Driver string `env:"DRIVER" yaml:"driver"` // DataSource is the database data source name. DataSource string `env:"DATA_SOURCE" yaml:"data_source"` } // LFSConfig is the configuration for Git LFS. type LFSConfig struct { // Enabled is whether or not Git LFS is enabled. Enabled bool `env:"ENABLED" yaml:"enabled"` // SSHEnabled is whether or not Git LFS over SSH is enabled. // This is only used if LFS is enabled. SSHEnabled bool `env:"SSH_ENABLED" yaml:"ssh_enabled"` } // JobsConfig is the configuration for cron jobs. type JobsConfig struct { MirrorPull string `env:"MIRROR_PULL" yaml:"mirror_pull"` } // Config is the configuration for Soft Serve. type Config struct { // Name is the name of the server. Name string `env:"NAME" yaml:"name"` // SSH is the configuration for the SSH server. SSH SSHConfig `envPrefix:"SSH_" yaml:"ssh"` // Git is the configuration for the Git daemon. Git GitConfig `envPrefix:"GIT_" yaml:"git"` // HTTP is the configuration for the HTTP server. HTTP HTTPConfig `envPrefix:"HTTP_" yaml:"http"` // Stats is the configuration for the stats server. Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"` // Log is the logger configuration. Log LogConfig `envPrefix:"LOG_" yaml:"log"` // DB is the database configuration. DB DBConfig `envPrefix:"DB_" yaml:"db"` // LFS is the configuration for Git LFS. LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"` // Jobs is the configuration for cron jobs Jobs JobsConfig `envPrefix:"JOBS_" yaml:"jobs"` // InitialAdminKeys is a list of public keys that will be added to the list of admins. InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"` // DataPath is the path to the directory where Soft Serve will store its data. DataPath string `env:"DATA_PATH" yaml:"-"` } // Environ returns the config as a list of environment variables. func (c *Config) Environ() []string { envs := []string{ fmt.Sprintf("SOFT_SERVE_BIN_PATH=%s", binPath), } if c == nil { return envs } // TODO: do this dynamically envs = append(envs, []string{ fmt.Sprintf("SOFT_SERVE_CONFIG_LOCATION=%s", c.ConfigPath()), fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath), fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name), fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")), fmt.Sprintf("SOFT_SERVE_SSH_ENABLED=%t", c.SSH.Enabled), fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr), fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL), fmt.Sprintf("SOFT_SERVE_SSH_KEY_PATH=%s", c.SSH.KeyPath), fmt.Sprintf("SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s", c.SSH.ClientKeyPath), fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout), fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout), fmt.Sprintf("SOFT_SERVE_GIT_ENABLED=%t", c.Git.Enabled), fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr), fmt.Sprintf("SOFT_SERVE_GIT_PUBLIC_URL=%s", c.Git.PublicURL), fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout), fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout), fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections), fmt.Sprintf("SOFT_SERVE_HTTP_ENABLED=%t", c.HTTP.Enabled), fmt.Sprintf("SOFT_SERVE_HTTP_LISTEN_ADDR=%s", c.HTTP.ListenAddr), fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath), fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath), fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL), fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS=%s", strings.Join(c.HTTP.CORS.AllowedHeaders, ",")), fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s", strings.Join(c.HTTP.CORS.AllowedOrigins, ",")), fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s", strings.Join(c.HTTP.CORS.AllowedMethods, ",")), fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled), fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr), fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format), fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat), fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver), fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource), fmt.Sprintf("SOFT_SERVE_LFS_ENABLED=%t", c.LFS.Enabled), fmt.Sprintf("SOFT_SERVE_LFS_SSH_ENABLED=%t", c.LFS.SSHEnabled), fmt.Sprintf("SOFT_SERVE_JOBS_MIRROR_PULL=%s", c.Jobs.MirrorPull), }...) return envs } // IsDebug returns true if the server is running in debug mode. func IsDebug() bool { debug, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_DEBUG")) return debug } // IsVerbose returns true if the server is running in verbose mode. // Verbose mode is only enabled if debug mode is enabled. func IsVerbose() bool { verbose, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_VERBOSE")) return IsDebug() && verbose } // parseFile parses the given file as a configuration file. // The file must be in YAML format. func parseFile(cfg *Config, path string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() //nolint: errcheck if err := yaml.NewDecoder(f).Decode(cfg); err != nil { return fmt.Errorf("decode config: %w", err) } return cfg.Validate() } // ParseFile parses the config from the default file path. // This also calls Validate() on the config. func (c *Config) ParseFile() error { return parseFile(c, c.ConfigPath()) } // parseEnv parses the environment variables as a configuration file. func parseEnv(cfg *Config) error { // Merge initial admin keys from both config file and environment variables. initialAdminKeys := append([]string{}, cfg.InitialAdminKeys...) // Override with environment variables if err := env.ParseWithOptions(cfg, env.Options{ Prefix: "SOFT_SERVE_", }); err != nil { return fmt.Errorf("parse environment variables: %w", err) } // Merge initial admin keys from environment variables. if initialAdminKeysEnv := os.Getenv("SOFT_SERVE_INITIAL_ADMIN_KEYS"); initialAdminKeysEnv != "" { cfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...) } return cfg.Validate() } // ParseEnv parses the config from the environment variables. // This also calls Validate() on the config. func (c *Config) ParseEnv() error { return parseEnv(c) } // Parse parses the config from the default file path and environment variables. // This also calls Validate() on the config. func (c *Config) Parse() error { if err := c.ParseFile(); err != nil { return err } return c.ParseEnv() } // writeConfig writes the configuration to the given file. func writeConfig(cfg *Config, path string) error { if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { return err } return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) //nolint: errcheck, gosec } // WriteConfig writes the configuration to the default file. func (c *Config) WriteConfig() error { return writeConfig(c, c.ConfigPath()) } // DefaultDataPath returns the path to the data directory. // It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it // uses "data". func DefaultDataPath() string { dp := os.Getenv("SOFT_SERVE_DATA_PATH") if dp == "" { dp = "data" } return dp } // ConfigPath returns the path to the config file. func (c *Config) ConfigPath() string { //nolint:revive // If we have a custom config location set, then use that. if path := os.Getenv("SOFT_SERVE_CONFIG_LOCATION"); exist(path) { return path } // Otherwise, look in the data path. return filepath.Join(c.DataPath, "config.yaml") } func exist(path string) bool { _, err := os.Stat(path) return err == nil } // Exist returns true if the config file exists. func (c *Config) Exist() bool { return exist(c.ConfigPath()) } // DefaultConfig returns the default Config. All the path values are relative // to the data directory. // Use Validate() to validate the config and ensure absolute paths. func DefaultConfig() *Config { return &Config{ Name: "Soft Serve", DataPath: DefaultDataPath(), SSH: SSHConfig{ Enabled: true, ListenAddr: ":23231", PublicURL: "ssh://localhost:23231", KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"), ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"), MaxTimeout: 0, IdleTimeout: 10 * 60, // 10 minutes }, Git: GitConfig{ Enabled: true, ListenAddr: ":9418", PublicURL: "git://localhost", MaxTimeout: 0, IdleTimeout: 3, MaxConnections: 32, }, HTTP: HTTPConfig{ Enabled: true, ListenAddr: ":23232", PublicURL: "http://localhost:23232", CORS: CORSConfig{ AllowedHeaders: []string{"Accept", "Accept-Language", "Content-Language", "Content-Type", "Origin", "X-Requested-With", "User-Agent", "Authorization", "Access-Control-Request-Method", "Access-Control-Allow-Origin"}, AllowedMethods: []string{"GET", "HEAD", "POST", "PUT", "OPTIONS"}, AllowedOrigins: []string{"http://localhost:23232"}, }, }, Stats: StatsConfig{ Enabled: true, ListenAddr: "localhost:23233", }, Log: LogConfig{ Format: "text", TimeFormat: time.DateTime, }, DB: DBConfig{ Driver: "sqlite", DataSource: "soft-serve.db" + "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", }, LFS: LFSConfig{ Enabled: true, SSHEnabled: false, }, Jobs: JobsConfig{ MirrorPull: "@every 10m", }, } } // Validate validates the configuration. // It updates the configuration with absolute paths. func (c *Config) Validate() error { // Use absolute paths if !filepath.IsAbs(c.DataPath) { dp, err := filepath.Abs(c.DataPath) if err != nil { return err } c.DataPath = dp } c.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, "/") c.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, "/") if c.SSH.KeyPath != "" && !filepath.IsAbs(c.SSH.KeyPath) { c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath) } if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) { c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath) } if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) { c.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath) } if c.HTTP.TLSCertPath != "" && !filepath.IsAbs(c.HTTP.TLSCertPath) { c.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath) } if strings.HasPrefix(c.DB.Driver, "sqlite") && !filepath.IsAbs(c.DB.DataSource) { c.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource) } // Validate keys pks := make([]string, 0) for _, key := range parseAuthKeys(c.InitialAdminKeys) { ak := sshutils.MarshalAuthorizedKey(key) pks = append(pks, ak) } c.InitialAdminKeys = pks c.HTTP.CORS.AllowedOrigins = append([]string{c.HTTP.PublicURL}, c.HTTP.CORS.AllowedOrigins...) return nil } // parseAuthKeys parses authorized keys from either file paths or string authorized_keys. func parseAuthKeys(aks []string) []ssh.PublicKey { exist := make(map[string]struct{}, 0) pks := make([]ssh.PublicKey, 0) for _, key := range aks { if bts, err := os.ReadFile(key); err == nil { // key is a file key = strings.TrimSpace(string(bts)) } if pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil { if _, ok := exist[key]; !ok { pks = append(pks, pk) exist[key] = struct{}{} } } } return pks } // AdminKeys returns the server admin keys. func (c *Config) AdminKeys() []ssh.PublicKey { return parseAuthKeys(c.InitialAdminKeys) } func init() { if ex, err := os.Executable(); err == nil { binPath = filepath.ToSlash(ex) } } ================================================ FILE: pkg/config/config_test.go ================================================ package config import ( "os" "testing" "github.com/matryer/is" ) func TestParseMultipleKeys(t *testing.T) { is := is.New(t) td := t.TempDir() is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEYS", "testdata/k1.pub\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b")) is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", td)) t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEYS")) is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH")) }) cfg := DefaultConfig() is.NoErr(cfg.ParseEnv()) is.Equal(cfg.InitialAdminKeys, []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8", }) } func TestMergeInitAdminKeys(t *testing.T) { is := is.New(t) is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEYS", "testdata/k1.pub")) t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEYS")) }) cfg := &Config{ DataPath: t.TempDir(), InitialAdminKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b"}, } is.NoErr(cfg.WriteConfig()) is.NoErr(cfg.Parse()) is.Equal(cfg.InitialAdminKeys, []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8", }) } func TestValidateInitAdminKeys(t *testing.T) { is := is.New(t) cfg := &Config{ DataPath: t.TempDir(), InitialAdminKeys: []string{ "testdata/k1.pub", "abc", "", }, } is.NoErr(cfg.WriteConfig()) is.NoErr(cfg.Parse()) is.Equal(cfg.InitialAdminKeys, []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH", }) } func TestCustomConfigLocation(t *testing.T) { is := is.New(t) td := t.TempDir() t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_CONFIG_LOCATION")) }) // Test that we get data from the custom file location, and not from the data dir. is.NoErr(os.Setenv("SOFT_SERVE_CONFIG_LOCATION", "testdata/config.yaml")) is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", td)) cfg := DefaultConfig() is.NoErr(cfg.Parse()) is.Equal(cfg.Name, "Test server name") // If we unset the custom location, then use the default location. is.NoErr(os.Unsetenv("SOFT_SERVE_CONFIG_LOCATION")) cfg = DefaultConfig() is.Equal(cfg.Name, "Soft Serve") // Test that if the custom config location doesn't exist, default to datapath config. is.NoErr(os.Setenv("SOFT_SERVE_CONFIG_LOCATION", "testdata/config_nonexistent.yaml")) cfg = DefaultConfig() is.Equal(cfg.Name, "Soft Serve") } func TestParseMultipleHeaders(t *testing.T) { is := is.New(t) is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS", "Accept,Accept-Language,User-Agent")) t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS")) }) cfg := DefaultConfig() is.NoErr(cfg.ParseEnv()) is.Equal(cfg.HTTP.CORS.AllowedHeaders, []string{ "Accept", "Accept-Language", "User-Agent", }) } func TestParseMultipleOrigins(t *testing.T) { is := is.New(t) is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS", "http://example.com,https://example.com")) t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS")) }) cfg := DefaultConfig() is.NoErr(cfg.ParseEnv()) is.Equal(cfg.HTTP.CORS.AllowedOrigins, []string{ "http://localhost:23232", "http://example.com", "https://example.com", }) } func TestParseMultipleMethods(t *testing.T) { is := is.New(t) is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS", "GET,POST,PUT")) t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS")) }) cfg := DefaultConfig() is.NoErr(cfg.ParseEnv()) is.Equal(cfg.HTTP.CORS.AllowedMethods, []string{ "GET", "POST", "PUT", }) } ================================================ FILE: pkg/config/context.go ================================================ package config import "context" // ContextKey is the context key for the config. var ContextKey = struct{ string }{"config"} // WithContext returns a new context with the configuration attached. func WithContext(ctx context.Context, cfg *Config) context.Context { return context.WithValue(ctx, ContextKey, cfg) } // FromContext returns the configuration from the context. func FromContext(ctx context.Context) *Config { if c, ok := ctx.Value(ContextKey).(*Config); ok { return c } return nil } ================================================ FILE: pkg/config/context_test.go ================================================ package config import ( "context" "reflect" "testing" ) func TestBadFromContext(t *testing.T) { ctx := context.TODO() if c := FromContext(ctx); c != nil { t.Errorf("FromContext(ctx) => %v, want %v", c, nil) } } func TestGoodFromContext(t *testing.T) { ctx := WithContext(context.TODO(), &Config{}) if c := FromContext(ctx); c == nil { t.Errorf("FromContext(ctx) => %v, want %v", c, &Config{}) } } func TestGoodFromContextWithDefaultConfig(t *testing.T) { cfg := DefaultConfig() ctx := WithContext(context.TODO(), cfg) if c := FromContext(ctx); c == nil || !reflect.DeepEqual(c, cfg) { t.Errorf("FromContext(ctx) => %v, want %v", c, cfg) } } ================================================ FILE: pkg/config/file.go ================================================ package config import ( "bytes" "text/template" ) var configFileTmpl = template.Must(template.New("config").Parse(`# Soft Serve Server configurations # The name of the server. # This is the name that will be displayed in the UI. name: "{{ .Name }}" # Logging configuration. log: # Log format to use. Valid values are "json", "logfmt", and "text". format: "{{ .Log.Format }}" # Time format for the log "timestamp" field. # Should be described in Golang's time format. time_format: "{{ .Log.TimeFormat }}" # Path to the log file. Leave empty to write to stderr. #path: "{{ .Log.Path }}" # The SSH server configuration. ssh: # Enable SSH. enabled: {{ .SSH.Enabled }} # The address on which the SSH server will listen. listen_addr: "{{ .SSH.ListenAddr }}" # The public URL of the SSH server. # This is the address that will be used to clone repositories. public_url: "{{ .SSH.PublicURL }}" # The path to the SSH server's private key. key_path: {{ .SSH.KeyPath }} # The path to the server's client private key. This key will be used to # authenticate the server to make git requests to ssh remotes. client_key_path: {{ .SSH.ClientKeyPath }} # The maximum number of seconds a connection can take. # A value of 0 means no timeout. max_timeout: {{ .SSH.MaxTimeout }} # The number of seconds a connection can be idle before it is closed. # A value of 0 means no timeout. idle_timeout: {{ .SSH.IdleTimeout }} # The Git daemon configuration. git: # Enable the Git daemon. enabled: {{ .Git.Enabled }} # The address on which the Git daemon will listen. listen_addr: "{{ .Git.ListenAddr }}" # The public URL of the Git daemon server. # This is the address that will be used to clone repositories. public_url: "{{ .Git.PublicURL }}" # The maximum number of seconds a connection can take. # A value of 0 means no timeout. max_timeout: {{ .Git.MaxTimeout }} # The number of seconds a connection can be idle before it is closed. idle_timeout: {{ .Git.IdleTimeout }} # The maximum number of concurrent connections. max_connections: {{ .Git.MaxConnections }} # The HTTP server configuration. http: # Enable the HTTP server. enabled: {{ .HTTP.Enabled }} # The address on which the HTTP server will listen. listen_addr: "{{ .HTTP.ListenAddr }}" # The path to the TLS private key. tls_key_path: {{ .HTTP.TLSKeyPath }} # The path to the TLS certificate. tls_cert_path: {{ .HTTP.TLSCertPath }} # The public URL of the HTTP server. # This is the address that will be used to clone repositories. # Make sure to use https:// if you are using TLS. public_url: "{{ .HTTP.PublicURL }}" # The cross-origin request security options cors: # The allowed cross-origin headers allowed_headers: - "Accept" - "Accept-Language" - "Content-Language" - "Content-Type" - "Origin" - "X-Requested-With" - "User-Agent" - "Authorization" - "Access-Control-Request-Method" - "Access-Control-Allow-Origin" # The allowed cross-origin URLs allowed_origins: - "{{ .HTTP.PublicURL }}" # always allowed # - "https://example.com" # The allowed cross-origin methods allowed_methods: - "GET" - "HEAD" - "POST" - "PUT" - "OPTIONS" # The stats server configuration. stats: # Enable the stats server. enabled: {{ .Stats.Enabled }} # The address on which the stats server will listen. listen_addr: "{{ .Stats.ListenAddr }}" # The database configuration. db: # The database driver to use. # Valid values are "sqlite" and "postgres". driver: "{{ .DB.Driver }}" # The database data source name. # This is driver specific and can be a file path or connection string. # Make sure foreign key support is enabled when using SQLite. data_source: "{{ .DB.DataSource }}" # Git LFS configuration. lfs: # Enable Git LFS. enabled: {{ .LFS.Enabled }} # Enable Git SSH transfer. ssh_enabled: {{ .LFS.SSHEnabled }} # Cron job configuration jobs: mirror_pull: "{{ .Jobs.MirrorPull }}" # Additional admin keys. #initial_admin_keys: # - "ssh-rsa AAAAB3NzaC1yc2..." `)) func newConfigFile(cfg *Config) string { var b bytes.Buffer configFileTmpl.Execute(&b, cfg) //nolint: errcheck return b.String() } ================================================ FILE: pkg/config/file_test.go ================================================ package config import "testing" func TestNewConfigFile(t *testing.T) { for _, cfg := range []*Config{ nil, DefaultConfig(), {}, } { if s := newConfigFile(cfg); s == "" { t.Errorf("newConfigFile(nil) => %q, want non-empty string", s) } } } ================================================ FILE: pkg/config/ssh.go ================================================ package config import ( "errors" "github.com/charmbracelet/keygen" ) var ( // ErrNilConfig is returned when a nil config is passed to a function. ErrNilConfig = errors.New("nil config") // ErrEmptySSHKeyPath is returned when the SSH key path is empty. ErrEmptySSHKeyPath = errors.New("empty SSH key path") ) // KeyPair returns the server's SSH key pair. func KeyPair(cfg *Config) (*keygen.SSHKeyPair, error) { if cfg == nil { return nil, ErrNilConfig } if cfg.SSH.KeyPath == "" { return nil, ErrEmptySSHKeyPath } return keygen.New(cfg.SSH.KeyPath, keygen.WithKeyType(keygen.Ed25519)) } ================================================ FILE: pkg/config/ssh_test.go ================================================ package config import "testing" func TestBadSSHKeyPair(t *testing.T) { for _, cfg := range []*Config{ nil, {}, } { if _, err := KeyPair(cfg); err == nil { t.Errorf("cfg.SSH.KeyPair() => _, nil, want non-nil error") } } } func TestGoodSSHKeyPair(t *testing.T) { cfg := &Config{ SSH: SSHConfig{ KeyPath: "testdata/ssh_host_ed25519_key", }, } if _, err := KeyPair(cfg); err != nil { t.Errorf("cfg.SSH.KeyPair() => _, %v, want nil error", err) } } ================================================ FILE: pkg/config/testdata/config.yaml ================================================ # Soft Serve Server configurations name: "Test server name" ================================================ FILE: pkg/config/testdata/k1.pub ================================================ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b ================================================ FILE: pkg/cron/cron.go ================================================ package cron import ( "context" "time" "charm.land/log/v2" "github.com/robfig/cron/v3" ) // Scheduler is a cron-like job scheduler. type Scheduler struct { *cron.Cron } // cronLogger is a wrapper around the logger to make it compatible with the // cron logger. type cronLogger struct { logger *log.Logger } // Info logs routine messages about cron's operation. func (l cronLogger) Info(msg string, keysAndValues ...interface{}) { l.logger.Debug(msg, keysAndValues...) } // Error logs an error condition. func (l cronLogger) Error(err error, msg string, keysAndValues ...interface{}) { l.logger.Error(msg, append(keysAndValues, "err", err)...) } // NewScheduler returns a new Cron. func NewScheduler(ctx context.Context) *Scheduler { logger := cronLogger{log.FromContext(ctx).WithPrefix("cron")} return &Scheduler{ Cron: cron.New(cron.WithLogger(logger)), } } // Shutdonw gracefully shuts down the Scheduler. func (s *Scheduler) Shutdown() { ctx, cancel := context.WithTimeout(s.Cron.Stop(), 30*time.Second) defer func() { cancel() }() <-ctx.Done() } // Start starts the Scheduler. func (s *Scheduler) Start() { s.Cron.Start() } // AddFunc adds a job to the Scheduler. func (s *Scheduler) AddFunc(spec string, fn func()) (int, error) { id, err := s.Cron.AddFunc(spec, fn) return int(id), err } // Remove removes a job from the Scheduler. func (s *Scheduler) Remove(id int) { s.Cron.Remove(cron.EntryID(id)) } ================================================ FILE: pkg/cron/cron_test.go ================================================ package cron import ( "bytes" "context" "fmt" "testing" "charm.land/log/v2" ) func TestCronLogger(t *testing.T) { var buf bytes.Buffer logger := log.New(&buf) logger.SetLevel(log.DebugLevel) clogger := cronLogger{logger} clogger.Info("foo") clogger.Error(fmt.Errorf("bar"), "test") if buf.String() != "DEBU foo\nERRO test err=bar\n" { t.Errorf("unexpected log output: %s", buf.String()) } } func TestSchedularAddRemove(t *testing.T) { s := NewScheduler(context.TODO()) id, err := s.AddFunc("* * * * *", func() {}) if err != nil { t.Fatal(err) } s.Remove(id) } ================================================ FILE: pkg/daemon/conn.go ================================================ package daemon import ( "context" "errors" "net" "sync" "time" ) // connections is a synchronizes access to to a net.Conn pool. type connections struct { m map[net.Conn]struct{} mu sync.Mutex } func (m *connections) Add(c net.Conn) { m.mu.Lock() defer m.mu.Unlock() m.m[c] = struct{}{} } func (m *connections) Close(c net.Conn) error { m.mu.Lock() defer m.mu.Unlock() err := c.Close() delete(m.m, c) return err } func (m *connections) Size() int { m.mu.Lock() defer m.mu.Unlock() return len(m.m) } func (m *connections) CloseAll() error { m.mu.Lock() defer m.mu.Unlock() var err error for c := range m.m { err = errors.Join(err, c.Close()) delete(m.m, c) } return err } // serverConn is a wrapper around a net.Conn that closes the connection when // the one of the timeouts is reached. type serverConn struct { net.Conn initTimeout time.Duration idleTimeout time.Duration maxDeadline time.Time closeCanceler context.CancelFunc } var _ net.Conn = (*serverConn)(nil) func (c *serverConn) Write(p []byte) (n int, err error) { c.updateDeadline() n, err = c.Conn.Write(p) if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { c.closeCanceler() } return } func (c *serverConn) Read(b []byte) (n int, err error) { c.updateDeadline() n, err = c.Conn.Read(b) if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { c.closeCanceler() } return } func (c *serverConn) Close() (err error) { err = c.Conn.Close() if c.closeCanceler != nil { c.closeCanceler() } return } func (c *serverConn) updateDeadline() { switch { case c.initTimeout > 0: initTimeout := time.Now().Add(c.initTimeout) c.initTimeout = 0 if initTimeout.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() { c.Conn.SetDeadline(initTimeout) //nolint: errcheck return } case c.idleTimeout > 0: idleDeadline := time.Now().Add(c.idleTimeout) if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() { c.Conn.SetDeadline(idleDeadline) //nolint: errcheck return } } c.Conn.SetDeadline(c.maxDeadline) //nolint: errcheck } ================================================ FILE: pkg/daemon/daemon.go ================================================ package daemon import ( "bytes" "context" "errors" "fmt" "net" "path/filepath" "strings" "sync" "sync/atomic" "time" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/git" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/go-git/go-git/v5/plumbing/format/pktline" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var ( uploadPackGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "git_upload_pack_total", Help: "The total number of git-upload-pack requests", }, []string{"repo"}) uploadArchiveGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "git_upload_archive_total", Help: "The total number of git-upload-archive requests", }, []string{"repo"}) ) // ErrServerClosed indicates that the server has been closed. var ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed) // GitDaemon represents a Git daemon. type GitDaemon struct { ctx context.Context addr string finished chan struct{} conns connections cfg *config.Config be *backend.Backend wg sync.WaitGroup once sync.Once logger *log.Logger done atomic.Bool // indicates if the server has been closed listeners []net.Listener liMu sync.Mutex } // NewGitDaemon returns a new Git daemon. func NewGitDaemon(ctx context.Context) (*GitDaemon, error) { cfg := config.FromContext(ctx) addr := cfg.Git.ListenAddr d := &GitDaemon{ ctx: ctx, addr: addr, finished: make(chan struct{}, 1), cfg: cfg, be: backend.FromContext(ctx), conns: connections{m: make(map[net.Conn]struct{})}, logger: log.FromContext(ctx).WithPrefix("gitdaemon"), } return d, nil } // ListenAndServe starts the Git TCP daemon. func (d *GitDaemon) ListenAndServe() error { if d.done.Load() { return ErrServerClosed } var cfg net.ListenConfig listener, err := cfg.Listen(d.ctx, "tcp", d.addr) if err != nil { return err } return d.Serve(listener) } // Serve listens on the TCP network address and serves Git requests. func (d *GitDaemon) Serve(listener net.Listener) error { if d.done.Load() { return ErrServerClosed } d.wg.Add(1) defer d.wg.Done() d.liMu.Lock() d.listeners = append(d.listeners, listener) d.liMu.Unlock() var tempDelay time.Duration for { conn, err := listener.Accept() if err != nil { select { case <-d.finished: return ErrServerClosed default: d.logger.Debugf("git: error accepting connection: %v", err) } if ne, ok := err.(net.Error); ok && ne.Temporary() { if tempDelay == 0 { tempDelay = 5 * time.Millisecond } else { tempDelay *= 2 } if max := 1 * time.Second; tempDelay > max { //nolint:revive tempDelay = max } time.Sleep(tempDelay) continue } return err } // Close connection if there are too many open connections. if d.conns.Size()+1 >= d.cfg.Git.MaxConnections { d.logger.Debugf("git: max connections reached, closing %s", conn.RemoteAddr()) d.fatal(conn, git.ErrMaxConnections) continue } d.wg.Add(1) go func() { d.handleClient(conn) d.wg.Done() }() } } func (d *GitDaemon) fatal(c net.Conn, err error) { git.WritePktlineErr(c, err) //nolint: errcheck if err := c.Close(); err != nil { d.logger.Debugf("git: error closing connection: %v", err) } } // handleClient handles a git protocol client. func (d *GitDaemon) handleClient(conn net.Conn) { ctx, cancel := context.WithCancel(context.Background()) idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second c := &serverConn{ Conn: conn, idleTimeout: idleTimeout, closeCanceler: cancel, } if d.cfg.Git.MaxTimeout > 0 { dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second c.maxDeadline = time.Now().Add(dur) } d.conns.Add(c) defer func() { d.conns.Close(c) //nolint: errcheck }() errc := make(chan error, 1) s := pktline.NewScanner(c) go func() { if !s.Scan() { if err := s.Err(); err != nil { errc <- err } } errc <- nil }() select { case <-ctx.Done(): if err := ctx.Err(); err != nil { d.logger.Debugf("git: connection context error: %v", err) d.fatal(c, git.ErrTimeout) } return case err := <-errc: if nerr, ok := err.(net.Error); ok && nerr.Timeout() { d.fatal(c, git.ErrTimeout) return } else if err != nil { d.logger.Debugf("git: error scanning pktline: %v", err) d.fatal(c, git.ErrSystemMalfunction) return } line := s.Bytes() split := bytes.SplitN(line, []byte{' '}, 2) if len(split) != 2 { d.fatal(c, git.ErrInvalidRequest) return } var counter *prometheus.CounterVec service := git.Service(split[0]) switch service { case git.UploadPackService: counter = uploadPackGitCounter case git.UploadArchiveService: counter = uploadArchiveGitCounter default: d.fatal(c, git.ErrInvalidRequest) return } opts := bytes.SplitN(split[1], []byte{0}, 3) if len(opts) < 2 { d.fatal(c, git.ErrInvalidRequest) //nolint: errcheck return } host := strings.TrimPrefix(string(opts[1]), "host=") extraParams := map[string]string{} if len(opts) > 2 { buf := bytes.TrimPrefix(opts[2], []byte{0}) for _, o := range bytes.Split(buf, []byte{0}) { opt := string(o) if opt == "" { continue } kv := strings.SplitN(opt, "=", 2) if len(kv) != 2 { d.logger.Errorf("git: invalid option %q", opt) continue } extraParams[kv[0]] = kv[1] } version := extraParams["version"] if version != "" { d.logger.Debugf("git: protocol version %s", version) } } be := d.be if !be.AllowKeyless(ctx) { d.fatal(c, git.ErrNotAuthed) return } name := utils.SanitizeRepo(string(opts[0])) d.logger.Debugf("git: connect %s %s %s", c.RemoteAddr(), service, name) defer d.logger.Debugf("git: disconnect %s %s %s", c.RemoteAddr(), service, name) // git bare repositories should end in ".git" // https://git-scm.com/docs/gitrepository-layout repo := name + ".git" reposDir := filepath.Join(d.cfg.DataPath, "repos") if err := git.EnsureWithin(reposDir, repo); err != nil { d.logger.Debugf("git: error ensuring repo path: %v", err) d.fatal(c, git.ErrInvalidRepo) return } if _, err := d.be.Repository(ctx, repo); err != nil { d.fatal(c, git.ErrInvalidRepo) return } auth := be.AccessLevel(ctx, name, "") if auth < access.ReadOnlyAccess { d.fatal(c, git.ErrNotAuthed) return } // Environment variables to pass down to git hooks. envs := []string{ "SOFT_SERVE_REPO_NAME=" + name, "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo), "SOFT_SERVE_HOST=" + host, "SOFT_SERVE_LOG_PATH=" + filepath.Join(d.cfg.DataPath, "log", "hooks.log"), } // Add git protocol environment variable. if len(extraParams) > 0 { var gitProto string for k, v := range extraParams { if len(gitProto) > 0 { gitProto += ":" } gitProto += k + "=" + v } envs = append(envs, "GIT_PROTOCOL="+gitProto) } envs = append(envs, d.cfg.Environ()...) cmd := git.ServiceCommand{ Stdin: c, Stdout: c, Stderr: c, Env: envs, Dir: filepath.Join(reposDir, repo), } if err := service.Handler(ctx, cmd); err != nil { d.logger.Debugf("git: error handling request: %v", err) d.fatal(c, err) return } counter.WithLabelValues(name) } } // Close closes the underlying listener. func (d *GitDaemon) Close() error { err := d.closeListener() d.conns.CloseAll() //nolint: errcheck return err } // closeListener closes the listener and the finished channel. func (d *GitDaemon) closeListener() error { if d.done.Load() { return ErrServerClosed } var err error d.liMu.Lock() for _, l := range d.listeners { if err = l.Close(); err != nil { err = errors.Join(err, fmt.Errorf("close listener %s: %w", l.Addr(), err)) } } d.listeners = d.listeners[:0] d.liMu.Unlock() d.once.Do(func() { d.done.Store(true) close(d.finished) }) return err } // Shutdown gracefully shuts down the daemon. func (d *GitDaemon) Shutdown(ctx context.Context) error { if d.done.Load() { return ErrServerClosed } err := d.closeListener() finished := make(chan struct{}, 1) go func() { defer close(finished) d.wg.Wait() }() select { case <-ctx.Done(): return ctx.Err() case <-finished: return err } } ================================================ FILE: pkg/daemon/daemon_test.go ================================================ package daemon import ( "context" "fmt" "log" "net" "os" "strings" "testing" "time" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/migrate" "github.com/charmbracelet/soft-serve/pkg/git" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/store/database" "github.com/charmbracelet/soft-serve/pkg/test" "github.com/go-git/go-git/v5/plumbing/format/pktline" _ "modernc.org/sqlite" // sqlite driver ) var testDaemon *GitDaemon func TestMain(m *testing.M) { tmp, err := os.MkdirTemp("", "soft-serve-test") if err != nil { log.Fatal(err) } defer os.RemoveAll(tmp) ctx := context.TODO() cfg := config.DefaultConfig() cfg.DataPath = tmp cfg.Git.MaxConnections = 3 cfg.Git.MaxTimeout = 100 cfg.Git.IdleTimeout = 1 cfg.Git.ListenAddr = fmt.Sprintf(":%d", test.RandomPort()) if err := cfg.Validate(); err != nil { log.Fatal(err) } ctx = config.WithContext(ctx, cfg) dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource) if err != nil { log.Fatal(err) } defer dbx.Close() //nolint: errcheck if err := migrate.Migrate(ctx, dbx); err != nil { log.Fatal(err) } datastore := database.New(ctx, dbx) ctx = store.WithContext(ctx, datastore) be := backend.New(ctx, cfg, dbx, datastore) ctx = backend.WithContext(ctx, be) d, err := NewGitDaemon(ctx) if err != nil { log.Fatal(err) } testDaemon = d go d.ListenAndServe() //nolint:errcheck code := m.Run() os.Unsetenv("SOFT_SERVE_DATA_PATH") os.Unsetenv("SOFT_SERVE_GIT_MAX_CONNECTIONS") os.Unsetenv("SOFT_SERVE_GIT_MAX_TIMEOUT") os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT") os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR") _ = d.Close() _ = dbx.Close() os.Exit(code) } func TestIdleTimeout(t *testing.T) { var err error var c net.Conn var tries int var dialer net.Dialer for { c, err = dialer.DialContext(t.Context(), "tcp", testDaemon.addr) if err != nil && tries >= 3 { t.Fatalf("failed to connect to daemon after %d tries: %v", tries, err) } tries++ if testDaemon.conns.Size() != 0 { break } time.Sleep(10 * time.Millisecond) } time.Sleep(2 * time.Second) _, err = readPktline(c) if err == nil { t.Errorf("expected error, got nil") } } func TestInvalidRepo(t *testing.T) { c, err := net.Dial("tcp", testDaemon.addr) //nolint:noctx if err != nil { t.Fatalf("failed to connect to daemon: %v", err) } if err := pktline.NewEncoder(c).EncodeString("git-upload-pack /test.git\x00"); err != nil { t.Fatalf("expected nil, got error: %v", err) } _, err = readPktline(c) if err != nil && err.Error() != git.ErrInvalidRepo.Error() { t.Errorf("expected %q error, got %q", git.ErrInvalidRepo, err) } } func readPktline(c net.Conn) (string, error) { pktout := pktline.NewScanner(c) if !pktout.Scan() { return "", pktout.Err() } return strings.TrimSpace(string(pktout.Bytes())), nil } ================================================ FILE: pkg/db/context.go ================================================ package db import "context" // ContextKey is the key used to store the database in the context. var ContextKey = struct{ string }{"db"} // FromContext returns the database from the context. func FromContext(ctx context.Context) *DB { if db, ok := ctx.Value(ContextKey).(*DB); ok { return db } return nil } // WithContext returns a new context with the database. func WithContext(ctx context.Context, db *DB) context.Context { return context.WithValue(ctx, ContextKey, db) } ================================================ FILE: pkg/db/context_test.go ================================================ package db_test import ( "context" "testing" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/internal/test" ) func TestBadFromContext(t *testing.T) { ctx := context.TODO() if c := db.FromContext(ctx); c != nil { t.Errorf("FromContext(ctx) => %v, want %v", c, nil) } } func TestGoodFromContext(t *testing.T) { ctx := context.TODO() dbx, err := test.OpenSqlite(ctx, t) if err != nil { t.Fatal(err) } ctx = db.WithContext(ctx, dbx) if c := db.FromContext(ctx); c == nil { t.Errorf("FromContext(ctx) => %v, want %v", c, dbx) } } ================================================ FILE: pkg/db/db.go ================================================ package db import ( "context" "database/sql" "errors" "fmt" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" // postgres driver _ "modernc.org/sqlite" // sqlite driver ) // DB is the interface for a Soft Serve database. type DB struct { *sqlx.DB logger *log.Logger } // Open opens a database connection. func Open(ctx context.Context, driverName string, dsn string) (*DB, error) { db, err := sqlx.ConnectContext(ctx, driverName, dsn) if err != nil { return nil, err } d := &DB{ DB: db, } if config.IsVerbose() { logger := log.FromContext(ctx).WithPrefix("db") d.logger = logger } return d, nil } // Close implements db.DB. func (d *DB) Close() error { return d.DB.Close() } // Tx is a database transaction. type Tx struct { *sqlx.Tx logger *log.Logger } // Transaction implements db.DB. func (d *DB) Transaction(fn func(tx *Tx) error) error { return d.TransactionContext(context.Background(), fn) } // TransactionContext implements db.DB. func (d *DB) TransactionContext(ctx context.Context, fn func(tx *Tx) error) error { txx, err := d.DB.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } tx := &Tx{txx, d.logger} if err := fn(tx); err != nil { return rollback(tx, err) } if err := tx.Commit(); err != nil { if errors.Is(err, sql.ErrTxDone) { // this is ok because whoever did finish the tx should have also written the error already. return nil } return fmt.Errorf("failed to commit transaction: %w", err) } return nil } func rollback(tx *Tx, err error) error { if rerr := tx.Rollback(); rerr != nil { if errors.Is(rerr, sql.ErrTxDone) { return err } return fmt.Errorf("failed to rollback: %s: %w", err.Error(), rerr) } return err } ================================================ FILE: pkg/db/db_test.go ================================================ package db import ( "context" "strings" "testing" ) func TestOpenUnknownDriver(t *testing.T) { _, err := Open(context.TODO(), "invalid", "") if err == nil { t.Error("Open(invalid) => nil, want error") } if !strings.Contains(err.Error(), "unknown driver") { t.Errorf("Open(invalid) => %v, want error containing 'unknown driver'", err) } } ================================================ FILE: pkg/db/errors.go ================================================ package db import ( "database/sql" "errors" "github.com/lib/pq" sqlite "modernc.org/sqlite" sqlitelib "modernc.org/sqlite/lib" ) var ( // ErrDuplicateKey is a constraint violation error. ErrDuplicateKey = errors.New("duplicate key value violates table constraint") // ErrRecordNotFound is returned when a record is not found. ErrRecordNotFound = sql.ErrNoRows ) // WrapError is a convenient function that unite various database driver // errors to consistent errors. func WrapError(err error) error { if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrRecordNotFound } // Handle sqlite constraint error. if liteErr, ok := err.(*sqlite.Error); ok { code := liteErr.Code() if code == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY || code == sqlitelib.SQLITE_CONSTRAINT_FOREIGNKEY || code == sqlitelib.SQLITE_CONSTRAINT_UNIQUE { return ErrDuplicateKey } } // Handle postgres constraint error. if pgErr, ok := err.(*pq.Error); ok { if pgErr.Code == "23505" || pgErr.Code == "23503" || pgErr.Code == "23514" { return ErrDuplicateKey } } } return err } ================================================ FILE: pkg/db/errors_test.go ================================================ package db import ( "database/sql" "errors" "fmt" "testing" ) func TestWrapErrorBadNoRows(t *testing.T) { for _, e := range []error{ fmt.Errorf("foo"), errors.New("bar"), } { if err := WrapError(e); err != e { t.Errorf("WrapError(%v) => %v, want %v", e, err, e) } } } func TestWrapErrorGoodNoRows(t *testing.T) { if err := WrapError(sql.ErrNoRows); err != ErrRecordNotFound { t.Errorf("WrapError(sql.ErrNoRows) => %v, want %v", err, ErrRecordNotFound) } } ================================================ FILE: pkg/db/handler.go ================================================ package db import ( "context" "database/sql" "github.com/jmoiron/sqlx" ) // Handler is a database handler. type Handler interface { Rebind(string) string Select(interface{}, string, ...interface{}) error Get(interface{}, string, ...interface{}) error Queryx(string, ...interface{}) (*sqlx.Rows, error) QueryRowx(string, ...interface{}) *sqlx.Row Exec(string, ...interface{}) (sql.Result, error) SelectContext(context.Context, interface{}, string, ...interface{}) error GetContext(context.Context, interface{}, string, ...interface{}) error QueryxContext(context.Context, string, ...interface{}) (*sqlx.Rows, error) QueryRowxContext(context.Context, string, ...interface{}) *sqlx.Row ExecContext(context.Context, string, ...interface{}) (sql.Result, error) } ================================================ FILE: pkg/db/internal/test/test.go ================================================ package test import ( "context" "path/filepath" "testing" "github.com/charmbracelet/soft-serve/pkg/db" ) // OpenSqlite opens a new temp SQLite database for testing. // It removes the database file when the test is done using tb.Cleanup. // If ctx is nil, context.TODO() is used. func OpenSqlite(ctx context.Context, tb testing.TB) (*db.DB, error) { if ctx == nil { ctx = context.TODO() } dbpath := filepath.Join(tb.TempDir(), "test.db") dbx, err := db.Open(ctx, "sqlite", dbpath) if err != nil { return nil, err } tb.Cleanup(func() { if err := dbx.Close(); err != nil { tb.Error(err) } }) return dbx, nil } ================================================ FILE: pkg/db/logger.go ================================================ package db import ( "context" "database/sql" "strings" "charm.land/log/v2" "github.com/jmoiron/sqlx" ) func trace(l *log.Logger, query string, args ...interface{}) { if l != nil { // Remove newlines and tabs query = strings.ReplaceAll(query, "\t", "") query = strings.TrimSpace(query) l.Debug("trace", "query", query, "args", args) } } // Select is a wrapper around sqlx.Select that logs the query and arguments. func (d *DB) Select(dest interface{}, query string, args ...interface{}) error { trace(d.logger, query, args...) return d.DB.Select(dest, query, args...) } // Get is a wrapper around sqlx.Get that logs the query and arguments. func (d *DB) Get(dest interface{}, query string, args ...interface{}) error { trace(d.logger, query, args...) return d.DB.Get(dest, query, args...) } // Queryx is a wrapper around sqlx.Queryx that logs the query and arguments. func (d *DB) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) { trace(d.logger, query, args...) return d.DB.Queryx(query, args...) } // QueryRowx is a wrapper around sqlx.QueryRowx that logs the query and arguments. func (d *DB) QueryRowx(query string, args ...interface{}) *sqlx.Row { trace(d.logger, query, args...) return d.DB.QueryRowx(query, args...) } // Exec is a wrapper around sqlx.Exec that logs the query and arguments. // // Deprecated: Use [DB.ExecContext] instead. func (d *DB) Exec(query string, args ...interface{}) (sql.Result, error) { trace(d.logger, query, args...) return d.DB.Exec(query, args...) //nolint:noctx } // SelectContext is a wrapper around sqlx.SelectContext that logs the query and arguments. func (d *DB) SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { trace(d.logger, query, args...) return d.DB.SelectContext(ctx, dest, query, args...) } // GetContext is a wrapper around sqlx.GetContext that logs the query and arguments. func (d *DB) GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { trace(d.logger, query, args...) return d.DB.GetContext(ctx, dest, query, args...) } // QueryxContext is a wrapper around sqlx.QueryxContext that logs the query and arguments. func (d *DB) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { trace(d.logger, query, args...) return d.DB.QueryxContext(ctx, query, args...) } // QueryRowxContext is a wrapper around sqlx.QueryRowxContext that logs the query and arguments. func (d *DB) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row { trace(d.logger, query, args...) return d.DB.QueryRowxContext(ctx, query, args...) } // ExecContext is a wrapper around sqlx.ExecContext that logs the query and arguments. func (d *DB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { trace(d.logger, query, args...) return d.DB.ExecContext(ctx, query, args...) } // Select is a wrapper around sqlx.Select that logs the query and arguments. func (t *Tx) Select(dest interface{}, query string, args ...interface{}) error { trace(t.logger, query, args...) return t.Tx.Select(dest, query, args...) } // Get is a wrapper around sqlx.Get that logs the query and arguments. func (t *Tx) Get(dest interface{}, query string, args ...interface{}) error { trace(t.logger, query, args...) return t.Tx.Get(dest, query, args...) } // Queryx is a wrapper around sqlx.Queryx that logs the query and arguments. func (t *Tx) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) { trace(t.logger, query, args...) return t.Tx.Queryx(query, args...) } // QueryRowx is a wrapper around sqlx.QueryRowx that logs the query and arguments. func (t *Tx) QueryRowx(query string, args ...interface{}) *sqlx.Row { trace(t.logger, query, args...) return t.Tx.QueryRowx(query, args...) } // Exec is a wrapper around sqlx.Exec that logs the query and arguments. // // Deprecated: Use [Tx.ExecContext] instead. func (t *Tx) Exec(query string, args ...interface{}) (sql.Result, error) { trace(t.logger, query, args...) return t.Tx.Exec(query, args...) //nolint:noctx } // SelectContext is a wrapper around sqlx.SelectContext that logs the query and arguments. func (t *Tx) SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { trace(t.logger, query, args...) return t.Tx.SelectContext(ctx, dest, query, args...) } // GetContext is a wrapper around sqlx.GetContext that logs the query and arguments. func (t *Tx) GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { trace(t.logger, query, args...) return t.Tx.GetContext(ctx, dest, query, args...) } // QueryxContext is a wrapper around sqlx.QueryxContext that logs the query and arguments. func (t *Tx) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { trace(t.logger, query, args...) return t.Tx.QueryxContext(ctx, query, args...) } // QueryRowxContext is a wrapper around sqlx.QueryRowxContext that logs the query and arguments. func (t *Tx) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row { trace(t.logger, query, args...) return t.Tx.QueryRowxContext(ctx, query, args...) } // ExecContext is a wrapper around sqlx.ExecContext that logs the query and arguments. func (t *Tx) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { trace(t.logger, query, args...) return t.Tx.ExecContext(ctx, query, args...) } ================================================ FILE: pkg/db/migrate/0001_create_tables.go ================================================ package migrate import ( "context" "errors" "fmt" "strconv" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/sshutils" ) const ( createTablesName = "create tables" createTablesVersion = 1 ) var createTables = Migration{ Version: createTablesVersion, Name: createTablesName, Migrate: func(ctx context.Context, tx *db.Tx) error { cfg := config.FromContext(ctx) insert := "INSERT " // Alter old tables (if exist) // This is to support prior versions of Soft Serve v0.6 switch tx.DriverName() { case "sqlite3", "sqlite": insert += "OR IGNORE " hasUserTable := hasTable(tx, "user") if hasUserTable { if _, err := tx.ExecContext(ctx, "ALTER TABLE user RENAME TO user_old"); err != nil { return err } } if hasTable(tx, "public_key") { if _, err := tx.ExecContext(ctx, "ALTER TABLE public_key RENAME TO public_key_old"); err != nil { return err } } if hasTable(tx, "collab") { if _, err := tx.ExecContext(ctx, "ALTER TABLE collab RENAME TO collab_old"); err != nil { return err } } if hasTable(tx, "repo") { if _, err := tx.ExecContext(ctx, "ALTER TABLE repo RENAME TO repo_old"); err != nil { return err } } } if err := migrateUp(ctx, tx, createTablesVersion, createTablesName); err != nil { return err } switch tx.DriverName() { case "sqlite3", "sqlite": if _, err := tx.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil { return err } if hasTable(tx, "user_old") { sqlm := ` INSERT INTO users (id, username, admin, updated_at) SELECT id, username, admin, updated_at FROM user_old; ` if _, err := tx.ExecContext(ctx, sqlm); err != nil { return err } } if hasTable(tx, "public_key_old") { // Check duplicate keys pks := []struct { ID string `db:"id"` PublicKey string `db:"public_key"` }{} if err := tx.SelectContext(ctx, &pks, "SELECT id, public_key FROM public_key_old"); err != nil { return err } pkss := map[string]struct{}{} for _, pk := range pks { if _, ok := pkss[pk.PublicKey]; ok { return fmt.Errorf("duplicate public key: %q, please remove the duplicate key and try again", pk.PublicKey) } pkss[pk.PublicKey] = struct{}{} } sqlm := ` INSERT INTO public_keys (id, user_id, public_key, created_at, updated_at) SELECT id, user_id, public_key, created_at, updated_at FROM public_key_old; ` if _, err := tx.ExecContext(ctx, sqlm); err != nil { return err } } if hasTable(tx, "repo_old") { sqlm := ` INSERT INTO repos (id, name, project_name, description, private,mirror, hidden, created_at, updated_at, user_id) SELECT id, name, project_name, description, private, mirror, hidden, created_at, updated_at, ( SELECT id FROM users WHERE admin = true ORDER BY id LIMIT 1 ) FROM repo_old; ` if _, err := tx.ExecContext(ctx, sqlm); err != nil { return err } } if hasTable(tx, "collab_old") { sqlm := ` INSERT INTO collabs (id, user_id, repo_id, access_level, created_at, updated_at) SELECT id, user_id, repo_id, ` + strconv.Itoa(int(access.ReadWriteAccess)) + `, created_at, updated_at FROM collab_old; ` if _, err := tx.ExecContext(ctx, sqlm); err != nil { return err } } if _, err := tx.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { return err } } // Insert default user insertUser := tx.Rebind(insert + "INTO users (username, admin, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)") if _, err := tx.ExecContext(ctx, insertUser, "admin", true); err != nil { return err } for _, k := range cfg.AdminKeys() { query := insert + "INTO public_keys (user_id, public_key, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)" if tx.DriverName() == "postgres" { query += " ON CONFLICT DO NOTHING" } query = tx.Rebind(query) ak := sshutils.MarshalAuthorizedKey(k) if _, err := tx.ExecContext(ctx, query, 1, ak); err != nil { if errors.Is(db.WrapError(err), db.ErrDuplicateKey) { continue } return err } } // Insert default settings insertSettings := insert + "INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)" insertSettings = tx.Rebind(insertSettings) settings := []struct { Key string Value string }{ {"allow_keyless", "true"}, {"anon_access", access.ReadOnlyAccess.String()}, {"init", "true"}, } for _, s := range settings { if _, err := tx.ExecContext(ctx, insertSettings, s.Key, s.Value); err != nil { return fmt.Errorf("inserting default settings %q: %w", s.Key, err) } } return nil }, Rollback: func(ctx context.Context, tx *db.Tx) error { return migrateDown(ctx, tx, createTablesVersion, createTablesName) }, } ================================================ FILE: pkg/db/migrate/0001_create_tables_postgres.down.sql ================================================ ================================================ FILE: pkg/db/migrate/0001_create_tables_postgres.up.sql ================================================ CREATE TABLE IF NOT EXISTS settings ( id SERIAL PRIMARY KEY, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, admin BOOLEAN NOT NULL, password TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS public_keys ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, public_key TEXT NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL, CONSTRAINT user_id_fk FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS repos ( id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, project_name TEXT NOT NULL, description TEXT NOT NULL, private BOOLEAN NOT NULL, mirror BOOLEAN NOT NULL, hidden BOOLEAN NOT NULL, user_id INTEGER NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL, CONSTRAINT user_id_fk FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS collabs ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, repo_id INTEGER NOT NULL, access_level INTEGER NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL, UNIQUE (user_id, repo_id), CONSTRAINT user_id_fk FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT repo_id_fk FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS lfs_objects ( id SERIAL PRIMARY KEY, oid TEXT NOT NULL, size INTEGER NOT NULL, repo_id INTEGER NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL, UNIQUE (oid, repo_id), CONSTRAINT repo_id_fk FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS lfs_locks ( id SERIAL PRIMARY KEY, repo_id INTEGER NOT NULL, user_id INTEGER NOT NULL, path TEXT NOT NULL, refname TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL, UNIQUE (repo_id, path), CONSTRAINT repo_id_fk FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT user_id_fk FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS access_tokens ( id SERIAL PRIMARY KEY, name text NOT NULL, token TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL, expires_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL, CONSTRAINT user_id_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); ================================================ FILE: pkg/db/migrate/0001_create_tables_sqlite.down.sql ================================================ ================================================ FILE: pkg/db/migrate/0001_create_tables_sqlite.up.sql ================================================ CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL ); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, admin BOOLEAN NOT NULL, password TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL ); CREATE TABLE IF NOT EXISTS public_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, public_key TEXT NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL, CONSTRAINT user_id_fk FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS repos ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, project_name TEXT NOT NULL, description TEXT NOT NULL, private BOOLEAN NOT NULL, mirror BOOLEAN NOT NULL, hidden BOOLEAN NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL, CONSTRAINT user_id_fk FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS collabs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, repo_id INTEGER NOT NULL, access_level INTEGER NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL, UNIQUE (user_id, repo_id), CONSTRAINT user_id_fk FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT repo_id_fk FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS lfs_objects ( id INTEGER PRIMARY KEY AUTOINCREMENT, oid TEXT NOT NULL, size INTEGER NOT NULL, repo_id INTEGER NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL, UNIQUE (oid, repo_id), CONSTRAINT repo_id_fk FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS lfs_locks ( id INTEGER PRIMARY KEY AUTOINCREMENT, repo_id INTEGER NOT NULL, user_id INTEGER NOT NULL, path TEXT NOT NULL, refname TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL, UNIQUE (repo_id, path), CONSTRAINT repo_id_fk FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT user_id_fk FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS access_tokens ( id INTEGER primary key autoincrement, token text NOT NULL UNIQUE, name text NOT NULL, user_id INTEGER NOT NULL, expires_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL, CONSTRAINT user_id_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE ); ================================================ FILE: pkg/db/migrate/0002_webhooks.go ================================================ package migrate import ( "context" "github.com/charmbracelet/soft-serve/pkg/db" ) const ( webhooksName = "webhooks" webhooksVersion = 2 ) var webhooks = Migration{ Name: webhooksName, Version: webhooksVersion, Migrate: func(ctx context.Context, tx *db.Tx) error { return migrateUp(ctx, tx, webhooksVersion, webhooksName) }, Rollback: func(ctx context.Context, tx *db.Tx) error { return migrateDown(ctx, tx, webhooksVersion, webhooksName) }, } ================================================ FILE: pkg/db/migrate/0002_webhooks_postgres.down.sql ================================================ ================================================ FILE: pkg/db/migrate/0002_webhooks_postgres.up.sql ================================================ CREATE TABLE IF NOT EXISTS webhooks ( id SERIAL PRIMARY KEY, repo_id INTEGER NOT NULL, url TEXT NOT NULL, secret TEXT NOT NULL, content_type INTEGER NOT NULL, active BOOLEAN NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL, UNIQUE (repo_id, url), CONSTRAINT repo_id_fk FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS webhook_events ( id SERIAL PRIMARY KEY, webhook_id INTEGER NOT NULL, event INTEGER NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (webhook_id, event), CONSTRAINT webhook_id_fk FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS webhook_deliveries ( id TEXT PRIMARY KEY, webhook_id INTEGER NOT NULL, event INTEGER NOT NULL, request_url TEXT NOT NULL, request_method TEXT NOT NULL, request_error TEXT, request_headers TEXT NOT NULL, request_body TEXT NOT NULL, response_status INTEGER NOT NULL, response_headers TEXT NOT NULL, response_body TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT webhook_id_fk FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE ON UPDATE CASCADE ); ================================================ FILE: pkg/db/migrate/0002_webhooks_sqlite.down.sql ================================================ ================================================ FILE: pkg/db/migrate/0002_webhooks_sqlite.up.sql ================================================ CREATE TABLE IF NOT EXISTS webhooks ( id INTEGER PRIMARY KEY AUTOINCREMENT, repo_id INTEGER NOT NULL, url TEXT NOT NULL, secret TEXT NOT NULL, content_type INTEGER NOT NULL, active BOOLEAN NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL, UNIQUE (repo_id, url), CONSTRAINT repo_id_fk FOREIGN KEY(repo_id) REFERENCES repos(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS webhook_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, webhook_id INTEGER NOT NULL, event INTEGER NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (webhook_id, event), CONSTRAINT webhook_id_fk FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS webhook_deliveries ( id TEXT PRIMARY KEY, webhook_id INTEGER NOT NULL, event INTEGER NOT NULL, request_url TEXT NOT NULL, request_method TEXT NOT NULL, request_error TEXT, request_headers TEXT NOT NULL, request_body TEXT NOT NULL, response_status INTEGER NOT NULL, response_headers TEXT NOT NULL, response_body TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT webhook_id_fk FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE ON UPDATE CASCADE ); ================================================ FILE: pkg/db/migrate/0003_migrate_lfs_objects.go ================================================ package migrate import ( "context" "os" "path/filepath" "strconv" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" ) const ( migrateLfsObjectsName = "migrate_lfs_objects" migrateLfsObjectsVersion = 3 ) // Correct LFS objects relative path. // From OID[:2]/OID[2:4]/OID[4:] to OID[:2]/OID[2:4]/OID // See: https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#intercepting-git var migrateLfsObjects = Migration{ Name: migrateLfsObjectsName, Version: migrateLfsObjectsVersion, Migrate: func(ctx context.Context, tx *db.Tx) error { cfg := config.FromContext(ctx) logger := log.FromContext(ctx).WithPrefix("migrate_lfs_objects") var repoIDs []int64 if err := tx.Select(&repoIDs, "SELECT id FROM repos"); err != nil { return err } for _, r := range repoIDs { var objs []models.LFSObject if err := tx.Select(&objs, "SELECT * FROM lfs_objects WHERE repo_id = ?", r); err != nil { return err } objsp := filepath.Join(cfg.DataPath, "lfs", strconv.FormatInt(r, 10), "objects") for _, obj := range objs { oldpath := filepath.Join(objsp, badRelativePath(obj.Oid)) newpath := filepath.Join(objsp, goodRelativePath(obj.Oid)) if _, err := os.Stat(oldpath); err == nil { if err := os.Rename(oldpath, newpath); err != nil { logger.Error("rename lfs object", "oldpath", oldpath, "newpath", newpath, "err", err) continue } } } } return nil }, Rollback: func(context.Context, *db.Tx) error { return nil }, } func goodRelativePath(oid string) string { if len(oid) < 5 { return oid } return filepath.Join(oid[:2], oid[2:4], oid) } func badRelativePath(oid string) string { if len(oid) < 5 { return oid } return filepath.Join(oid[:2], oid[2:4], oid[4:]) } ================================================ FILE: pkg/db/migrate/migrate.go ================================================ package migrate import ( "context" "database/sql" "errors" "fmt" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/db" ) // MigrateFunc is a function that executes a migration. type MigrateFunc func(ctx context.Context, tx *db.Tx) error //nolint:revive // Migration is a struct that contains the name of the migration and the // function to execute it. type Migration struct { Version int64 Name string Migrate MigrateFunc Rollback MigrateFunc } // Migrations is a database model to store migrations. type Migrations struct { ID int64 `db:"id"` Name string `db:"name"` Version int64 `db:"version"` } func (Migrations) schema(driverName string) string { switch driverName { case "sqlite3", "sqlite": return `CREATE TABLE IF NOT EXISTS migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, version INTEGER NOT NULL UNIQUE ); ` case "postgres": return `CREATE TABLE IF NOT EXISTS migrations ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, version INTEGER NOT NULL UNIQUE ); ` case "mysql": return `CREATE TABLE IF NOT EXISTS migrations ( id INT NOT NULL AUTO_INCREMENT, name TEXT NOT NULL, version INT NOT NULL, UNIQUE (version), PRIMARY KEY (id) ); ` default: panic("unknown driver") } } // Migrate runs the migrations. func Migrate(ctx context.Context, dbx *db.DB) error { logger := log.FromContext(ctx).WithPrefix("migrate") return dbx.TransactionContext(ctx, func(tx *db.Tx) error { if !hasTable(tx, "migrations") { if _, err := tx.ExecContext(ctx, Migrations{}.schema(tx.DriverName())); err != nil { return err } } var migrs Migrations if err := tx.Get(&migrs, tx.Rebind("SELECT * FROM migrations ORDER BY version DESC LIMIT 1")); err != nil { if !errors.Is(err, sql.ErrNoRows) { return err } } for _, m := range migrations { if m.Version <= migrs.Version { continue } logger.Infof("running migration %d. %s", m.Version, m.Name) if err := m.Migrate(ctx, tx); err != nil { return err } if _, err := tx.ExecContext(ctx, tx.Rebind("INSERT INTO migrations (name, version) VALUES (?, ?)"), m.Name, m.Version); err != nil { return err } } return nil }) } // Rollback rolls back a migration. func Rollback(ctx context.Context, dbx *db.DB) error { logger := log.FromContext(ctx).WithPrefix("migrate") return dbx.TransactionContext(ctx, func(tx *db.Tx) error { var migrs Migrations if err := tx.Get(&migrs, tx.Rebind("SELECT * FROM migrations ORDER BY version DESC LIMIT 1")); err != nil { if !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("there are no migrations to rollback: %w", err) } } if migrs.Version == 0 || len(migrations) < int(migrs.Version) { return fmt.Errorf("there are no migrations to rollback") } m := migrations[migrs.Version-1] logger.Infof("rolling back migration %d. %s", m.Version, m.Name) if err := m.Rollback(ctx, tx); err != nil { return err } if _, err := tx.ExecContext(ctx, tx.Rebind("DELETE FROM migrations WHERE version = ?"), migrs.Version); err != nil { return err } return nil }) } func hasTable(tx *db.Tx, tableName string) bool { var query string switch tx.DriverName() { case "sqlite3", "sqlite": query = "SELECT name FROM sqlite_master WHERE type='table' AND name=?" case "postgres": fallthrough case "mysql": query = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?" } query = tx.Rebind(query) var name string err := tx.Get(&name, query, tableName) return err == nil } ================================================ FILE: pkg/db/migrate/migrate_test.go ================================================ package migrate import ( "context" "testing" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db/internal/test" ) func TestMigrate(t *testing.T) { // XXX: we need a config.Config in the context for the migrations to run // properly. Some migrations depend on the config being present. ctx := config.WithContext(context.TODO(), config.DefaultConfig()) dbx, err := test.OpenSqlite(ctx, t) if err != nil { t.Fatal(err) } if err := Migrate(ctx, dbx); err != nil { t.Errorf("Migrate() => %v, want nil error", err) } } ================================================ FILE: pkg/db/migrate/migrations.go ================================================ package migrate import ( "context" "embed" "fmt" "regexp" "strings" "github.com/charmbracelet/soft-serve/pkg/db" ) //go:embed *.sql var sqls embed.FS // Keep this in order of execution, oldest to newest. var migrations = []Migration{ createTables, webhooks, migrateLfsObjects, } func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error { direction := "up" if down { direction = "down" } driverName := tx.DriverName() if driverName == "sqlite3" { driverName = "sqlite" } fn := fmt.Sprintf("%04d_%s_%s.%s.sql", version, toSnakeCase(name), driverName, direction) sqlstr, err := sqls.ReadFile(fn) if err != nil { return err } if _, err := tx.ExecContext(ctx, string(sqlstr)); err != nil { return err } return nil } func migrateUp(ctx context.Context, tx *db.Tx, version int, name string) error { return execMigration(ctx, tx, version, name, false) } func migrateDown(ctx context.Context, tx *db.Tx, version int, name string) error { return execMigration(ctx, tx, version, name, true) } var ( matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") ) func toSnakeCase(str string) string { str = strings.ReplaceAll(str, "-", "_") str = strings.ReplaceAll(str, " ", "_") snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") return strings.ToLower(snake) } ================================================ FILE: pkg/db/models/access_token.go ================================================ package models import ( "database/sql" "time" ) // AccessToken represents an access token. type AccessToken struct { ID int64 `db:"id"` Name string `db:"name"` UserID int64 `db:"user_id"` Token string `db:"token"` ExpiresAt sql.NullTime `db:"expires_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } ================================================ FILE: pkg/db/models/collab.go ================================================ package models import ( "time" "github.com/charmbracelet/soft-serve/pkg/access" ) // Collab represents a repository collaborator. type Collab struct { ID int64 `db:"id"` RepoID int64 `db:"repo_id"` UserID int64 `db:"user_id"` AccessLevel access.AccessLevel `db:"access_level"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } ================================================ FILE: pkg/db/models/lfs.go ================================================ package models import "time" // LFSObject is a Git LFS object. type LFSObject struct { ID int64 `db:"id"` Oid string `db:"oid"` Size int64 `db:"size"` RepoID int64 `db:"repo_id"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } // LFSLock is a Git LFS lock. type LFSLock struct { ID int64 `db:"id"` Path string `db:"path"` UserID int64 `db:"user_id"` RepoID int64 `db:"repo_id"` Refname string `db:"refname"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } ================================================ FILE: pkg/db/models/public_key.go ================================================ package models // PublicKey represents a public key. type PublicKey struct { ID int64 `db:"id"` UserID int64 `db:"user_id"` PublicKey string `db:"public_key"` CreatedAt string `db:"created_at"` UpdatedAt string `db:"updated_at"` } ================================================ FILE: pkg/db/models/repo.go ================================================ package models import ( "database/sql" "time" ) // Repo is a database model for a repository. type Repo struct { ID int64 `db:"id"` Name string `db:"name"` ProjectName string `db:"project_name"` Description string `db:"description"` Private bool `db:"private"` Mirror bool `db:"mirror"` Hidden bool `db:"hidden"` UserID sql.NullInt64 `db:"user_id"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } ================================================ FILE: pkg/db/models/settings.go ================================================ package models // Settings represents a settings record. type Settings struct { ID int64 `db:"id"` Key string `db:"key"` Value string `db:"value"` CreatedAt string `db:"created_at"` UpdatedAt string `db:"updated_at"` } ================================================ FILE: pkg/db/models/user.go ================================================ package models import ( "database/sql" "time" ) // User represents a user. type User struct { ID int64 `db:"id"` Username string `db:"username"` Admin bool `db:"admin"` Password sql.NullString `db:"password"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } ================================================ FILE: pkg/db/models/webhook.go ================================================ package models import ( "database/sql" "time" "github.com/google/uuid" ) // Webhook is a repository webhook. type Webhook struct { ID int64 `db:"id"` RepoID int64 `db:"repo_id"` URL string `db:"url"` Secret string `db:"secret"` ContentType int `db:"content_type"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } // WebhookEvent is a webhook event. type WebhookEvent struct { ID int64 `db:"id"` WebhookID int64 `db:"webhook_id"` Event int `db:"event"` CreatedAt time.Time `db:"created_at"` } // WebhookDelivery is a webhook delivery. type WebhookDelivery struct { ID uuid.UUID `db:"id"` WebhookID int64 `db:"webhook_id"` Event int `db:"event"` RequestURL string `db:"request_url"` RequestMethod string `db:"request_method"` RequestError sql.NullString `db:"request_error"` RequestHeaders string `db:"request_headers"` RequestBody string `db:"request_body"` ResponseStatus int `db:"response_status"` ResponseHeaders string `db:"response_headers"` ResponseBody string `db:"response_body"` CreatedAt time.Time `db:"created_at"` } ================================================ FILE: pkg/git/errors.go ================================================ package git import "errors" var ( // ErrNotAuthed represents unauthorized access. ErrNotAuthed = errors.New("you are not authorized to do this") // ErrSystemMalfunction represents a general system error returned to clients. ErrSystemMalfunction = errors.New("something went wrong") // ErrInvalidRepo represents an attempt to access a non-existent repo. ErrInvalidRepo = errors.New("invalid repo") // ErrInvalidRequest represents an invalid request. ErrInvalidRequest = errors.New("invalid request") // ErrMaxConnections represents a maximum connection limit being reached. ErrMaxConnections = errors.New("too many connections, try again later") // ErrTimeout is returned when the maximum read timeout is exceeded. ErrTimeout = errors.New("I/O timeout reached") ) ================================================ FILE: pkg/git/git.go ================================================ package git import ( "context" "errors" "fmt" "io" "path/filepath" "strings" "charm.land/log/v2" gitm "github.com/aymanbagabas/git-module" "github.com/charmbracelet/soft-serve/git" "github.com/go-git/go-git/v5/plumbing/format/pktline" ) // ErrNoBranches is returned when a repo has no branches. var ErrNoBranches = errors.New("no branches found") // WritePktline encodes and writes a pktline to the given writer. func WritePktline(w io.Writer, v ...interface{}) error { msg := fmt.Sprintln(v...) pkt := pktline.NewEncoder(w) if err := pkt.EncodeString(msg); err != nil { return fmt.Errorf("git: error writing pkt-line message: %w", err) } if err := pkt.Flush(); err != nil { return fmt.Errorf("git: error flushing pkt-line message: %w", err) } return nil } // WritePktlineErr writes an error pktline to the given writer. func WritePktlineErr(w io.Writer, err error) error { return WritePktline(w, "ERR", err.Error()) } // EnsureWithin ensures the given repo is within the repos directory. func EnsureWithin(reposDir string, repo string) error { repoDir := filepath.Join(reposDir, repo) absRepos, err := filepath.Abs(reposDir) if err != nil { log.Debugf("failed to get absolute path for repo: %s", err) return ErrSystemMalfunction } absRepo, err := filepath.Abs(repoDir) if err != nil { log.Debugf("failed to get absolute path for repos: %s", err) return ErrSystemMalfunction } // ensure the repo is within the repos directory if !strings.HasPrefix(absRepo, absRepos) { log.Debugf("repo path is outside of repos directory: %s", absRepo) return ErrInvalidRepo } return nil } // EnsureDefaultBranch ensures the repo has a default branch. // It will prefer choosing "main" or "master" if available. func EnsureDefaultBranch(ctx context.Context, repoPath string) error { r, err := git.Open(repoPath) if err != nil { return err } brs, err := r.Branches() if len(brs) == 0 { return ErrNoBranches } if err != nil { return err } // Rename the default branch to the first branch available _, err = r.HEAD() if err == git.ErrReferenceNotExist { branch := brs[0] // Prefer "main" or "master" as the default branch for _, b := range brs { if b == "main" || b == "master" { branch = b break } } if _, err := r.SymbolicRef(git.HEAD, git.RefsHeads+branch, gitm.SymbolicRefOptions{ CommandOptions: gitm.CommandOptions{ Context: ctx, }, }); err != nil { return err } } if err != nil && err != git.ErrReferenceNotExist { return err } return nil } ================================================ FILE: pkg/git/git_test.go ================================================ package git import ( "bytes" "context" "errors" "fmt" "testing" "github.com/charmbracelet/soft-serve/git" ) func TestPktline(t *testing.T) { cases := []struct { name string in []byte err error out []byte }{ { name: "empty", in: []byte{}, out: []byte("0005\n0000"), }, { name: "simple", in: []byte("hello"), out: []byte("000ahello\n0000"), }, { name: "newline", in: []byte("hello\n"), out: []byte("000bhello\n\n0000"), }, { name: "error", err: fmt.Errorf("foobar"), out: []byte("000fERR foobar\n0000"), }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { var out bytes.Buffer if c.err == nil { if err := WritePktline(&out, string(c.in)); err != nil { t.Fatal(err) } } else { if err := WritePktlineErr(&out, c.err); err != nil { t.Fatal(err) } } if !bytes.Equal(out.Bytes(), c.out) { t.Errorf("expected %q, got %q", c.out, out.Bytes()) } }) } } func TestEnsureWithinBad(t *testing.T) { tmp := t.TempDir() for _, f := range []string{ "..", "../../../", } { if err := EnsureWithin(tmp, f); err == nil { t.Errorf("EnsureWithin(%q, %q) => nil, want non-nil error", tmp, f) } } } func TestEnsureWithinGood(t *testing.T) { tmp := t.TempDir() for _, f := range []string{ tmp, tmp + "/foo", tmp + "/foo/bar", } { if err := EnsureWithin(tmp, f); err != nil { t.Errorf("EnsureWithin(%q, %q) => %v, want nil error", tmp, f, err) } } } func TestEnsureDefaultBranchEmpty(t *testing.T) { tmp := t.TempDir() r, err := git.Init(tmp, false) if err != nil { t.Fatal(err) } if err := EnsureDefaultBranch(context.TODO(), r.Path); !errors.Is(err, ErrNoBranches) { t.Errorf("EnsureDefaultBranch(%q) => %v, want ErrNoBranches", tmp, err) } } ================================================ FILE: pkg/git/lfs.go ================================================ package git import ( "context" "crypto/rand" "errors" "fmt" "io" "path" "path/filepath" "strconv" "time" "charm.land/log/v2" "github.com/charmbracelet/git-lfs-transfer/transfer" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/lfs" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/storage" "github.com/charmbracelet/soft-serve/pkg/store" ) // lfsTransfer implements transfer.Backend. type lfsTransfer struct { ctx context.Context cfg *config.Config dbx *db.DB store store.Store logger *log.Logger storage storage.Storage repo proto.Repository } var _ transfer.Backend = &lfsTransfer{} // LFSTransfer is a Git LFS transfer service handler. // ctx is expected to have proto.User, *backend.Backend, *log.Logger, // *config.Config, *db.DB, and store.Store. // The first arg in cmd.Args should be the repo path. // The second arg in cmd.Args should be the LFS operation (download or upload). func LFSTransfer(ctx context.Context, cmd ServiceCommand) error { if len(cmd.Args) < 2 { return errors.New("missing args") } op := cmd.Args[1] if op != lfs.OperationDownload && op != lfs.OperationUpload { return errors.New("invalid operation") } logger := log.FromContext(ctx).WithPrefix("lfs-transfer") handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout, &lfsLogger{logger}) repo := proto.RepositoryFromContext(ctx) if repo == nil { logger.Error("no repository in context") return proto.ErrRepoNotFound } // Advertise capabilities. for _, cap := range transfer.Capabilities { if err := handler.WritePacketText(cap); err != nil { logger.Errorf("error sending capability: %s: %v", cap, err) return err } } if err := handler.WriteFlush(); err != nil { logger.Error("error sending flush", "err", err) return err } repoID := strconv.FormatInt(repo.ID(), 10) cfg := config.FromContext(ctx) processor := transfer.NewProcessor(handler, &lfsTransfer{ ctx: ctx, cfg: cfg, dbx: db.FromContext(ctx), store: store.FromContext(ctx), logger: logger, storage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)), repo: repo, }, &lfsLogger{logger}) return processor.ProcessCommands(op) } // Batch implements transfer.Backend. func (t *lfsTransfer) Batch(_ string, pointers []transfer.BatchItem, _ transfer.Args) ([]transfer.BatchItem, error) { for i := range pointers { obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointers[i].Oid) if err != nil && !errors.Is(err, db.ErrRecordNotFound) { return pointers, db.WrapError(err) } pointers[i].Present, err = t.storage.Exists(path.Join("objects", pointers[i].RelativePath())) if err != nil { return pointers, err } if pointers[i].Present && obj.ID == 0 { if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), pointers[i].Oid, pointers[i].Size); err != nil { return pointers, db.WrapError(err) } } } return pointers, nil } // Download implements transfer.Backend. func (t *lfsTransfer) Download(oid string, _ transfer.Args) (io.ReadCloser, int64, error) { cfg := config.FromContext(t.ctx) repoID := strconv.FormatInt(t.repo.ID(), 10) strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) pointer := transfer.Pointer{Oid: oid} obj, err := strg.Open(path.Join("objects", pointer.RelativePath())) if err != nil { return nil, 0, err } stat, err := obj.Stat() if err != nil { return nil, 0, err } return obj, stat.Size(), nil } // Upload implements transfer.Backend. func (t *lfsTransfer) Upload(oid string, size int64, r io.Reader, _ transfer.Args) error { if r == nil { return fmt.Errorf("no reader: %w", transfer.ErrMissingData) } tempDir := "incomplete" randBytes := make([]byte, 12) if _, err := rand.Read(randBytes); err != nil { return err } tempName := fmt.Sprintf("%s%x", oid, randBytes) tempName = path.Join(tempDir, tempName) written, err := t.storage.Put(tempName, r) if err != nil { t.logger.Errorf("error putting object: %v", err) return err } obj, err := t.storage.Open(tempName) if err != nil { t.logger.Errorf("error opening object: %v", err) return err } pointer := transfer.Pointer{ Oid: oid, } if size > 0 { pointer.Size = size } else { pointer.Size = written } if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), pointer.Oid, pointer.Size); err != nil { return db.WrapError(err) } expectedPath := path.Join("objects", pointer.RelativePath()) if err := t.storage.Rename(obj.Name(), expectedPath); err != nil { t.logger.Errorf("error renaming object: %v", err) _ = t.store.DeleteLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointer.Oid) return err } return nil } // Verify implements transfer.Backend. func (t *lfsTransfer) Verify(oid string, size int64, _ transfer.Args) (transfer.Status, error) { obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), oid) if err != nil { if errors.Is(err, db.ErrRecordNotFound) { return transfer.NewStatus(transfer.StatusNotFound, "object not found"), nil } t.logger.Errorf("error getting object: %v", err) return nil, err } if obj.Size != size { t.logger.Errorf("size mismatch: %d != %d", obj.Size, size) return transfer.NewStatus(transfer.StatusConflict, "size mismatch"), nil } return transfer.SuccessStatus(), nil } type lfsLockBackend struct { *lfsTransfer args map[string]string user proto.User } var _ transfer.LockBackend = (*lfsLockBackend)(nil) // LockBackend implements transfer.Backend. func (t *lfsTransfer) LockBackend(args transfer.Args) transfer.LockBackend { user := proto.UserFromContext(t.ctx) if user == nil { t.logger.Errorf("no user in context while creating lock backend, repo %s", t.repo.Name()) return nil } return &lfsLockBackend{t, args, user} } // Create implements transfer.LockBackend. func (l *lfsLockBackend) Create(path string, refname string) (transfer.Lock, error) { var lock LFSLock if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { if err := l.store.CreateLFSLockForUser(l.ctx, tx, l.repo.ID(), l.user.ID(), path, refname); err != nil { return db.WrapError(err) } var err error lock.lock, err = l.store.GetLFSLockForUserPath(l.ctx, tx, l.repo.ID(), l.user.ID(), path) if err != nil { return db.WrapError(err) } lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) return db.WrapError(err) }); err != nil { // Return conflict (409) if the lock already exists. if errors.Is(err, db.ErrDuplicateKey) { return nil, transfer.ErrConflict } l.logger.Errorf("error creating lock: %v", err) return nil, err } lock.backend = l return &lock, nil } // FromID implements transfer.LockBackend. func (l *lfsLockBackend) FromID(id string) (transfer.Lock, error) { var lock LFSLock iid, err := strconv.ParseInt(id, 10, 64) if err != nil { return nil, err } if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { var err error lock.lock, err = l.store.GetLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), iid) if err != nil { return db.WrapError(err) } lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) return db.WrapError(err) }); err != nil { if errors.Is(err, db.ErrRecordNotFound) { return nil, transfer.ErrNotFound } l.logger.Errorf("error getting lock: %v", err) return nil, err } lock.backend = l return &lock, nil } // FromPath implements transfer.LockBackend. func (l *lfsLockBackend) FromPath(path string) (transfer.Lock, error) { var lock LFSLock if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { var err error lock.lock, err = l.store.GetLFSLockForUserPath(l.ctx, tx, l.repo.ID(), l.user.ID(), path) if err != nil { return db.WrapError(err) } lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) return db.WrapError(err) }); err != nil { if errors.Is(err, db.ErrRecordNotFound) { return nil, transfer.ErrNotFound } l.logger.Errorf("error getting lock: %v", err) return nil, err } lock.backend = l return &lock, nil } // Range implements transfer.LockBackend. func (l *lfsLockBackend) Range(cursor string, limit int, fn func(transfer.Lock) error) (string, error) { var nextCursor string var locks []*LFSLock page, _ := strconv.Atoi(cursor) if page <= 0 { page = 1 } if limit <= 0 { limit = lfs.DefaultLocksLimit } else if limit > 100 { limit = 100 } if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { l.logger.Debug("getting locks", "limit", limit, "page", page) mlocks, err := l.store.GetLFSLocks(l.ctx, tx, l.repo.ID(), page, limit) if err != nil { return db.WrapError(err) } if len(mlocks) == limit { nextCursor = strconv.Itoa(page + 1) } users := make(map[int64]models.User, 0) for _, mlock := range mlocks { owner, ok := users[mlock.UserID] if !ok { owner, err = l.store.GetUserByID(l.ctx, tx, mlock.UserID) if err != nil { return db.WrapError(err) } users[mlock.UserID] = owner } locks = append(locks, &LFSLock{lock: mlock, owner: owner, backend: l}) } return nil }); err != nil { return "", err } for _, lock := range locks { if err := fn(lock); err != nil { return "", err } } return nextCursor, nil } // Unlock implements transfer.LockBackend. func (l *lfsLockBackend) Unlock(lock transfer.Lock) error { id, err := strconv.ParseInt(lock.ID(), 10, 64) if err != nil { return err } err = l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { return db.WrapError( l.store.DeleteLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), id), ) }) if err != nil { if errors.Is(err, db.ErrRecordNotFound) { return transfer.ErrNotFound } l.logger.Error("error unlocking lock", "err", err) return err } return nil } // LFSLock is a Git LFS lock object. // It implements transfer.Lock. type LFSLock struct { lock models.LFSLock owner models.User backend *lfsLockBackend } var _ transfer.Lock = (*LFSLock)(nil) // AsArguments implements transfer.Lock. func (l *LFSLock) AsArguments() []string { return []string{ fmt.Sprintf("id=%s", l.ID()), fmt.Sprintf("path=%s", l.Path()), fmt.Sprintf("locked-at=%s", l.FormattedTimestamp()), fmt.Sprintf("ownername=%s", l.OwnerName()), } } // AsLockSpec implements transfer.Lock. func (l *LFSLock) AsLockSpec(ownerID bool) ([]string, error) { id := l.ID() spec := []string{ fmt.Sprintf("lock %s", id), fmt.Sprintf("path %s %s", id, l.Path()), fmt.Sprintf("locked-at %s %s", id, l.FormattedTimestamp()), fmt.Sprintf("ownername %s %s", id, l.OwnerName()), } if ownerID { who := "theirs" if l.lock.UserID == l.owner.ID { who = "ours" } spec = append(spec, fmt.Sprintf("owner %s %s", id, who)) } return spec, nil } // FormattedTimestamp implements transfer.Lock. func (l *LFSLock) FormattedTimestamp() string { return l.lock.CreatedAt.Format(time.RFC3339) } // ID implements transfer.Lock. func (l *LFSLock) ID() string { return strconv.FormatInt(l.lock.ID, 10) } // OwnerName implements transfer.Lock. func (l *LFSLock) OwnerName() string { return l.owner.Username } // Path implements transfer.Lock. func (l *LFSLock) Path() string { return l.lock.Path } // Unlock implements transfer.Lock. func (l *LFSLock) Unlock() error { return l.backend.Unlock(l) } ================================================ FILE: pkg/git/lfs_auth.go ================================================ package git import ( "context" "encoding/json" "errors" "fmt" "time" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/jwk" "github.com/charmbracelet/soft-serve/pkg/lfs" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/golang-jwt/jwt/v5" ) // LFSAuthenticate implements the Git LFS SSH authentication command. // Context must have *config.Config, *log.Logger, proto.User. // cmd.Args should have the repo path and operation as arguments. func LFSAuthenticate(ctx context.Context, cmd ServiceCommand) error { if len(cmd.Args) < 2 { return errors.New("missing args") } logger := log.FromContext(ctx).WithPrefix("ssh.lfs-authenticate") operation := cmd.Args[1] if operation != lfs.OperationDownload && operation != lfs.OperationUpload { logger.Errorf("invalid operation: %s", operation) return errors.New("invalid operation") } user := proto.UserFromContext(ctx) if user == nil { logger.Errorf("missing user") return proto.ErrUserNotFound } repo := proto.RepositoryFromContext(ctx) if repo == nil { logger.Errorf("missing repository") return proto.ErrRepoNotFound } cfg := config.FromContext(ctx) kp, err := jwk.NewPair(cfg) if err != nil { logger.Error("failed to get JWK pair", "err", err) return err } now := time.Now() expiresIn := time.Minute * 5 expiresAt := now.Add(expiresIn) claims := jwt.RegisteredClaims{ Subject: fmt.Sprintf("%s#%d", user.Username(), user.ID()), ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour NotBefore: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now), Issuer: cfg.HTTP.PublicURL, Audience: []string{ repo.Name(), }, } token := jwt.NewWithClaims(jwk.SigningMethod, claims) token.Header["kid"] = kp.JWK().KeyID j, err := token.SignedString(kp.PrivateKey()) if err != nil { logger.Error("failed to sign token", "err", err) return err } href := fmt.Sprintf("%s/%s.git/info/lfs", cfg.HTTP.PublicURL, repo.Name()) logger.Debug("generated token", "token", j, "href", href, "expires_at", expiresAt) return json.NewEncoder(cmd.Stdout).Encode(lfs.AuthenticateResponse{ Header: map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", j), }, Href: href, ExpiresAt: expiresAt, ExpiresIn: expiresIn, }) } ================================================ FILE: pkg/git/lfs_log.go ================================================ package git import ( "charm.land/log/v2" "github.com/charmbracelet/git-lfs-transfer/transfer" ) type lfsLogger struct { l *log.Logger } var _ transfer.Logger = &lfsLogger{} // Log implements transfer.Logger. func (l *lfsLogger) Log(msg string, kv ...interface{}) { l.l.Debug(msg, kv...) } ================================================ FILE: pkg/git/service.go ================================================ package git import ( "context" "errors" "fmt" "io" "os" "os/exec" "strings" "sync" "charm.land/log/v2" ) // Service is a Git daemon service. type Service string const ( // UploadPackService is the upload-pack service. UploadPackService Service = "git-upload-pack" // UploadArchiveService is the upload-archive service. UploadArchiveService Service = "git-upload-archive" // ReceivePackService is the receive-pack service. ReceivePackService Service = "git-receive-pack" // LFSTransferService is the LFS transfer service. LFSTransferService Service = "git-lfs-transfer" // LFSAuthenticateService is the LFS authenticate service. LFSAuthenticateService = "git-lfs-authenticate" ) // String returns the string representation of the service. func (s Service) String() string { return string(s) } // Name returns the name of the service. func (s Service) Name() string { return strings.TrimPrefix(s.String(), "git-") } // Handler is the service handler. func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error { switch s { case UploadPackService, UploadArchiveService, ReceivePackService: return gitServiceHandler(ctx, s, cmd) case LFSTransferService: return LFSTransfer(ctx, cmd) case LFSAuthenticateService: return LFSAuthenticate(ctx, cmd) default: return fmt.Errorf("unsupported service: %s", s) } } // ServiceHandler is a git service command handler. type ServiceHandler func(ctx context.Context, cmd ServiceCommand) error // gitServiceHandler is the default service handler using the git binary. func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) error { cmd := exec.CommandContext(ctx, "git") cmd.Dir = scmd.Dir cmd.Args = append(cmd.Args, []string{ // Enable partial clones "-c", "uploadpack.allowFilter=true", // Enable push options "-c", "receive.advertisePushOptions=true", // Disable LFS filters "-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=", svc.Name(), }...) if len(scmd.Args) > 0 { cmd.Args = append(cmd.Args, scmd.Args...) } cmd.Args = append(cmd.Args, ".") cmd.Env = os.Environ() if len(scmd.Env) > 0 { cmd.Env = append(cmd.Env, scmd.Env...) } if scmd.CmdFunc != nil { scmd.CmdFunc(cmd) } var ( err error stdin io.WriteCloser stdout io.ReadCloser stderr io.ReadCloser ) if scmd.Stdin != nil { stdin, err = cmd.StdinPipe() if err != nil { return err } } if scmd.Stdout != nil { stdout, err = cmd.StdoutPipe() if err != nil { return err } } if scmd.Stderr != nil { stderr, err = cmd.StderrPipe() if err != nil { return err } } if err := cmd.Start(); err != nil { if errors.Is(err, os.ErrNotExist) { return ErrInvalidRepo } return err } wg := &sync.WaitGroup{} // stdin if scmd.Stdin != nil { go func() { defer stdin.Close() //nolint: errcheck if _, err := io.Copy(stdin, scmd.Stdin); err != nil { log.Errorf("gitServiceHandler: failed to copy stdin: %v", err) } }() } // stdout if scmd.Stdout != nil { wg.Add(1) go func() { defer wg.Done() if _, err := io.Copy(scmd.Stdout, stdout); err != nil { log.Errorf("gitServiceHandler: failed to copy stdout: %v", err) } }() } // stderr if scmd.Stderr != nil { wg.Add(1) go func() { defer wg.Done() if _, erro := io.Copy(scmd.Stderr, stderr); err != nil { log.Errorf("gitServiceHandler: failed to copy stderr: %v", erro) } }() } // Ensure all the output is written before waiting for the command to // finish. // Stdin is handled by the client side. wg.Wait() err = cmd.Wait() if err != nil && errors.Is(err, os.ErrNotExist) { return ErrInvalidRepo } else if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { return fmt.Errorf("%s: %s", exitErr, exitErr.Stderr) } return err } return nil } // ServiceCommand is used to run a git service command. type ServiceCommand struct { Stdin io.Reader Stdout io.Writer Stderr io.Writer Dir string Env []string Args []string // Modifier functions CmdFunc func(*exec.Cmd) } // UploadPack runs the git upload-pack protocol against the provided repo. func UploadPack(ctx context.Context, cmd ServiceCommand) error { return gitServiceHandler(ctx, UploadPackService, cmd) } // UploadArchive runs the git upload-archive protocol against the provided repo. func UploadArchive(ctx context.Context, cmd ServiceCommand) error { return gitServiceHandler(ctx, UploadArchiveService, cmd) } // ReceivePack runs the git receive-pack protocol against the provided repo. func ReceivePack(ctx context.Context, cmd ServiceCommand) error { return gitServiceHandler(ctx, ReceivePackService, cmd) } ================================================ FILE: pkg/hooks/gen.go ================================================ package hooks import ( "bytes" "context" "os" "path/filepath" "text/template" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/utils" ) // The names of git server-side hooks. const ( PreReceiveHook = "pre-receive" UpdateHook = "update" PostReceiveHook = "post-receive" PostUpdateHook = "post-update" ) // GenerateHooks generates git server-side hooks for a repository. Currently, it supports the following hooks: // - pre-receive // - update // - post-receive // - post-update // // This function should be called by the backend when a repository is created. // TODO: support context. func GenerateHooks(_ context.Context, cfg *config.Config, repo string) error { repo = utils.SanitizeRepo(repo) + ".git" hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks") if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil { return err } for _, hook := range []string{ PreReceiveHook, UpdateHook, PostReceiveHook, PostUpdateHook, } { var data bytes.Buffer var args string // Hooks script/directory path hp := filepath.Join(hooksPath, hook) // Write the hooks primary script if err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil { //nolint:gosec return err } // Create ${hook}.d directory. hp += ".d" if err := os.MkdirAll(hp, os.ModePerm); err != nil { return err } switch hook { case UpdateHook: args = "$1 $2 $3" case PostUpdateHook: args = "$@" } if err := hooksTmpl.Execute(&data, struct { Executable string Hook string Args string }{ Executable: "\"${SOFT_SERVE_BIN_PATH}\"", Hook: hook, Args: args, }); err != nil { log.WithPrefix("hooks").Error("failed to execute hook template", "err", err) continue } // Write the soft-serve hook inside ${hook}.d directory. hp = filepath.Join(hp, "soft-serve") err := os.WriteFile(hp, data.Bytes(), os.ModePerm) //nolint:gosec if err != nil { log.WithPrefix("hooks").Error("failed to write hook", "err", err) continue } } return nil } const ( // hookTemplate allows us to run multiple hooks from a directory. It should // support every type of git hook, as it proxies both stdin and arguments. hookTemplate = `#!/usr/bin/env bash # AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY data=$(cat) exitcodes="" hookname=$(basename $0) GIT_DIR=${GIT_DIR:-$(dirname $0)/..} for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do # Avoid running non-executable hooks test -x "${hook}" && test -f "${hook}" || continue # Run the actual hook echo "${data}" | "${hook}" "$@" # Store the exit code for later use exitcodes="${exitcodes} $?" done # Exit on the first non-zero exit code. for i in ${exitcodes}; do [ ${i} -eq 0 ] || exit ${i} done ` ) // hooksTmpl is the soft-serve hook that will be run by the git hooks // inside the hooks directory. var hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash # AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY if [ -z "$SOFT_SERVE_REPO_NAME" ]; then echo "Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks." exit 0 fi {{ .Executable }} hook {{ .Hook }} {{ .Args }} `)) ================================================ FILE: pkg/hooks/gen_test.go ================================================ package hooks import ( "context" "os" "path/filepath" "testing" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/config" ) func TestGenerateHooks(t *testing.T) { tmp := t.TempDir() cfg := config.DefaultConfig() cfg.DataPath = tmp repoPath := filepath.Join(tmp, "repos", "test.git") _, err := git.Init(repoPath, true) if err != nil { t.Fatal(err) } if err := GenerateHooks(context.TODO(), cfg, "test.git"); err != nil { t.Fatal(err) } for _, hn := range []string{ PreReceiveHook, UpdateHook, PostReceiveHook, PostUpdateHook, } { if _, err := os.Stat(filepath.Join(repoPath, "hooks", hn)); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(repoPath, "hooks", hn+".d", "soft-serve")); err != nil { t.Fatal(err) } } } ================================================ FILE: pkg/hooks/hooks.go ================================================ package hooks import ( "context" "io" ) // HookArg is an argument to a git hook. type HookArg struct { OldSha string NewSha string RefName string } // Hooks provides an interface for git server-side hooks. type Hooks interface { PreReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg) Update(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, arg HookArg) PostReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg) PostUpdate(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args ...string) } ================================================ FILE: pkg/jobs/jobs.go ================================================ package jobs import ( "context" "sync" ) // Job is a job that can be registered with the scheduler. type Job struct { ID int Runner Runner } // Runner is a job runner. type Runner interface { Spec(context.Context) string Func(context.Context) func() } var ( mtx sync.Mutex jobs = make(map[string]*Job, 0) ) // Register registers a job. func Register(name string, runner Runner) { mtx.Lock() defer mtx.Unlock() jobs[name] = &Job{Runner: runner} } // List returns a map of registered jobs. func List() map[string]*Job { mtx.Lock() defer mtx.Unlock() return jobs } ================================================ FILE: pkg/jobs/mirror.go ================================================ package jobs import ( "context" "fmt" "path/filepath" "runtime" "strings" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/lfs" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/sync" ) func init() { Register("mirror-pull", mirrorPull{}) } type mirrorPull struct{} // Spec derives the spec used for pull mirrors and implements Runner. func (m mirrorPull) Spec(ctx context.Context) string { cfg := config.FromContext(ctx) if cfg.Jobs.MirrorPull != "" { return cfg.Jobs.MirrorPull } return "@every 10m" } // Func runs the (pull) mirror job task and implements Runner. func (m mirrorPull) Func(ctx context.Context) func() { cfg := config.FromContext(ctx) logger := log.FromContext(ctx).WithPrefix("jobs.mirror") b := backend.FromContext(ctx) dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) return func() { repos, err := b.Repositories(ctx) if err != nil { logger.Error("error getting repositories", "err", err) return } // Divide the work up among the number of CPUs. wq := sync.NewWorkPool(ctx, runtime.GOMAXPROCS(0), sync.WithWorkPoolLogger(logger.Errorf), ) logger.Debug("updating mirror repos") for _, repo := range repos { if repo.IsMirror() { r, err := repo.Open() if err != nil { logger.Error("error opening repository", "repo", repo.Name(), "err", err) continue } name := repo.Name() wq.Add(name, func() { repo := repo cmds := []string{ "fetch --prune", // fetch prune before updating remote "remote update --prune", // update remote and prune remote refs } for _, c := range cmds { args := strings.Split(c, " ") cmd := git.NewCommand(args...).WithContext(ctx) cmd.AddEnvs( fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`, filepath.Join(cfg.DataPath, "ssh", "known_hosts"), cfg.SSH.ClientKeyPath, ), ) if _, err := cmd.RunInDir(r.Path); err != nil { logger.Error("error running git remote update", "repo", name, "err", err) } } if cfg.LFS.Enabled { rcfg, err := r.Config() if err != nil { logger.Error("error getting git config", "repo", name, "err", err) return } lfsEndpoint := rcfg.Section("lfs").Option("url") if lfsEndpoint == "" { // If there is no LFS url defined, means the repo // doesn't use LFS and we can skip it. return } ep, err := lfs.NewEndpoint(lfsEndpoint) if err != nil { logger.Error("error creating LFS endpoint", "repo", name, "err", err) return } client := lfs.NewClient(ep) if client == nil { logger.Errorf("failed to create lfs client: unsupported endpoint %s", lfsEndpoint) return } if err := backend.StoreRepoMissingLFSObjects(ctx, repo, dbx, datastore, client); err != nil { logger.Error("failed to store missing lfs objects", "err", err, "path", r.Path) return } } }) } } wq.Run() } } ================================================ FILE: pkg/jwk/jwk.go ================================================ package jwk import ( "crypto" "crypto/sha256" "fmt" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/go-jose/go-jose/v3" "github.com/golang-jwt/jwt/v5" ) // SigningMethod is a JSON Web Token signing method. It uses Ed25519 keys to // sign and verify tokens. var SigningMethod = &jwt.SigningMethodEd25519{} // Pair is a JSON Web Key pair. type Pair struct { privateKey crypto.PrivateKey jwk jose.JSONWebKey } // PrivateKey returns the private key. func (p Pair) PrivateKey() crypto.PrivateKey { return p.privateKey } // JWK returns the JSON Web Key. func (p Pair) JWK() jose.JSONWebKey { return p.jwk } // NewPair creates a new JSON Web Key pair. func NewPair(cfg *config.Config) (Pair, error) { kp, err := config.KeyPair(cfg) if err != nil { return Pair{}, err } sum := sha256.Sum256(kp.RawPrivateKey()) kid := fmt.Sprintf("%x", sum) jwk := jose.JSONWebKey{ Key: kp.CryptoPublicKey(), KeyID: kid, Algorithm: SigningMethod.Alg(), } return Pair{privateKey: kp.PrivateKey(), jwk: jwk}, nil } ================================================ FILE: pkg/jwk/jwk_test.go ================================================ package jwk import ( "errors" "testing" "github.com/charmbracelet/soft-serve/pkg/config" ) func TestBadNewPair(t *testing.T) { _, err := NewPair(nil) if !errors.Is(err, config.ErrNilConfig) { t.Errorf("NewPair(nil) => %v, want %v", err, config.ErrNilConfig) } } func TestGoodNewPair(t *testing.T) { cfg := config.DefaultConfig() if _, err := NewPair(cfg); err != nil { t.Errorf("NewPair(cfg) => _, %v, want nil error", err) } } ================================================ FILE: pkg/lfs/basic_transfer.go ================================================ package lfs import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "charm.land/log/v2" ) // BasicTransferAdapter implements the "basic" adapter type BasicTransferAdapter struct { client *http.Client } // Name returns the name of the adapter func (a *BasicTransferAdapter) Name() string { return "basic" } // Download reads the download location and downloads the data func (a *BasicTransferAdapter) Download(ctx context.Context, _ Pointer, l *Link) (io.ReadCloser, error) { resp, err := a.performRequest(ctx, "GET", l, nil, nil) if err != nil { return nil, err } return resp.Body, nil } // Upload sends the content to the LFS server func (a *BasicTransferAdapter) Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error { res, err := a.performRequest(ctx, "PUT", l, r, func(req *http.Request) { if len(req.Header.Get("Content-Type")) == 0 { req.Header.Set("Content-Type", "application/octet-stream") } if req.Header.Get("Transfer-Encoding") == "chunked" { req.TransferEncoding = []string{"chunked"} } req.ContentLength = p.Size }) if err != nil { return err } return res.Body.Close() } // Verify calls the verify handler on the LFS server func (a *BasicTransferAdapter) Verify(ctx context.Context, p Pointer, l *Link) error { logger := log.FromContext(ctx).WithPrefix("lfs") b, err := json.Marshal(p) if err != nil { logger.Errorf("Error encoding json: %v", err) return err } res, err := a.performRequest(ctx, "POST", l, bytes.NewReader(b), func(req *http.Request) { req.Header.Set("Content-Type", MediaType) }) if err != nil { return err } return res.Body.Close() } func (a *BasicTransferAdapter) performRequest(ctx context.Context, method string, l *Link, body io.Reader, callback func(*http.Request)) (*http.Response, error) { logger := log.FromContext(ctx).WithPrefix("lfs") logger.Debugf("Calling: %s %s", method, l.Href) req, err := http.NewRequestWithContext(ctx, method, l.Href, body) if err != nil { logger.Errorf("Error creating request: %v", err) return nil, err } for key, value := range l.Header { req.Header.Set(key, value) } req.Header.Set("Accept", MediaType) if callback != nil { callback(req) } res, err := a.client.Do(req) if err != nil { select { case <-ctx.Done(): return res, ctx.Err() default: } logger.Errorf("Error while processing request: %v", err) return res, err } if res.StatusCode != http.StatusOK { return res, handleErrorResponse(res) } return res, nil } func handleErrorResponse(resp *http.Response) error { defer resp.Body.Close() //nolint: errcheck er, err := decodeResponseError(resp.Body) if err != nil { return fmt.Errorf("request failed with status %s", resp.Status) } return errors.New(er.Message) } func decodeResponseError(r io.Reader) (ErrorResponse, error) { var er ErrorResponse err := json.NewDecoder(r).Decode(&er) if err != nil { log.Error("Error decoding json: %v", err) } return er, err } ================================================ FILE: pkg/lfs/client.go ================================================ package lfs import ( "context" "io" ) // DownloadCallback gets called for every requested LFS object to process its content type DownloadCallback func(p Pointer, content io.ReadCloser, objectError error) error // UploadCallback gets called for every requested LFS object to provide its content type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error) // Client is a Git LFS client to communicate with a LFS source API. type Client interface { Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error } // NewClient returns a new Git LFS client. func NewClient(e Endpoint) Client { if e.Scheme == "http" || e.Scheme == "https" { return newHTTPClient(e) } // TODO: support ssh client return nil } ================================================ FILE: pkg/lfs/common.go ================================================ package lfs import ( "time" ) const ( // MediaType contains the media type for LFS server requests. MediaType = "application/vnd.git-lfs+json" // OperationDownload is the operation name for a download request. OperationDownload = "download" // OperationUpload is the operation name for an upload request. OperationUpload = "upload" // ActionDownload is the action name for a download request. ActionDownload = OperationDownload // ActionUpload is the action name for an upload request. ActionUpload = OperationUpload // ActionVerify is the action name for a verify request. ActionVerify = "verify" // DefaultLocksLimit is the default number of locks to return in a single // request. DefaultLocksLimit = 20 ) // Pointer contains LFS pointer data type Pointer struct { Oid string `json:"oid"` Size int64 `json:"size"` } // PointerBlob associates a Git blob with a Pointer. type PointerBlob struct { Hash string Pointer } // ErrorResponse describes the error to the client. type ErrorResponse struct { Message string `json:"message,omitempty"` DocumentationURL string `json:"documentation_url,omitempty"` RequestID string `json:"request_id,omitempty"` } // BatchResponse contains multiple object metadata Representation structures // for use with the batch API. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses type BatchResponse struct { Transfer string `json:"transfer,omitempty"` Objects []*ObjectResponse `json:"objects"` HashAlgo string `json:"hash_algo,omitempty"` } // ObjectResponse is object metadata as seen by clients of the LFS server. type ObjectResponse struct { Pointer Actions map[string]*Link `json:"actions,omitempty"` Error *ObjectError `json:"error,omitempty"` } // Link provides a structure with information about how to access a object. type Link struct { Href string `json:"href"` Header map[string]string `json:"header,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` ExpiresIn *time.Duration `json:"expires_in,omitempty"` } // ObjectError defines the JSON structure returned to the client in case of an error. type ObjectError struct { Code int `json:"code"` Message string `json:"message"` } // BatchRequest contains multiple requests processed in one batch operation. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#requests type BatchRequest struct { Operation string `json:"operation"` Transfers []string `json:"transfers,omitempty"` Ref *Reference `json:"ref,omitempty"` Objects []Pointer `json:"objects"` HashAlgo string `json:"hash_algo,omitempty"` } // Reference contains a git reference. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#ref-property type Reference struct { Name string `json:"name"` } // AuthenticateResponse is the git-lfs-authenticate JSON response object. type AuthenticateResponse struct { Header map[string]string `json:"header"` Href string `json:"href"` ExpiresIn time.Duration `json:"expires_in"` ExpiresAt time.Time `json:"expires_at"` } // LockCreateRequest contains the request data for creating a lock. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md // https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-request-schema.json type LockCreateRequest struct { Path string `json:"path"` Ref Reference `json:"ref,omitempty"` } // Owner contains the owner data for a lock. type Owner struct { Name string `json:"name"` } // Lock contains the response data for creating a lock. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md // https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-response-schema.json type Lock struct { ID string `json:"id"` Path string `json:"path"` LockedAt time.Time `json:"locked_at"` Owner Owner `json:"owner,omitempty"` } // LockDeleteRequest contains the request data for deleting a lock. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md // https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-delete-request-schema.json type LockDeleteRequest struct { Force bool `json:"force,omitempty"` Ref Reference `json:"ref,omitempty"` } // LockListResponse contains the response data for listing locks. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md // https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-list-response-schema.json type LockListResponse struct { Locks []Lock `json:"locks"` NextCursor string `json:"next_cursor,omitempty"` } // LockVerifyRequest contains the request data for verifying a lock. type LockVerifyRequest struct { Ref Reference `json:"ref,omitempty"` Cursor string `json:"cursor,omitempty"` Limit int `json:"limit,omitempty"` } // LockVerifyResponse contains the response data for verifying a lock. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md // https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-verify-response-schema.json type LockVerifyResponse struct { Ours []Lock `json:"ours"` Theirs []Lock `json:"theirs"` NextCursor string `json:"next_cursor,omitempty"` } // LockResponse contains the response data for a lock. type LockResponse struct { Lock Lock `json:"lock"` ErrorResponse } ================================================ FILE: pkg/lfs/endpoint.go ================================================ package lfs import ( "fmt" "net/url" "strings" ) // Endpoint is a Git LFS endpoint. type Endpoint = *url.URL // NewEndpoint returns a new Git LFS endpoint. func NewEndpoint(rawurl string) (Endpoint, error) { u, err := url.Parse(rawurl) if err != nil { e, err := endpointFromBareSSH(rawurl) if err != nil { return nil, err } u = e } u.Path = strings.TrimSuffix(u.Path, "/") switch u.Scheme { case "git": // Use https for git:// URLs and strip the port if it exists. u.Scheme = "https" if u.Port() != "" { u.Host = u.Hostname() } fallthrough case "http", "https": if strings.HasSuffix(u.Path, ".git") { u.Path += "/info/lfs" } else { u.Path += ".git/info/lfs" } case "ssh", "git+ssh", "ssh+git": default: return nil, fmt.Errorf("unknown url: %s", rawurl) } return u, nil } // endpointFromBareSSH creates a new endpoint from a bare ssh repo. // // user@host.com:path/to/repo.git or // [user@host.com:port]:path/to/repo.git func endpointFromBareSSH(rawurl string) (*url.URL, error) { parts := strings.Split(rawurl, ":") partsLen := len(parts) if partsLen < 2 { return url.Parse(rawurl) } // Treat presence of ':' as a bare URL var newPath string if len(parts) > 2 { // port included; really should only ever be 3 parts // Correctly handle [host:port]:path URLs parts[0] = strings.TrimPrefix(parts[0], "[") parts[1] = strings.TrimSuffix(parts[1], "]") newPath = fmt.Sprintf("%v:%v", parts[0], strings.Join(parts[1:], "/")) } else { newPath = strings.Join(parts, "/") } newrawurl := fmt.Sprintf("ssh://%v", newPath) return url.Parse(newrawurl) } ================================================ FILE: pkg/lfs/http_client.go ================================================ package lfs import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/ssrf" ) // httpClient is a Git LFS client to communicate with a LFS source API. type httpClient struct { client *http.Client endpoint Endpoint transfers map[string]TransferAdapter } var _ Client = (*httpClient)(nil) // newHTTPClient returns a new Git LFS client. func newHTTPClient(endpoint Endpoint) *httpClient { client := ssrf.NewSecureClient() return &httpClient{ client: client, endpoint: endpoint, transfers: map[string]TransferAdapter{ TransferBasic: &BasicTransferAdapter{client}, }, } } // Download implements Client. func (c *httpClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error { return c.performOperation(ctx, objects, callback, nil) } // Upload implements Client. func (c *httpClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error { return c.performOperation(ctx, objects, nil, callback) } func (c *httpClient) transferNames() []string { names := make([]string, len(c.transfers)) i := 0 for name := range c.transfers { names[i] = name i++ } return names } // batch performs a batch request to the LFS server. func (c *httpClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) { logger := log.FromContext(ctx).WithPrefix("lfs") url := fmt.Sprintf("%s/objects/batch", c.endpoint.String()) // TODO: support ref request := &BatchRequest{operation, c.transferNames(), nil, objects, HashAlgorithmSHA256} payload := new(bytes.Buffer) err := json.NewEncoder(payload).Encode(request) if err != nil { logger.Errorf("Error encoding json: %v", err) return nil, err } logger.Debugf("Calling: %s", url) req, err := http.NewRequestWithContext(ctx, "POST", url, payload) if err != nil { logger.Errorf("Error creating request: %v", err) return nil, err } req.Header.Set("Content-type", MediaType) req.Header.Set("Accept", MediaType) res, err := c.client.Do(req) if err != nil { select { case <-ctx.Done(): return nil, ctx.Err() default: } logger.Errorf("Error while processing request: %v", err) return nil, err } defer res.Body.Close() //nolint: errcheck if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected server response: %s", res.Status) } var response BatchResponse err = json.NewDecoder(res.Body).Decode(&response) if err != nil { logger.Errorf("Error decoding json: %v", err) return nil, err } if len(response.Transfer) == 0 { response.Transfer = TransferBasic } return &response, nil } func (c *httpClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error { logger := log.FromContext(ctx).WithPrefix("lfs") if len(objects) == 0 { return nil } operation := OperationDownload if uc != nil { operation = OperationUpload } result, err := c.batch(ctx, operation, objects) if err != nil { return err } transferAdapter, ok := c.transfers[result.Transfer] if !ok { return fmt.Errorf("TransferAdapter not found: %s", result.Transfer) } for _, object := range result.Objects { if object.Error != nil { objectError := errors.New(object.Error.Message) logger.Debugf("Error on object %v: %v", object.Pointer, objectError) if uc != nil { if _, err := uc(object.Pointer, objectError); err != nil { return err } } else { if err := dc(object.Pointer, nil, objectError); err != nil { return err } } continue } if uc != nil { if len(object.Actions) == 0 { logger.Debugf("%v already present on server", object.Pointer) continue } link, ok := object.Actions[ActionUpload] if !ok { logger.Debugf("%+v", object) return errors.New("missing action 'upload'") } content, err := uc(object.Pointer, nil) if err != nil { return err } err = transferAdapter.Upload(ctx, object.Pointer, content, link) content.Close() //nolint: errcheck if err != nil { return err } link, ok = object.Actions[ActionVerify] if ok { if err := transferAdapter.Verify(ctx, object.Pointer, link); err != nil { return err } } } else { link, ok := object.Actions[ActionDownload] if !ok { logger.Debugf("%+v", object) return errors.New("missing action 'download'") } content, err := transferAdapter.Download(ctx, object.Pointer, link) if err != nil { return err } if err := dc(object.Pointer, content, nil); err != nil { return err } } } return nil } ================================================ FILE: pkg/lfs/pointer.go ================================================ package lfs import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "path" "regexp" "strconv" "strings" ) const ( blobSizeCutoff = 1024 // HashAlgorithmSHA256 is the hash algorithm used for Git LFS. HashAlgorithmSHA256 = "sha256" // MetaFileIdentifier is the string appearing at the first line of LFS pointer files. // https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1" // MetaFileOidPrefix appears in LFS pointer files on a line before the sha256 hash. MetaFileOidPrefix = "oid " + HashAlgorithmSHA256 + ":" ) var ( // ErrMissingPrefix occurs if the content lacks the LFS prefix ErrMissingPrefix = errors.New("content lacks the LFS prefix") // ErrInvalidStructure occurs if the content has an invalid structure ErrInvalidStructure = errors.New("content has an invalid structure") // ErrInvalidOIDFormat occurs if the oid has an invalid format ErrInvalidOIDFormat = errors.New("OID has an invalid format") ) // ReadPointer tries to read LFS pointer data from the reader func ReadPointer(reader io.Reader) (Pointer, error) { buf := make([]byte, blobSizeCutoff) n, err := io.ReadFull(reader, buf) if err != nil && err != io.ErrUnexpectedEOF { return Pointer{}, err } buf = buf[:n] return ReadPointerFromBuffer(buf) } var oidPattern = regexp.MustCompile(`^[a-f\d]{64}$`) // ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise. func ReadPointerFromBuffer(buf []byte) (Pointer, error) { var p Pointer headString := string(buf) if !strings.HasPrefix(headString, MetaFileIdentifier) { return p, ErrMissingPrefix } splitLines := strings.Split(headString, "\n") if len(splitLines) < 3 { return p, ErrInvalidStructure } oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix) if len(oid) != 64 || !oidPattern.MatchString(oid) { return p, ErrInvalidOIDFormat } size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64) if err != nil { return p, err } p.Oid = oid p.Size = size return p, nil } // IsValid checks if the pointer has a valid structure. // It doesn't check if the pointed-to-content exists. func (p Pointer) IsValid() bool { if len(p.Oid) != 64 { return false } if !oidPattern.MatchString(p.Oid) { return false } if p.Size < 0 { return false } return true } // String returns the string representation of the pointer // https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#the-pointer func (p Pointer) String() string { return fmt.Sprintf("%s\n%s%s\nsize %d\n", MetaFileIdentifier, MetaFileOidPrefix, p.Oid, p.Size) } // RelativePath returns the relative storage path of the pointer // https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#intercepting-git func (p Pointer) RelativePath() string { if len(p.Oid) < 5 { return p.Oid } return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid) } // GeneratePointer generates a pointer for arbitrary content func GeneratePointer(content io.Reader) (Pointer, error) { h := sha256.New() c, err := io.Copy(h, content) if err != nil { return Pointer{}, err } sum := h.Sum(nil) return Pointer{Oid: hex.EncodeToString(sum), Size: c}, nil } ================================================ FILE: pkg/lfs/pointer_test.go ================================================ package lfs import ( "errors" "path" "strconv" "testing" ) func TestReadPointer(t *testing.T) { cases := []struct { name string content string want Pointer wantErr error wantErrp interface{} }{ { name: "valid pointer", content: `version https://git-lfs.github.com/spec/v1 oid sha256:1234567890123456789012345678901234567890123456789012345678901234 size 1234 `, want: Pointer{ Oid: "1234567890123456789012345678901234567890123456789012345678901234", Size: 1234, }, }, { name: "invalid prefix", content: `version https://foobar/spec/v2 oid sha256:1234567890123456789012345678901234567890123456789012345678901234 size 1234 `, wantErr: ErrMissingPrefix, }, { name: "invalid oid", content: `version https://git-lfs.github.com/spec/v1 oid sha256:&2345a78$012345678901234567890123456789012345678901234567890123 size 1234 `, wantErr: ErrInvalidOIDFormat, }, { name: "invalid size", content: `version https://git-lfs.github.com/spec/v1 oid sha256:1234567890123456789012345678901234567890123456789012345678901234 size abc `, wantErrp: &strconv.NumError{}, }, { name: "invalid structure", content: `version https://git-lfs.github.com/spec/v1 `, wantErr: ErrInvalidStructure, }, { name: "empty pointer", wantErr: ErrMissingPrefix, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { p, err := ReadPointerFromBuffer([]byte(tc.content)) if err != tc.wantErr && !errors.As(err, &tc.wantErrp) { t.Errorf("ReadPointerFromBuffer() error = %v(%T), wantErr %v(%T)", err, err, tc.wantErr, tc.wantErr) return } if err != nil { return } if err == nil { if !p.IsValid() { t.Errorf("Expected a valid pointer") return } if path.Join(p.Oid[:2], p.Oid[2:4], p.Oid) != p.RelativePath() { t.Errorf("Expected a valid relative path") return } } if p.Oid != tc.want.Oid { t.Errorf("ReadPointerFromBuffer() oid = %v, want %v", p.Oid, tc.want.Oid) } if p.Size != tc.want.Size { t.Errorf("ReadPointerFromBuffer() size = %v, want %v", p.Size, tc.want.Size) } }) } } ================================================ FILE: pkg/lfs/scanner.go ================================================ package lfs import ( "bufio" "bytes" "context" "fmt" "io" "strconv" "strings" "sync" gitm "github.com/aymanbagabas/git-module" "github.com/charmbracelet/soft-serve/git" ) // SearchPointerBlobs scans the whole repository for LFS pointer files func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) { basePath := repo.Path catFileCheckReader, catFileCheckWriter := io.Pipe() shasToBatchReader, shasToBatchWriter := io.Pipe() catFileBatchReader, catFileBatchWriter := io.Pipe() wg := sync.WaitGroup{} wg.Add(6) // Create the go-routines in reverse order. // 4. Take the output of cat-file --batch and check if each file in turn // to see if they're pointers to files in the LFS store go createPointerResultsFromCatFileBatch(ctx, catFileBatchReader, &wg, pointerChan) // 3. Take the shas of the blobs and batch read them go catFileBatch(ctx, shasToBatchReader, catFileBatchWriter, &wg, basePath) // 2. From the provided objects restrict to blobs <=1k go blobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg) // 1. Run batch-check on all objects in the repository revListReader, revListWriter := io.Pipe() shasToCheckReader, shasToCheckWriter := io.Pipe() go catFileBatchCheck(ctx, shasToCheckReader, catFileCheckWriter, &wg, basePath) go blobsFromRevListObjects(revListReader, shasToCheckWriter, &wg) go revListAllObjects(ctx, revListWriter, &wg, basePath, errChan) wg.Wait() close(pointerChan) close(errChan) } func createPointerResultsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- PointerBlob) { defer wg.Done() defer catFileBatchReader.Close() //nolint: errcheck bufferedReader := bufio.NewReader(catFileBatchReader) buf := make([]byte, 1025) loop: for { select { case <-ctx.Done(): break loop default: } // File descriptor line: sha sha, err := bufferedReader.ReadString(' ') if err != nil { _ = catFileBatchReader.CloseWithError(err) break } sha = strings.TrimSpace(sha) // Throw away the blob if _, err := bufferedReader.ReadString(' '); err != nil { _ = catFileBatchReader.CloseWithError(err) break } sizeStr, err := bufferedReader.ReadString('\n') if err != nil { _ = catFileBatchReader.CloseWithError(err) break } size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1]) if err != nil { _ = catFileBatchReader.CloseWithError(err) break } pointerBuf := buf[:size+1] if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil { _ = catFileBatchReader.CloseWithError(err) break } pointerBuf = pointerBuf[:size] // Now we need to check if the pointerBuf is an LFS pointer pointer, _ := ReadPointerFromBuffer(pointerBuf) if !pointer.IsValid() { continue } pointerChan <- PointerBlob{Hash: sha, Pointer: pointer} } } func catFileBatch(ctx context.Context, shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string) { defer wg.Done() defer shasToBatchReader.Close() //nolint: errcheck defer catFileBatchWriter.Close() //nolint: errcheck stderr := new(bytes.Buffer) var errbuf strings.Builder if err := gitm.NewCommandWithContext(ctx, "cat-file", "--batch"). WithTimeout(-1). RunInDirWithOptions(basePath, gitm.RunInDirOptions{ Stdout: catFileBatchWriter, Stdin: shasToBatchReader, Stderr: stderr, }); err != nil { _ = shasToBatchReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %w - %s", basePath, err, errbuf.String())) } } func blobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) { defer wg.Done() defer catFileCheckReader.Close() //nolint: errcheck scanner := bufio.NewScanner(catFileCheckReader) defer func() { _ = shasToBatchWriter.CloseWithError(scanner.Err()) }() for scanner.Scan() { line := scanner.Text() if len(line) == 0 { continue } fields := strings.Split(line, " ") if len(fields) < 3 || fields[1] != "blob" { continue } size, _ := strconv.Atoi(fields[2]) if size > 1024 { continue } toWrite := []byte(fields[0] + "\n") for len(toWrite) > 0 { n, err := shasToBatchWriter.Write(toWrite) if err != nil { _ = catFileCheckReader.CloseWithError(err) break } toWrite = toWrite[n:] } } } func catFileBatchCheck(ctx context.Context, shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string) { defer wg.Done() defer shasToCheckReader.Close() //nolint: errcheck defer catFileCheckWriter.Close() //nolint: errcheck stderr := new(bytes.Buffer) var errbuf strings.Builder if err := gitm.NewCommandWithContext(ctx, "cat-file", "--batch-check"). WithTimeout(-1). RunInDirWithOptions(basePath, gitm.RunInDirOptions{ Stdout: catFileCheckWriter, Stdin: shasToCheckReader, Stderr: stderr, }); err != nil { _ = shasToCheckReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %w - %s", basePath, err, errbuf.String())) } } func blobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) { defer wg.Done() defer revListReader.Close() //nolint: errcheck scanner := bufio.NewScanner(revListReader) defer func() { _ = shasToCheckWriter.CloseWithError(scanner.Err()) }() for scanner.Scan() { line := scanner.Text() if len(line) == 0 { continue } fields := strings.Split(line, " ") if len(fields) < 2 || len(fields[1]) == 0 { continue } toWrite := []byte(fields[0] + "\n") for len(toWrite) > 0 { n, err := shasToCheckWriter.Write(toWrite) if err != nil { _ = revListReader.CloseWithError(err) break } toWrite = toWrite[n:] } } } func revListAllObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string, errChan chan<- error) { defer wg.Done() defer revListWriter.Close() //nolint: errcheck stderr := new(bytes.Buffer) var errbuf strings.Builder if err := gitm.NewCommandWithContext(ctx, "rev-list", "--objects", "--all"). WithTimeout(-1). RunInDirWithOptions(basePath, gitm.RunInDirOptions{ Stdout: revListWriter, Stderr: stderr, }); err != nil { errChan <- fmt.Errorf("git rev-list [%s]: %w - %s", basePath, err, errbuf.String()) } } ================================================ FILE: pkg/lfs/ssh_client.go ================================================ package lfs // TODO: implement Git LFS SSH client. ================================================ FILE: pkg/lfs/transfer.go ================================================ package lfs import ( "context" "io" ) // TransferBasic is the name of the Git LFS basic transfer protocol. const TransferBasic = "basic" // TransferAdapter represents an adapter for downloading/uploading LFS objects type TransferAdapter interface { Name() string Download(ctx context.Context, p Pointer, l *Link) (io.ReadCloser, error) Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error Verify(ctx context.Context, p Pointer, l *Link) error } ================================================ FILE: pkg/log/log.go ================================================ package log import ( "os" "strings" "time" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/config" ) // NewLogger returns a new logger with default settings. func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) { if cfg == nil { return nil, nil, config.ErrNilConfig } logger := log.NewWithOptions(os.Stderr, log.Options{ ReportTimestamp: true, TimeFormat: time.DateOnly, }) switch { case config.IsVerbose(): logger.SetReportCaller(true) fallthrough case config.IsDebug(): logger.SetLevel(log.DebugLevel) } logger.SetTimeFormat(cfg.Log.TimeFormat) switch strings.ToLower(cfg.Log.Format) { case "json": logger.SetFormatter(log.JSONFormatter) case "logfmt": logger.SetFormatter(log.LogfmtFormatter) case "text": logger.SetFormatter(log.TextFormatter) } var f *os.File if cfg.Log.Path != "" { var err error f, err = os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return nil, nil, err } logger.SetOutput(f) } return logger, f, nil } ================================================ FILE: pkg/log/log_test.go ================================================ package log import ( "path/filepath" "testing" "github.com/charmbracelet/soft-serve/pkg/config" ) func TestGoodNewLogger(t *testing.T) { for _, c := range []*config.Config{ config.DefaultConfig(), {}, {Log: config.LogConfig{Path: filepath.Join(t.TempDir(), "logfile.txt")}}, } { _, f, err := NewLogger(c) if err != nil { t.Errorf("expected nil got %v", err) } if f != nil { if err := f.Close(); err != nil { t.Errorf("failed to close logger: %v", err) } } } } func TestBadNewLogger(t *testing.T) { for _, c := range []*config.Config{ nil, {Log: config.LogConfig{Path: "\x00"}}, } { _, f, err := NewLogger(c) if err == nil { t.Errorf("expected error got nil") } if f != nil { if err := f.Close(); err != nil { t.Errorf("failed to close logger: %v", err) } } } } ================================================ FILE: pkg/proto/access_token.go ================================================ package proto import "time" // AccessToken represents an access token. type AccessToken struct { ID int64 Name string UserID int64 TokenHash string ExpiresAt time.Time CreatedAt time.Time } ================================================ FILE: pkg/proto/context.go ================================================ package proto import "context" // ContextKeyRepository is the context key for the repository. var ContextKeyRepository = &struct{ string }{"repository"} // ContextKeyUser is the context key for the user. var ContextKeyUser = &struct{ string }{"user"} // RepositoryFromContext returns the repository from the context. func RepositoryFromContext(ctx context.Context) Repository { if r, ok := ctx.Value(ContextKeyRepository).(Repository); ok { return r } return nil } // UserFromContext returns the user from the context. func UserFromContext(ctx context.Context) User { if u, ok := ctx.Value(ContextKeyUser).(User); ok { return u } return nil } // WithRepositoryContext returns a new context with the repository. func WithRepositoryContext(ctx context.Context, r Repository) context.Context { return context.WithValue(ctx, ContextKeyRepository, r) } // WithUserContext returns a new context with the user. func WithUserContext(ctx context.Context, u User) context.Context { return context.WithValue(ctx, ContextKeyUser, u) } ================================================ FILE: pkg/proto/errors.go ================================================ package proto import ( "errors" ) var ( // ErrUnauthorized is returned when the user is not authorized to perform action. ErrUnauthorized = errors.New("unauthorized") // ErrInvalidRemote is returned when a repository import remote is invalid. ErrInvalidRemote = errors.New("remote must be a network URL") // ErrFileNotFound is returned when the file is not found. ErrFileNotFound = errors.New("file not found") // ErrRepoNotFound is returned when a repository is not found. ErrRepoNotFound = errors.New("repository not found") // ErrRepoExist is returned when a repository already exists. ErrRepoExist = errors.New("repository already exists") // ErrUserNotFound is returned when a user is not found. ErrUserNotFound = errors.New("user not found") // ErrTokenNotFound is returned when a token is not found. ErrTokenNotFound = errors.New("token not found") // ErrTokenExpired is returned when a token is expired. ErrTokenExpired = errors.New("token expired") // ErrCollaboratorNotFound is returned when a collaborator is not found. ErrCollaboratorNotFound = errors.New("collaborator not found") // ErrCollaboratorExist is returned when a collaborator already exists. ErrCollaboratorExist = errors.New("collaborator already exists") ) ================================================ FILE: pkg/proto/repo.go ================================================ package proto import ( "time" "github.com/charmbracelet/soft-serve/git" ) // Repository is a Git repository interface. type Repository interface { // ID returns the repository's ID. ID() int64 // Name returns the repository's name. Name() string // ProjectName returns the repository's project name. ProjectName() string // Description returns the repository's description. Description() string // IsPrivate returns whether the repository is private. IsPrivate() bool // IsMirror returns whether the repository is a mirror. IsMirror() bool // IsHidden returns whether the repository is hidden. IsHidden() bool // UserID returns the ID of the user who owns the repository. // It returns 0 if the repository is not owned by a user. UserID() int64 // CreatedAt returns the time the repository was created. CreatedAt() time.Time // UpdatedAt returns the time the repository was last updated. // If the repository has never been updated, it returns the time it was created. UpdatedAt() time.Time // Open returns the underlying git.Repository. Open() (*git.Repository, error) } // RepositoryOptions are options for creating a new repository. type RepositoryOptions struct { Private bool Description string ProjectName string Mirror bool Hidden bool LFS bool LFSEndpoint string } // RepositoryDefaultBranch returns the default branch of a repository. func RepositoryDefaultBranch(repo Repository) (string, error) { r, err := repo.Open() if err != nil { return "", err } ref, err := r.HEAD() if err != nil { return "", err } return ref.Name().Short(), nil } ================================================ FILE: pkg/proto/user.go ================================================ package proto import "golang.org/x/crypto/ssh" // User is an interface representing a user. type User interface { // ID returns the user's ID. ID() int64 // Username returns the user's username. Username() string // IsAdmin returns whether the user is an admin. IsAdmin() bool // PublicKeys returns the user's public keys. PublicKeys() []ssh.PublicKey // Password returns the user's password hash. Password() string } // UserOptions are options for creating a user. type UserOptions struct { // Admin is whether the user is an admin. Admin bool // PublicKeys are the user's public keys. PublicKeys []ssh.PublicKey } ================================================ FILE: pkg/ssh/cmd/blob.go ================================================ package cmd import ( "fmt" "os" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/styles" "github.com/spf13/cobra" ) // blobCommand returns a command that prints the contents of a file. func blobCommand() *cobra.Command { var linenumber bool var color bool var raw bool var noColor bool if testrun, ok := os.LookupEnv("SOFT_SERVE_NO_COLOR"); ok && testrun == "1" { noColor = true } styles := styles.DefaultStyles() cmd := &cobra.Command{ Use: "blob REPOSITORY [REFERENCE] [PATH]", Aliases: []string{"cat", "show"}, Short: "Print out the contents of file at path", Args: cobra.RangeArgs(1, 3), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := args[0] ref := "" fp := "" switch len(args) { case 2: fp = args[1] case 3: ref = args[1] fp = args[2] } repo, err := be.Repository(ctx, rn) if err != nil { return err } r, err := repo.Open() if err != nil { return err } if ref == "" { head, err := r.HEAD() if err != nil { return err } ref = head.ID } tree, err := r.LsTree(ref) if err != nil { return err } te, err := tree.TreeEntry(fp) if err != nil { return err } if te.Type() != "blob" { return git.ErrFileNotFound } bts, err := te.Contents() if err != nil { return err } c := string(bts) isBin, _ := te.File().IsBinary() if isBin { if raw { cmd.Println(c) } else { return fmt.Errorf("binary file: use --raw to print") } } else { if color && !noColor { c, err = common.FormatHighlight(fp, c) if err != nil { return err } } if linenumber { c, _ = common.FormatLineNumber(styles, c, color && !noColor) } cmd.Println(c) } return nil }, } cmd.Flags().BoolVarP(&raw, "raw", "r", false, "Print raw contents") cmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers") cmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output") return cmd } ================================================ FILE: pkg/ssh/cmd/branch.go ================================================ package cmd import ( "fmt" "strings" gitm "github.com/aymanbagabas/git-module" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/webhook" "github.com/spf13/cobra" ) func branchCommand() *cobra.Command { cmd := &cobra.Command{ Use: "branch", Short: "Manage repository branches", } cmd.AddCommand( branchListCommand(), branchDefaultCommand(), branchDeleteCommand(), ) return cmd } func branchListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list REPOSITORY", Short: "List repository branches", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := strings.TrimSuffix(args[0], ".git") rr, err := be.Repository(ctx, rn) if err != nil { return err } r, err := rr.Open() if err != nil { return err } branches, _ := r.Branches() for _, b := range branches { cmd.Println(b) } return nil }, } return cmd } func branchDefaultCommand() *cobra.Command { cmd := &cobra.Command{ Use: "default REPOSITORY [BRANCH]", Short: "Set or get the default branch", Args: cobra.RangeArgs(1, 2), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := strings.TrimSuffix(args[0], ".git") switch len(args) { case 1: rr, err := be.Repository(ctx, rn) if err != nil { return err } r, err := rr.Open() if err != nil { return err } head, err := r.HEAD() if err != nil { return err } cmd.Println(head.Name().Short()) case 2: if err := checkIfCollab(cmd, args); err != nil { return err } rr, err := be.Repository(ctx, rn) if err != nil { return err } r, err := rr.Open() if err != nil { return err } branch := args[1] branches, _ := r.Branches() var exists bool for _, b := range branches { if branch == b { exists = true break } } if !exists { return git.ErrReferenceNotExist } if _, err := r.SymbolicRef(git.HEAD, gitm.RefsHeads+branch, gitm.SymbolicRefOptions{ CommandOptions: gitm.CommandOptions{ Context: ctx, }, }); err != nil { return err } // TODO: move this to backend? user := proto.UserFromContext(ctx) wh, err := webhook.NewRepositoryEvent(ctx, user, rr, webhook.RepositoryEventActionDefaultBranchChange) if err != nil { return err } return webhook.SendEvent(ctx, wh) } return nil }, } return cmd } func branchDeleteCommand() *cobra.Command { cmd := &cobra.Command{ Use: "delete REPOSITORY BRANCH", Aliases: []string{"remove", "rm", "del"}, Short: "Delete a branch", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfReadableAndCollab, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := strings.TrimSuffix(args[0], ".git") rr, err := be.Repository(ctx, rn) if err != nil { return err } r, err := rr.Open() if err != nil { return err } branch := args[1] branches, _ := r.Branches() var exists bool for _, b := range branches { if branch == b { exists = true break } } if !exists { return git.ErrReferenceNotExist } head, err := r.HEAD() if err != nil { return err } if head.Name().Short() == branch { return fmt.Errorf("cannot delete the default branch") } branchCommit, err := r.BranchCommit(branch) if err != nil { return err } if err := r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true}); err != nil { return err } wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsHeads+branch, branchCommit.ID.String(), git.ZeroID) if err != nil { return err } return webhook.SendEvent(ctx, wh) }, } return cmd } ================================================ FILE: pkg/ssh/cmd/cmd.go ================================================ package cmd import ( "fmt" "net/url" "strings" "text/template" "unicode" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/charmbracelet/ssh" "github.com/spf13/cobra" ) var templateFuncs = template.FuncMap{ "trim": strings.TrimSpace, "trimRightSpace": trimRightSpace, "trimTrailingWhitespaces": trimRightSpace, "rpad": rpad, "gt": cobra.Gt, "eq": cobra.Eq, } const ( // UsageTemplate is the template used for the help output. UsageTemplate = `Usage:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} Aliases: {{.NameAndAliases}}{{end}}{{if .HasExample}} Examples: {{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} {{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} Flags: {{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} Global Flags: {{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}} ` ) // UsageFunc is a function that can be used as a cobra.Command's // UsageFunc to render the help output. func UsageFunc(c *cobra.Command) error { ctx := c.Context() cfg := config.FromContext(ctx) hostname := "localhost" port := "23231" url, err := url.Parse(cfg.SSH.PublicURL) if err == nil { hostname = url.Hostname() port = url.Port() } sshCmd := "ssh" if port != "" && port != "22" { sshCmd += " -p " + port } sshCmd += " " + hostname t := template.New("usage") t.Funcs(templateFuncs) template.Must(t.Parse(c.UsageTemplate())) return t.Execute(c.OutOrStderr(), struct { *cobra.Command SSHCommand string }{ Command: c, SSHCommand: sshCmd, }) } func trimRightSpace(s string) string { return strings.TrimRightFunc(s, unicode.IsSpace) } // rpad adds padding to the right of a string. func rpad(s string, padding int) string { template := fmt.Sprintf("%%-%ds", padding) return fmt.Sprintf(template, s) } // CommandName returns the name of the command from the args. func CommandName(args []string) string { if len(args) == 0 { return "" } return args[0] } func checkIfReadable(cmd *cobra.Command, args []string) error { var repo string if len(args) > 0 { repo = args[0] } ctx := cmd.Context() be := backend.FromContext(ctx) rn := utils.SanitizeRepo(repo) user := proto.UserFromContext(ctx) auth := be.AccessLevelForUser(cmd.Context(), rn, user) if auth < access.ReadOnlyAccess { return proto.ErrRepoNotFound } return nil } // IsPublicKeyAdmin returns true if the given public key is an admin key from // the initial_admin_keys config or environment field. func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool { for _, k := range cfg.AdminKeys() { if sshutils.KeysEqual(pk, k) { return true } } return false } func checkIfAdmin(cmd *cobra.Command, args []string) error { var repo string if len(args) > 0 { repo = args[0] } ctx := cmd.Context() cfg := config.FromContext(ctx) be := backend.FromContext(ctx) rn := utils.SanitizeRepo(repo) pk := sshutils.PublicKeyFromContext(ctx) if IsPublicKeyAdmin(cfg, pk) { return nil } user := proto.UserFromContext(ctx) if user == nil { return proto.ErrUnauthorized } if user.IsAdmin() { return nil } auth := be.AccessLevelForUser(cmd.Context(), rn, user) if auth >= access.AdminAccess { return nil } return proto.ErrUnauthorized } func checkIfCollab(cmd *cobra.Command, args []string) error { var repo string if len(args) > 0 { repo = args[0] } ctx := cmd.Context() be := backend.FromContext(ctx) rn := utils.SanitizeRepo(repo) user := proto.UserFromContext(ctx) auth := be.AccessLevelForUser(cmd.Context(), rn, user) if auth < access.ReadWriteAccess { return proto.ErrUnauthorized } return nil } func checkIfReadableAndCollab(cmd *cobra.Command, args []string) error { if err := checkIfReadable(cmd, args); err != nil { return err } if err := checkIfCollab(cmd, args); err != nil { return err } return nil } ================================================ FILE: pkg/ssh/cmd/collab.go ================================================ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/spf13/cobra" ) func collabCommand() *cobra.Command { cmd := &cobra.Command{ Use: "collab", Aliases: []string{"collabs", "collaborator", "collaborators"}, Short: "Manage collaborators", } cmd.AddCommand( collabAddCommand(), collabRemoveCommand(), collabListCommand(), ) return cmd } func collabAddCommand() *cobra.Command { cmd := &cobra.Command{ Use: "add REPOSITORY USERNAME [LEVEL]", Short: "Add a collaborator to a repo", Long: "Add a collaborator to a repo. LEVEL can be one of: no-access, read-only, read-write, or admin-access. Defaults to read-write.", Args: cobra.RangeArgs(2, 3), PersistentPreRunE: checkIfReadableAndCollab, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repo := args[0] username := args[1] level := access.ReadWriteAccess if len(args) > 2 { level = access.ParseAccessLevel(args[2]) if level < 0 { return access.ErrInvalidAccessLevel } } return be.AddCollaborator(ctx, repo, username, level) }, } return cmd } func collabRemoveCommand() *cobra.Command { cmd := &cobra.Command{ Use: "remove REPOSITORY USERNAME", Args: cobra.ExactArgs(2), Short: "Remove a collaborator from a repo", PersistentPreRunE: checkIfReadableAndCollab, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repo := args[0] username := args[1] return be.RemoveCollaborator(ctx, repo, username) }, } return cmd } func collabListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list REPOSITORY", Short: "List collaborators for a repo", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadableAndCollab, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repo := args[0] collabs, err := be.Collaborators(ctx, repo) if err != nil { return err } for _, c := range collabs { cmd.Println(c) } return nil }, } return cmd } ================================================ FILE: pkg/ssh/cmd/commit.go ================================================ package cmd import ( "fmt" "strings" "time" gansi "charm.land/glamour/v2/ansi" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/styles" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/spf13/cobra" ) // commitCommand returns a command that prints the contents of a commit. func commitCommand() *cobra.Command { var color bool var patchOnly bool cmd := &cobra.Command{ Use: "commit repo SHA", Short: "Print out the contents of a diff", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repoName := args[0] commitSHA := args[1] rr, err := be.Repository(ctx, repoName) if err != nil { return err } r, err := rr.Open() if err != nil { return err } commit, err := r.CommitByRevision(commitSHA) if err != nil { return err } patch, err := r.Patch(commit) if err != nil { return err } diff, err := r.Diff(commit) if err != nil { return err } commonStyle := styles.DefaultStyles() style := commonStyle.Log s := strings.Builder{} commitLine := "commit " + commitSHA authorLine := "Author: " + utils.Sanitize(commit.Author.Name) dateLine := "Date: " + commit.Committer.When.UTC().Format(time.UnixDate) msgLine := strings.ReplaceAll(utils.Sanitize(commit.Message), "\r\n", "\n") statsLine := renderStats(diff, commonStyle, color) diffLine := renderDiff(patch, color) if patchOnly { cmd.Println( diffLine, ) return nil } if color { s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", style.CommitHash.Render(commitLine), style.CommitAuthor.Render(authorLine), style.CommitDate.Render(dateLine), style.CommitBody.Render(msgLine), )) } else { s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", commitLine, authorLine, dateLine, msgLine, )) } s.WriteString(fmt.Sprintf("\n%s\n%s", statsLine, diffLine, )) cmd.Println( s.String(), ) return nil }, } cmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output") cmd.Flags().BoolVarP(&patchOnly, "patch", "p", false, "Output patch only") return cmd } func renderDiff(patch string, color bool) string { c := patch if color { var s strings.Builder var pr strings.Builder diffChroma := &gansi.CodeBlockElement{ Code: patch, Language: "diff", } err := diffChroma.Render(&pr, common.StyleRenderer()) if err != nil { s.WriteString(fmt.Sprintf("\n%s", err.Error())) } else { s.WriteString(fmt.Sprintf("\n%s", pr.String())) } c = s.String() } return c } func renderStats(diff *git.Diff, commonStyle *styles.Styles, color bool) string { style := commonStyle.Log c := diff.Stats().String() if color { s := strings.Split(c, "\n") for i, line := range s { ch := strings.Split(line, "|") if len(ch) > 1 { adddel := ch[len(ch)-1] adddel = strings.ReplaceAll(adddel, "+", style.CommitStatsAdd.Render("+")) adddel = strings.ReplaceAll(adddel, "-", style.CommitStatsDel.Render("-")) s[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel } } return strings.Join(s, "\n") } return c } ================================================ FILE: pkg/ssh/cmd/create.go ================================================ package cmd import ( "fmt" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/spf13/cobra" ) // createCommand is the command for creating a new repository. func createCommand() *cobra.Command { var private bool var description string var projectName string var hidden bool cmd := &cobra.Command{ Use: "create REPOSITORY", Short: "Create a new repository", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cfg := config.FromContext(ctx) be := backend.FromContext(ctx) user := proto.UserFromContext(ctx) name := args[0] r, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{ Private: private, Description: description, ProjectName: projectName, Hidden: hidden, }) if err != nil { return err } cloneurl := fmt.Sprintf("%s/%s.git", cfg.SSH.PublicURL, r.Name()) cmd.PrintErrf("Created repository %s\n", r.Name()) cmd.Println(cloneurl) return nil }, } cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name") cmd.Flags().BoolVarP(&hidden, "hidden", "H", false, "hide the repository from the UI") return cmd } ================================================ FILE: pkg/ssh/cmd/delete.go ================================================ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/spf13/cobra" ) func deleteCommand() *cobra.Command { cmd := &cobra.Command{ Use: "delete REPOSITORY", Aliases: []string{"del", "remove", "rm"}, Short: "Delete a repository", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadableAndCollab, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) name := args[0] return be.DeleteRepository(ctx, name) }, } return cmd } ================================================ FILE: pkg/ssh/cmd/description.go ================================================ package cmd import ( "strings" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/spf13/cobra" ) func descriptionCommand() *cobra.Command { cmd := &cobra.Command{ Use: "description REPOSITORY [DESCRIPTION]", Aliases: []string{"desc"}, Short: "Set or get the description for a repository", Args: cobra.MinimumNArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := strings.TrimSuffix(args[0], ".git") switch len(args) { case 1: desc, err := be.Description(ctx, rn) if err != nil { return err } cmd.Println(desc) default: if err := checkIfCollab(cmd, args); err != nil { return err } if err := be.SetDescription(ctx, rn, strings.Join(args[1:], " ")); err != nil { return err } } return nil }, } return cmd } ================================================ FILE: pkg/ssh/cmd/git.go ================================================ package cmd import ( "errors" "path/filepath" "strings" "time" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/git" "github.com/charmbracelet/soft-serve/pkg/lfs" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/spf13/cobra" ) var ( uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "upload_pack_total", Help: "The total number of git-upload-pack requests", }, []string{"repo"}) receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "receive_pack_total", Help: "The total number of git-receive-pack requests", }, []string{"repo"}) uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "upload_archive_total", Help: "The total number of git-upload-archive requests", }, []string{"repo"}) lfsAuthenticateCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "lfs_authenticate_total", Help: "The total number of git-lfs-authenticate requests", }, []string{"repo", "operation"}) lfsTransferCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "lfs_transfer_total", Help: "The total number of git-lfs-transfer requests", }, []string{"repo", "operation"}) uploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "upload_pack_seconds_total", Help: "The total time spent on git-upload-pack requests", }, []string{"repo"}) receivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "receive_pack_seconds_total", Help: "The total time spent on git-receive-pack requests", }, []string{"repo"}) uploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "upload_archive_seconds_total", Help: "The total time spent on git-upload-archive requests", }, []string{"repo"}) lfsAuthenticateSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "lfs_authenticate_seconds_total", Help: "The total time spent on git-lfs-authenticate requests", }, []string{"repo", "operation"}) lfsTransferSeconds = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "git", Name: "lfs_transfer_seconds_total", Help: "The total time spent on git-lfs-transfer requests", }, []string{"repo", "operation"}) createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "ssh", Name: "create_repo_total", Help: "The total number of create repo requests", }, []string{"repo"}) ) // GitUploadPackCommand returns a cobra command for git-upload-pack. func GitUploadPackCommand() *cobra.Command { cmd := &cobra.Command{ Use: "git-upload-pack REPO", Short: "Git upload pack", Args: cobra.ExactArgs(1), Hidden: true, RunE: gitRunE, } return cmd } // GitUploadArchiveCommand returns a cobra command for git-upload-archive. func GitUploadArchiveCommand() *cobra.Command { cmd := &cobra.Command{ Use: "git-upload-archive REPO", Short: "Git upload archive", Args: cobra.ExactArgs(1), Hidden: true, RunE: gitRunE, } return cmd } // GitReceivePackCommand returns a cobra command for git-receive-pack. func GitReceivePackCommand() *cobra.Command { cmd := &cobra.Command{ Use: "git-receive-pack REPO", Short: "Git receive pack", Args: cobra.ExactArgs(1), Hidden: true, RunE: gitRunE, } return cmd } // GitLFSAuthenticateCommand returns a cobra command for git-lfs-authenticate. func GitLFSAuthenticateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "git-lfs-authenticate REPO OPERATION", Short: "Git LFS authenticate", Args: cobra.ExactArgs(2), Hidden: true, RunE: gitRunE, } return cmd } // GitLFSTransfer returns a cobra command for git-lfs-transfer. func GitLFSTransfer() *cobra.Command { cmd := &cobra.Command{ Use: "git-lfs-transfer REPO OPERATION", Short: "Git LFS transfer", Args: cobra.ExactArgs(2), Hidden: true, RunE: gitRunE, } return cmd } func gitRunE(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cfg := config.FromContext(ctx) be := backend.FromContext(ctx) logger := log.FromContext(ctx) start := time.Now() // repo should be in the form of "repo.git" name := utils.SanitizeRepo(args[0]) pk := sshutils.PublicKeyFromContext(ctx) ak := sshutils.MarshalAuthorizedKey(pk) user := proto.UserFromContext(ctx) accessLevel := be.AccessLevelForUser(ctx, name, user) // git bare repositories should end in ".git" // https://git-scm.com/docs/gitrepository-layout repoDir := name + ".git" reposDir := filepath.Join(cfg.DataPath, "repos") if err := git.EnsureWithin(reposDir, repoDir); err != nil { return err } // Set repo in context repo, _ := be.Repository(ctx, name) ctx = proto.WithRepositoryContext(ctx, repo) // Environment variables to pass down to git hooks. envs := []string{ "SOFT_SERVE_REPO_NAME=" + name, "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir), "SOFT_SERVE_PUBLIC_KEY=" + ak, "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"), } if user != nil { envs = append(envs, "SOFT_SERVE_USERNAME="+user.Username(), ) } envs = append(envs, cfg.Environ()...) // Add GIT_PROTOCOL from session. if sess := sshutils.SessionFromContext(ctx); sess != nil { for _, env := range sess.Environ() { if strings.HasPrefix(env, "GIT_PROTOCOL=") { envs = append(envs, env) break } } } repoPath := filepath.Join(reposDir, repoDir) service := git.Service(cmd.Name()) stdin := cmd.InOrStdin() stdout := cmd.OutOrStdout() stderr := cmd.ErrOrStderr() scmd := git.ServiceCommand{ Stdin: stdin, Stdout: stdout, Stderr: stderr, Env: envs, Dir: repoPath, } switch service { case git.ReceivePackService: receivePackCounter.WithLabelValues(name).Inc() defer func() { receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds()) }() if accessLevel < access.ReadWriteAccess { return git.ErrNotAuthed } if repo == nil { if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil { log.Errorf("failed to create repo: %s", err) return err } createRepoCounter.WithLabelValues(name).Inc() } if err := service.Handler(ctx, scmd); err != nil { logger.Error("failed to handle git service", "service", service, "err", err, "repo", name) defer func() { if repo == nil { // If the repo was created, but the request failed, delete it. be.DeleteRepository(ctx, name) //nolint: errcheck } }() return git.ErrSystemMalfunction } if err := git.EnsureDefaultBranch(ctx, scmd.Dir); err != nil { logger.Error("failed to ensure default branch", "err", err, "repo", name) return git.ErrSystemMalfunction } receivePackCounter.WithLabelValues(name).Inc() return nil case git.UploadPackService, git.UploadArchiveService: if accessLevel < access.ReadOnlyAccess { return git.ErrNotAuthed } if repo == nil { return git.ErrInvalidRepo } switch service { case git.UploadArchiveService: uploadArchiveCounter.WithLabelValues(name).Inc() defer func() { uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds()) }() default: uploadPackCounter.WithLabelValues(name).Inc() defer func() { uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds()) }() } err := service.Handler(ctx, scmd) if errors.Is(err, git.ErrInvalidRepo) { return git.ErrInvalidRepo } else if err != nil { logger.Error("failed to handle git service", "service", service, "err", err, "repo", name) return git.ErrSystemMalfunction } return nil case git.LFSTransferService, git.LFSAuthenticateService: operation := args[1] switch operation { case lfs.OperationDownload: if accessLevel < access.ReadOnlyAccess { return git.ErrNotAuthed } case lfs.OperationUpload: if accessLevel < access.ReadWriteAccess { return git.ErrNotAuthed } default: return git.ErrInvalidRequest } if repo == nil { return git.ErrInvalidRepo } scmd.Args = []string{ name, args[1], } switch service { case git.LFSTransferService: lfsTransferCounter.WithLabelValues(name, operation).Inc() defer func() { lfsTransferSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds()) }() default: lfsAuthenticateCounter.WithLabelValues(name, operation).Inc() defer func() { lfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds()) }() } if err := service.Handler(ctx, scmd); err != nil { logger.Error("failed to handle lfs service", "service", service, "err", err, "repo", name) return git.ErrSystemMalfunction } return nil } return errors.New("unsupported git service") } ================================================ FILE: pkg/ssh/cmd/hidden.go ================================================ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/spf13/cobra" ) func hiddenCommand() *cobra.Command { cmd := &cobra.Command{ Use: "hidden REPOSITORY [TRUE|FALSE]", Short: "Hide or unhide a repository", Aliases: []string{"hide"}, Args: cobra.MinimumNArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repo := args[0] switch len(args) { case 1: hidden, err := be.IsHidden(ctx, repo) if err != nil { return err } cmd.Println(hidden) case 2: if err := checkIfCollab(cmd, args); err != nil { return err } hidden := args[1] == "true" if err := be.SetHidden(ctx, repo, hidden); err != nil { return err } } return nil }, } return cmd } ================================================ FILE: pkg/ssh/cmd/import.go ================================================ package cmd import ( "errors" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/task" "github.com/spf13/cobra" ) // importCommand is the command for creating a new repository. func importCommand() *cobra.Command { var private bool var description string var projectName string var mirror bool var hidden bool var lfs bool var lfsEndpoint string cmd := &cobra.Command{ Use: "import REPOSITORY REMOTE", Short: "Import a new repository from remote", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) user := proto.UserFromContext(ctx) name := args[0] remote := args[1] if _, err := be.ImportRepository(ctx, name, user, remote, proto.RepositoryOptions{ Private: private, Description: description, ProjectName: projectName, Mirror: mirror, Hidden: hidden, LFS: lfs, LFSEndpoint: lfsEndpoint, }); err != nil { if errors.Is(err, task.ErrAlreadyStarted) { return errors.New("import already in progress") } return err } return nil }, } cmd.Flags().BoolVarP(&lfs, "lfs", "", false, "pull Git LFS objects") cmd.Flags().StringVarP(&lfsEndpoint, "lfs-endpoint", "", "", "set the Git LFS endpoint") cmd.Flags().BoolVarP(&mirror, "mirror", "m", false, "mirror the repository") cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name") cmd.Flags().BoolVarP(&hidden, "hidden", "H", false, "hide the repository from the UI") return cmd } ================================================ FILE: pkg/ssh/cmd/info.go ================================================ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/spf13/cobra" ) // InfoCommand returns a command that shows the user's info func InfoCommand() *cobra.Command { cmd := &cobra.Command{ Use: "info", Short: "Show your info", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) pk := sshutils.PublicKeyFromContext(ctx) user, err := be.UserByPublicKey(ctx, pk) if err != nil { return err } cmd.Printf("Username: %s\n", user.Username()) cmd.Printf("Admin: %t\n", user.IsAdmin()) cmd.Printf("Public keys:\n") for _, pk := range user.PublicKeys() { cmd.Printf(" %s\n", sshutils.MarshalAuthorizedKey(pk)) } return nil }, } return cmd } ================================================ FILE: pkg/ssh/cmd/jwt.go ================================================ package cmd import ( "fmt" "time" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/jwk" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/golang-jwt/jwt/v5" "github.com/spf13/cobra" ) // JWTCommand returns a command that generates a JSON Web Token. func JWTCommand() *cobra.Command { cmd := &cobra.Command{ Use: "jwt [repository1 repository2...]", Short: "Generate a JSON Web Token", Args: cobra.MinimumNArgs(0), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cfg := config.FromContext(ctx) kp, err := jwk.NewPair(cfg) if err != nil { return err } user := proto.UserFromContext(ctx) if user == nil { return proto.ErrUserNotFound } now := time.Now() expiresAt := now.Add(time.Hour) claims := jwt.RegisteredClaims{ Subject: fmt.Sprintf("%s#%d", user.Username(), user.ID()), ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour NotBefore: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now), Issuer: cfg.HTTP.PublicURL, Audience: args, } token := jwt.NewWithClaims(jwk.SigningMethod, claims) token.Header["kid"] = kp.JWK().KeyID j, err := token.SignedString(kp.PrivateKey()) if err != nil { return err } cmd.Println(j) return nil }, } return cmd } ================================================ FILE: pkg/ssh/cmd/list.go ================================================ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/spf13/cobra" ) // listCommand returns a command that list file or directory at path. func listCommand() *cobra.Command { var all bool listCmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List repositories", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) pk := sshutils.PublicKeyFromContext(ctx) repos, err := be.Repositories(ctx) if err != nil { return err } for _, r := range repos { if be.AccessLevelByPublicKey(ctx, r.Name(), pk) >= access.ReadOnlyAccess { if !r.IsHidden() || all { cmd.Println(r.Name()) } } } return nil }, } listCmd.Flags().BoolVarP(&all, "all", "a", false, "List all repositories") return listCmd } ================================================ FILE: pkg/ssh/cmd/mirror.go ================================================ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/spf13/cobra" ) func mirrorCommand() *cobra.Command { cmd := &cobra.Command{ Use: "is-mirror REPOSITORY", Short: "Whether a repository is a mirror", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := args[0] rr, err := be.Repository(ctx, rn) if err != nil { return err } isMirror := rr.IsMirror() cmd.Println(isMirror) return nil }, } return cmd } ================================================ FILE: pkg/ssh/cmd/private.go ================================================ package cmd import ( "strconv" "strings" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/spf13/cobra" ) func privateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "private REPOSITORY [true|false]", Short: "Set or get a repository private property", Args: cobra.RangeArgs(1, 2), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := strings.TrimSuffix(args[0], ".git") switch len(args) { case 1: isPrivate, err := be.IsPrivate(ctx, rn) if err != nil { return err } cmd.Println(isPrivate) case 2: isPrivate, err := strconv.ParseBool(args[1]) if err != nil { return err } if err := checkIfCollab(cmd, args); err != nil { return err } if err := be.SetPrivate(ctx, rn, isPrivate); err != nil { return err } } return nil }, } return cmd } ================================================ FILE: pkg/ssh/cmd/project_name.go ================================================ package cmd import ( "strings" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/spf13/cobra" ) func projectName() *cobra.Command { cmd := &cobra.Command{ Use: "project-name REPOSITORY [NAME]", Aliases: []string{"project"}, Short: "Set or get the project name for a repository", Args: cobra.MinimumNArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := strings.TrimSuffix(args[0], ".git") switch len(args) { case 1: pn, err := be.ProjectName(ctx, rn) if err != nil { return err } cmd.Println(pn) default: if err := checkIfCollab(cmd, args); err != nil { return err } if err := be.SetProjectName(ctx, rn, strings.Join(args[1:], " ")); err != nil { return err } } return nil }, } return cmd } ================================================ FILE: pkg/ssh/cmd/pubkey.go ================================================ package cmd import ( "strings" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/spf13/cobra" ) // PubkeyCommand returns a command that manages user public keys. func PubkeyCommand() *cobra.Command { cmd := &cobra.Command{ Use: "pubkey", Aliases: []string{"pubkeys", "publickey", "publickeys"}, Short: "Manage your public keys", } pubkeyAddCommand := &cobra.Command{ Use: "add AUTHORIZED_KEY", Short: "Add a public key", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) pk := sshutils.PublicKeyFromContext(ctx) user, err := be.UserByPublicKey(ctx, pk) if err != nil { return err } apk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, " ")) if err != nil { return err } return be.AddPublicKey(ctx, user.Username(), apk) }, } pubkeyRemoveCommand := &cobra.Command{ Use: "remove AUTHORIZED_KEY", Args: cobra.MinimumNArgs(1), Short: "Remove a public key", RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) pk := sshutils.PublicKeyFromContext(ctx) user, err := be.UserByPublicKey(ctx, pk) if err != nil { return err } apk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, " ")) if err != nil { return err } return be.RemovePublicKey(ctx, user.Username(), apk) }, } pubkeyListCommand := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List public keys", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) pk := sshutils.PublicKeyFromContext(ctx) user, err := be.UserByPublicKey(ctx, pk) if err != nil { return err } pks := user.PublicKeys() for _, pk := range pks { cmd.Println(sshutils.MarshalAuthorizedKey(pk)) } return nil }, } cmd.AddCommand( pubkeyAddCommand, pubkeyRemoveCommand, pubkeyListCommand, ) return cmd } ================================================ FILE: pkg/ssh/cmd/rename.go ================================================ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/spf13/cobra" ) func renameCommand() *cobra.Command { cmd := &cobra.Command{ Use: "rename REPOSITORY NEW_NAME", Aliases: []string{"mv", "move"}, Short: "Rename an existing repository", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfReadableAndCollab, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) oldName := args[0] newName := args[1] return be.RenameRepository(ctx, oldName, newName) }, } return cmd } ================================================ FILE: pkg/ssh/cmd/repo.go ================================================ package cmd import ( "fmt" "strings" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/spf13/cobra" ) // RepoCommand returns a command for managing repositories. func RepoCommand() *cobra.Command { cmd := &cobra.Command{ Use: "repo", Aliases: []string{"repos", "repository", "repositories"}, Short: "Manage repositories", } cmd.AddCommand( blobCommand(), branchCommand(), collabCommand(), commitCommand(), createCommand(), deleteCommand(), descriptionCommand(), hiddenCommand(), importCommand(), listCommand(), mirrorCommand(), privateCommand(), projectName(), renameCommand(), tagCommand(), treeCommand(), webhookCommand(), ) cmd.AddCommand( &cobra.Command{ Use: "info REPOSITORY", Short: "Get information about a repository", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := args[0] rr, err := be.Repository(ctx, rn) if err != nil { return err } r, err := rr.Open() if err != nil { return err } head, err := r.HEAD() if err != nil { return err } var owner proto.User if rr.UserID() > 0 { owner, err = be.UserByID(ctx, rr.UserID()) if err != nil { return err } } branches, _ := r.Branches() tags, _ := r.Tags() // project name and description are optional, handle trailing // whitespace to avoid breaking tests. cmd.Println(strings.TrimSpace(fmt.Sprint("Project Name: ", rr.ProjectName()))) cmd.Println("Repository:", rr.Name()) cmd.Println(strings.TrimSpace(fmt.Sprint("Description: ", rr.Description()))) cmd.Println("Private:", rr.IsPrivate()) cmd.Println("Hidden:", rr.IsHidden()) cmd.Println("Mirror:", rr.IsMirror()) if owner != nil { cmd.Println(strings.TrimSpace(fmt.Sprint("Owner: ", owner.Username()))) } cmd.Println("Default Branch:", head.Name().Short()) if len(branches) > 0 { cmd.Println("Branches:") for _, b := range branches { cmd.Println(" -", b) } } if len(tags) > 0 { cmd.Println("Tags:") for _, t := range tags { cmd.Println(" -", t) } } return nil }, }, ) return cmd } ================================================ FILE: pkg/ssh/cmd/set_username.go ================================================ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/spf13/cobra" ) // SetUsernameCommand returns a command that sets the user's username. func SetUsernameCommand() *cobra.Command { cmd := &cobra.Command{ Use: "set-username USERNAME", Short: "Set your username", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) pk := sshutils.PublicKeyFromContext(ctx) user, err := be.UserByPublicKey(ctx, pk) if err != nil { return err } return be.SetUsername(ctx, user.Username(), args[0]) }, } return cmd } ================================================ FILE: pkg/ssh/cmd/settings.go ================================================ package cmd import ( "fmt" "strconv" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/spf13/cobra" ) // SettingsCommand returns a command that manages server settings. func SettingsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "settings", Short: "Manage server settings", } cmd.AddCommand( &cobra.Command{ Use: "allow-keyless [true|false]", Short: "Set or get allow keyless access to repositories", Args: cobra.RangeArgs(0, 1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) switch len(args) { case 0: cmd.Println(be.AllowKeyless(ctx)) case 1: v, _ := strconv.ParseBool(args[0]) if err := be.SetAllowKeyless(ctx, v); err != nil { return err } } return nil }, }, ) als := []string{access.NoAccess.String(), access.ReadOnlyAccess.String(), access.ReadWriteAccess.String(), access.AdminAccess.String()} cmd.AddCommand( &cobra.Command{ Use: "anon-access [ACCESS_LEVEL]", Short: "Set or get the default access level for anonymous users", Args: cobra.RangeArgs(0, 1), ValidArgs: als, PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) switch len(args) { case 0: cmd.Println(be.AnonAccess(ctx)) case 1: al := access.ParseAccessLevel(args[0]) if al < 0 { return fmt.Errorf("invalid access level: %s. Please choose one of the following: %s", args[0], als) } if err := be.SetAnonAccess(ctx, al); err != nil { return err } } return nil }, }, ) return cmd } ================================================ FILE: pkg/ssh/cmd/tag.go ================================================ package cmd import ( "strings" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/webhook" "github.com/spf13/cobra" ) func tagCommand() *cobra.Command { cmd := &cobra.Command{ Use: "tag", Short: "Manage repository tags", } cmd.AddCommand( tagListCommand(), tagDeleteCommand(), ) return cmd } func tagListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list REPOSITORY", Aliases: []string{"ls"}, Short: "List repository tags", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := strings.TrimSuffix(args[0], ".git") rr, err := be.Repository(ctx, rn) if err != nil { return err } r, err := rr.Open() if err != nil { return err } tags, _ := r.Tags() for _, t := range tags { cmd.Println(t) } return nil }, } return cmd } func tagDeleteCommand() *cobra.Command { cmd := &cobra.Command{ Use: "delete REPOSITORY TAG", Aliases: []string{"remove", "rm", "del"}, Short: "Delete a tag", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfReadableAndCollab, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := strings.TrimSuffix(args[0], ".git") rr, err := be.Repository(ctx, rn) if err != nil { return err } r, err := rr.Open() if err != nil { log.Errorf("failed to open repo: %s", err) return err } tag := args[1] tags, _ := r.Tags() var exists bool for _, t := range tags { if tag == t { exists = true break } } if !exists { log.Errorf("failed to get tag: tag %s does not exist", tag) return git.ErrReferenceNotExist } tagCommit, err := r.TagCommit(tag) if err != nil { log.Errorf("failed to get tag commit: %s", err) return err } if err := r.DeleteTag(tag); err != nil { log.Errorf("failed to delete tag: %s", err) return err } wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsTags+tag, tagCommit.ID.String(), git.ZeroID) if err != nil { log.Error("failed to create branch_tag webhook", "err", err) return err } return webhook.SendEvent(ctx, wh) }, } return cmd } ================================================ FILE: pkg/ssh/cmd/token.go ================================================ package cmd import ( "strconv" "strings" "time" "charm.land/lipgloss/v2/table" "github.com/caarlos0/duration" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/dustin/go-humanize" "github.com/spf13/cobra" ) // TokenCommand returns a command that manages user access tokens. func TokenCommand() *cobra.Command { cmd := &cobra.Command{ Use: "token", Aliases: []string{"access-token"}, Short: "Manage access tokens", } var createExpiresIn string createCmd := &cobra.Command{ Use: "create NAME", Short: "Create a new access token", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) name := strings.Join(args, " ") user := proto.UserFromContext(ctx) if user == nil { return proto.ErrUserNotFound } var expiresAt time.Time var expiresIn time.Duration if createExpiresIn != "" { d, err := duration.Parse(createExpiresIn) if err != nil { return err } expiresIn = d expiresAt = time.Now().Add(d) } token, err := be.CreateAccessToken(ctx, user, name, expiresAt) if err != nil { return err } notice := "Access token created" if expiresIn != 0 { notice += " (expires in " + humanize.Time(expiresAt) + ")" } cmd.PrintErrln(notice) cmd.Println(token) return nil }, } createCmd.Flags().StringVar(&createExpiresIn, "expires-in", "", "Token expiration time (e.g. 1y, 3mo, 2w, 5d4h, 1h30m)") listCmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List access tokens", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) user := proto.UserFromContext(ctx) if user == nil { return proto.ErrUserNotFound } tokens, err := be.ListAccessTokens(ctx, user) if err != nil { return err } if len(tokens) == 0 { cmd.Println("No tokens found") return nil } now := time.Now() table := table.New().Headers("ID", "Name", "Created At", "Expires In") for _, token := range tokens { expiresAt := "-" if !token.ExpiresAt.IsZero() { if now.After(token.ExpiresAt) { expiresAt = "expired" } else { expiresAt = humanize.Time(token.ExpiresAt) } } table = table.Row(strconv.FormatInt(token.ID, 10), token.Name, humanize.Time(token.CreatedAt), expiresAt, ) } cmd.Println(table) return nil }, } deleteCmd := &cobra.Command{ Use: "delete ID", Aliases: []string{"rm", "remove"}, Short: "Delete an access token", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) user := proto.UserFromContext(ctx) if user == nil { return proto.ErrUserNotFound } id, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return err } if err := be.DeleteAccessToken(ctx, user, id); err != nil { return err } cmd.PrintErrln("Access token deleted") return nil }, } cmd.AddCommand( createCmd, listCmd, deleteCmd, ) return cmd } ================================================ FILE: pkg/ssh/cmd/tree.go ================================================ package cmd import ( "fmt" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/dustin/go-humanize" "github.com/spf13/cobra" ) // treeCommand returns a command that list file or directory at path. func treeCommand() *cobra.Command { cmd := &cobra.Command{ Use: "tree REPOSITORY [REFERENCE] [PATH]", Short: "Print repository tree at path", Args: cobra.RangeArgs(1, 3), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) rn := args[0] path := "" ref := "" switch len(args) { case 2: path = args[1] case 3: ref = args[1] path = args[2] } rr, err := be.Repository(ctx, rn) if err != nil { return err } r, err := rr.Open() if err != nil { return err } if ref == "" { head, err := r.HEAD() if err != nil { if bs, err := r.Branches(); err != nil && len(bs) == 0 { return fmt.Errorf("repository is empty") } return err } ref = head.ID } tree, err := r.LsTree(ref) if err != nil { return err } ents := git.Entries{} if path != "" && path != "/" { te, err := tree.TreeEntry(path) if err == git.ErrRevisionNotExist { return proto.ErrFileNotFound } if err != nil { return err } if te.Type() == "tree" { tree, err = tree.SubTree(path) if err != nil { return err } ents, err = tree.Entries() if err != nil { return err } } else { ents = append(ents, te) } } else { ents, err = tree.Entries() if err != nil { return err } } ents.Sort() for _, ent := range ents { size := ent.Size() ssize := "" if size == 0 { ssize = "-" } else { ssize = humanize.Bytes(uint64(size)) //nolint:gosec } cmd.Printf("%s\t%s\t %s\n", ent.Mode(), ssize, common.UnquoteFilename(ent.Name())) } return nil }, } return cmd } ================================================ FILE: pkg/ssh/cmd/user.go ================================================ package cmd import ( "sort" "strings" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" ) // UserCommand returns the user subcommand. func UserCommand() *cobra.Command { cmd := &cobra.Command{ Use: "user", Aliases: []string{"users"}, Short: "Manage users", } var admin bool var key string userCreateCommand := &cobra.Command{ Use: "create USERNAME", Short: "Create a new user", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { var pubkeys []ssh.PublicKey ctx := cmd.Context() be := backend.FromContext(ctx) username := args[0] if key != "" { pk, _, err := sshutils.ParseAuthorizedKey(key) if err != nil { return err } pubkeys = []ssh.PublicKey{pk} } opts := proto.UserOptions{ Admin: admin, PublicKeys: pubkeys, } _, err := be.CreateUser(ctx, username, opts) return err }, } userCreateCommand.Flags().BoolVarP(&admin, "admin", "a", false, "make the user an admin") userCreateCommand.Flags().StringVarP(&key, "key", "k", "", "add a public key to the user") userDeleteCommand := &cobra.Command{ Use: "delete USERNAME", Short: "Delete a user", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) username := args[0] return be.DeleteUser(ctx, username) }, } userListCommand := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List users", Args: cobra.NoArgs, PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) users, err := be.Users(ctx) if err != nil { return err } sort.Strings(users) for _, u := range users { cmd.Println(u) } return nil }, } userAddPubkeyCommand := &cobra.Command{ Use: "add-pubkey USERNAME AUTHORIZED_KEY", Short: "Add a public key to a user", Args: cobra.MinimumNArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) username := args[0] pubkey := strings.Join(args[1:], " ") pk, _, err := sshutils.ParseAuthorizedKey(pubkey) if err != nil { return err } return be.AddPublicKey(ctx, username, pk) }, } userRemovePubkeyCommand := &cobra.Command{ Use: "remove-pubkey USERNAME AUTHORIZED_KEY", Short: "Remove a public key from a user", Args: cobra.MinimumNArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) username := args[0] pubkey := strings.Join(args[1:], " ") pk, _, err := sshutils.ParseAuthorizedKey(pubkey) if err != nil { return err } return be.RemovePublicKey(ctx, username, pk) }, } userSetAdminCommand := &cobra.Command{ Use: "set-admin USERNAME [true|false]", Short: "Make a user an admin", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) username := args[0] return be.SetAdmin(ctx, username, args[1] == "true") }, } userInfoCommand := &cobra.Command{ Use: "info USERNAME", Short: "Show information about a user", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) username := args[0] user, err := be.User(ctx, username) if err != nil { return err } isAdmin := user.IsAdmin() cmd.Printf("Username: %s\n", user.Username()) cmd.Printf("Admin: %t\n", isAdmin) cmd.Printf("Public keys:\n") for _, pk := range user.PublicKeys() { cmd.Printf(" %s\n", sshutils.MarshalAuthorizedKey(pk)) } return nil }, } userSetUsernameCommand := &cobra.Command{ Use: "set-username USERNAME NEW_USERNAME", Short: "Change a user's username", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) username := args[0] newUsername := args[1] return be.SetUsername(ctx, username, newUsername) }, } cmd.AddCommand( userCreateCommand, userAddPubkeyCommand, userInfoCommand, userListCommand, userDeleteCommand, userRemovePubkeyCommand, userSetAdminCommand, userSetUsernameCommand, ) return cmd } ================================================ FILE: pkg/ssh/cmd/webhooks.go ================================================ package cmd import ( "fmt" "strconv" "strings" "charm.land/lipgloss/v2/table" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/charmbracelet/soft-serve/pkg/webhook" "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/spf13/cobra" ) func webhookCommand() *cobra.Command { cmd := &cobra.Command{ Use: "webhook", Aliases: []string{"webhooks"}, Short: "Manage repository webhooks", } cmd.AddCommand( webhookListCommand(), webhookCreateCommand(), webhookDeleteCommand(), webhookUpdateCommand(), webhookDeliveriesCommand(), ) return cmd } var webhookEvents []string func init() { events := webhook.Events() webhookEvents = make([]string, len(events)) for i, e := range events { webhookEvents[i] = e.String() } } func webhookListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list REPOSITORY", Short: "List repository webhooks", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repo, err := be.Repository(ctx, args[0]) if err != nil { return err } webhooks, err := be.ListWebhooks(ctx, repo) if err != nil { return err } table := table.New().Headers("ID", "URL", "Events", "Active", "Created At", "Updated At") for _, h := range webhooks { events := make([]string, len(h.Events)) for i, e := range h.Events { events[i] = e.String() } table = table.Row( strconv.FormatInt(h.ID, 10), utils.Sanitize(h.URL), strings.Join(events, ","), strconv.FormatBool(h.Active), humanize.Time(h.CreatedAt), humanize.Time(h.UpdatedAt), ) } cmd.Println(table) return nil }, } return cmd } func webhookCreateCommand() *cobra.Command { var events []string var secret string var active bool var contentType string cmd := &cobra.Command{ Use: "create REPOSITORY URL", Short: "Create a repository webhook", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repo, err := be.Repository(ctx, args[0]) if err != nil { return err } var evs []webhook.Event for _, e := range events { ev, err := webhook.ParseEvent(e) if err != nil { return fmt.Errorf("invalid event: %w", err) } evs = append(evs, ev) } var ct webhook.ContentType switch strings.ToLower(strings.TrimSpace(contentType)) { case "json": ct = webhook.ContentTypeJSON case "form": ct = webhook.ContentTypeForm default: return webhook.ErrInvalidContentType } url := utils.Sanitize(args[1]) return be.CreateWebhook(ctx, repo, strings.TrimSpace(url), ct, secret, evs, active) }, } cmd.Flags().StringSliceVarP(&events, "events", "e", nil, fmt.Sprintf("events to trigger the webhook, available events are (%s)", strings.Join(webhookEvents, ", "))) cmd.Flags().StringVarP(&secret, "secret", "s", "", "secret to sign the webhook payload") cmd.Flags().BoolVarP(&active, "active", "a", true, "whether the webhook is active") cmd.Flags().StringVarP(&contentType, "content-type", "c", "json", "content type of the webhook payload, can be either `json` or `form`") return cmd } func webhookDeleteCommand() *cobra.Command { cmd := &cobra.Command{ Use: "delete REPOSITORY WEBHOOK_ID", Short: "Delete a repository webhook", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repo, err := be.Repository(ctx, args[0]) if err != nil { return err } id, err := strconv.ParseInt(args[1], 10, 64) if err != nil { return fmt.Errorf("invalid webhook ID: %w", err) } return be.DeleteWebhook(ctx, repo, id) }, } return cmd } func webhookUpdateCommand() *cobra.Command { var events []string var secret string var active string var contentType string var url string cmd := &cobra.Command{ Use: "update REPOSITORY WEBHOOK_ID", Short: "Update a repository webhook", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repo, err := be.Repository(ctx, args[0]) if err != nil { return err } id, err := strconv.ParseInt(args[1], 10, 64) if err != nil { return fmt.Errorf("invalid webhook ID: %w", err) } wh, err := be.Webhook(ctx, repo, id) if err != nil { return err } newURL := wh.URL if url != "" { newURL = url } newSecret := wh.Secret if secret != "" { newSecret = secret } newActive := wh.Active if active != "" { active, err := strconv.ParseBool(active) if err != nil { return fmt.Errorf("invalid active value: %w", err) } newActive = active } newContentType := wh.ContentType if contentType != "" { var ct webhook.ContentType switch strings.ToLower(strings.TrimSpace(contentType)) { case "json": ct = webhook.ContentTypeJSON case "form": ct = webhook.ContentTypeForm default: return webhook.ErrInvalidContentType } newContentType = ct } newEvents := wh.Events if len(events) > 0 { var evs []webhook.Event for _, e := range events { ev, err := webhook.ParseEvent(e) if err != nil { return fmt.Errorf("invalid event: %w", err) } evs = append(evs, ev) } newEvents = evs } return be.UpdateWebhook(ctx, repo, id, newURL, newContentType, newSecret, newEvents, newActive) }, } cmd.Flags().StringSliceVarP(&events, "events", "e", nil, fmt.Sprintf("events to trigger the webhook, available events are (%s)", strings.Join(webhookEvents, ", "))) cmd.Flags().StringVarP(&secret, "secret", "s", "", "secret to sign the webhook payload") cmd.Flags().StringVarP(&active, "active", "a", "", "whether the webhook is active") cmd.Flags().StringVarP(&contentType, "content-type", "c", "", "content type of the webhook payload, can be either `json` or `form`") cmd.Flags().StringVarP(&url, "url", "u", "", "webhook URL") return cmd } func webhookDeliveriesCommand() *cobra.Command { cmd := &cobra.Command{ Use: "deliveries", Short: "Manage webhook deliveries", Aliases: []string{"delivery", "deliver"}, } cmd.AddCommand( webhookDeliveriesListCommand(), webhookDeliveriesRedeliverCommand(), webhookDeliveriesGetCommand(), ) return cmd } func webhookDeliveriesListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list REPOSITORY WEBHOOK_ID", Short: "List webhook deliveries", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) id, err := strconv.ParseInt(args[1], 10, 64) if err != nil { return fmt.Errorf("invalid webhook ID: %w", err) } dels, err := be.ListWebhookDeliveries(ctx, id) if err != nil { return err } table := table.New().Headers("Status", "ID", "Event", "Created At") for _, d := range dels { status := "❌" if d.ResponseStatus >= 200 && d.ResponseStatus < 300 { status = "✅" } table = table.Row( status, d.ID.String(), d.Event.String(), humanize.Time(d.CreatedAt), ) } cmd.Println(table) return nil }, } return cmd } func webhookDeliveriesRedeliverCommand() *cobra.Command { cmd := &cobra.Command{ Use: "redeliver REPOSITORY WEBHOOK_ID DELIVERY_ID", Short: "Redeliver a webhook delivery", Args: cobra.ExactArgs(3), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) repo, err := be.Repository(ctx, args[0]) if err != nil { return err } id, err := strconv.ParseInt(args[1], 10, 64) if err != nil { return fmt.Errorf("invalid webhook ID: %w", err) } delID, err := uuid.Parse(args[2]) if err != nil { return fmt.Errorf("invalid delivery ID: %w", err) } return be.RedeliverWebhookDelivery(ctx, repo, id, delID) }, } return cmd } func webhookDeliveriesGetCommand() *cobra.Command { cmd := &cobra.Command{ Use: "get REPOSITORY WEBHOOK_ID DELIVERY_ID", Short: "Get a webhook delivery", Args: cobra.ExactArgs(3), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) id, err := strconv.ParseInt(args[1], 10, 64) if err != nil { return fmt.Errorf("invalid webhook ID: %w", err) } delID, err := uuid.Parse(args[2]) if err != nil { return fmt.Errorf("invalid delivery ID: %w", err) } del, err := be.WebhookDelivery(ctx, id, delID) if err != nil { return err } out := cmd.OutOrStdout() fmt.Fprintf(out, "ID: %s\n", del.ID) //nolint:errcheck fmt.Fprintf(out, "Event: %s\n", del.Event) //nolint:errcheck fmt.Fprintf(out, "Request URL: %s\n", del.RequestURL) //nolint:errcheck fmt.Fprintf(out, "Request Method: %s\n", del.RequestMethod) //nolint:errcheck fmt.Fprintf(out, "Request Error: %s\n", del.RequestError.String) //nolint:errcheck fmt.Fprintf(out, "Request Headers:\n") //nolint:errcheck reqHeaders := strings.Split(del.RequestHeaders, "\n") for _, h := range reqHeaders { fmt.Fprintf(out, " %s\n", h) //nolint:errcheck } fmt.Fprintf(out, "Request Body:\n") //nolint:errcheck reqBody := strings.Split(del.RequestBody, "\n") for _, b := range reqBody { fmt.Fprintf(out, " %s\n", b) //nolint:errcheck } fmt.Fprintf(out, "Response Status: %d\n", del.ResponseStatus) //nolint:errcheck fmt.Fprintf(out, "Response Headers:\n") //nolint:errcheck resHeaders := strings.Split(del.ResponseHeaders, "\n") for _, h := range resHeaders { fmt.Fprintf(out, " %s\n", h) //nolint:errcheck } fmt.Fprintf(out, "Response Body:\n") //nolint:errcheck resBody := strings.Split(del.ResponseBody, "\n") for _, b := range resBody { fmt.Fprintf(out, " %s\n", b) //nolint:errcheck } return nil }, } return cmd } ================================================ FILE: pkg/ssh/middleware.go ================================================ package ssh import ( "fmt" "strconv" "time" "charm.land/log/v2" "charm.land/wish/v2" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ssh/cmd" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/ssh" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/spf13/cobra" gossh "golang.org/x/crypto/ssh" ) // ErrPermissionDenied is returned when a user is not allowed connect. var ErrPermissionDenied = fmt.Errorf("permission denied") // AuthenticationMiddleware handles authentication. func AuthenticationMiddleware(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { // XXX: The authentication key is set in the context but gossh doesn't // validate the authentication. We need to verify that the _last_ key // that was approved is the one that's being used. ctx := s.Context() be := backend.FromContext(ctx) var pkFp string perms := s.Permissions().Permissions pk := s.PublicKey() if pk != nil { // There is no public key stored in the context, public-key auth // was never requested, skip if perms == nil { wish.Fatalln(s, ErrPermissionDenied) return } pkFp = gossh.FingerprintSHA256(pk) } // Check if the key is the same as the one we have in context fp := perms.Extensions["pubkey-fp"] if fp != "" && fp != pkFp { wish.Fatalln(s, ErrPermissionDenied) return } ac := be.AllowKeyless(ctx) publicKeyCounter.WithLabelValues(strconv.FormatBool(ac || pk != nil)).Inc() if !ac && pk == nil { wish.Fatalln(s, ErrPermissionDenied) return } // Set the auth'd user, or anon, in the context var user proto.User if pk != nil { user, _ = be.UserByPublicKey(ctx, pk) } ctx.SetValue(proto.ContextKeyUser, user) sh(s) } } // ContextMiddleware adds the config, backend, and logger to the session context. func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler { return func(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { ctx := s.Context() ctx.SetValue(sshutils.ContextKeySession, s) ctx.SetValue(config.ContextKey, cfg) ctx.SetValue(db.ContextKey, dbx) ctx.SetValue(store.ContextKey, datastore) ctx.SetValue(backend.ContextKey, be) ctx.SetValue(log.ContextKey, logger.WithPrefix("ssh")) sh(s) } } } var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "cli", Name: "commands_total", Help: "Total times each command was called", }, []string{"command"}) // CommandMiddleware handles git commands and CLI commands. // This middleware must be run after the ContextMiddleware. func CommandMiddleware(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { _, _, ptyReq := s.Pty() if ptyReq { sh(s) return } ctx := s.Context() cfg := config.FromContext(ctx) args := s.Command() cliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc() rootCmd := &cobra.Command{ Short: "Soft Serve is a self-hostable Git server for the command line.", SilenceUsage: true, } rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.SetUsageTemplate(cmd.UsageTemplate) rootCmd.SetUsageFunc(cmd.UsageFunc) rootCmd.AddCommand( cmd.GitUploadPackCommand(), cmd.GitUploadArchiveCommand(), cmd.GitReceivePackCommand(), cmd.RepoCommand(), cmd.SettingsCommand(), cmd.UserCommand(), cmd.InfoCommand(), cmd.PubkeyCommand(), cmd.SetUsernameCommand(), cmd.JWTCommand(), cmd.TokenCommand(), ) if cfg.LFS.Enabled { rootCmd.AddCommand( cmd.GitLFSAuthenticateCommand(), ) if cfg.LFS.SSHEnabled { rootCmd.AddCommand( cmd.GitLFSTransfer(), ) } } rootCmd.SetArgs(args) if len(args) == 0 { // otherwise it'll default to os.Args, which is not what we want. rootCmd.SetArgs([]string{"--help"}) } rootCmd.SetIn(s) rootCmd.SetOut(s) rootCmd.SetErr(s.Stderr()) rootCmd.SetContext(ctx) if err := rootCmd.ExecuteContext(ctx); err != nil { s.Exit(1) //nolint: errcheck return } } } // LoggingMiddleware logs the ssh connection and command. func LoggingMiddleware(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { ctx := s.Context() logger := log.FromContext(ctx).WithPrefix("ssh") ct := time.Now() hpk := sshutils.MarshalAuthorizedKey(s.PublicKey()) ptyReq, _, isPty := s.Pty() addr := s.RemoteAddr().String() user := proto.UserFromContext(ctx) logArgs := []interface{}{ "addr", addr, "cmd", s.Command(), } if user != nil { logArgs = append([]interface{}{ "username", user.Username(), }, logArgs...) } if isPty { logArgs = []interface{}{ "term", ptyReq.Term, "width", ptyReq.Window.Width, "height", ptyReq.Window.Height, } } if config.IsVerbose() { logArgs = append(logArgs, "key", hpk, "envs", s.Environ(), ) } msg := fmt.Sprintf("user %q", s.User()) logger.Debug(msg+" connected", logArgs...) sh(s) logger.Debug(msg+" disconnected", append(logArgs, "duration", time.Since(ct))...) } } ================================================ FILE: pkg/ssh/middleware_test.go ================================================ package ssh import ( "context" "net" "testing" "github.com/charmbracelet/keygen" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/migrate" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/store/database" "github.com/charmbracelet/ssh" "github.com/matryer/is" gossh "golang.org/x/crypto/ssh" _ "modernc.org/sqlite" ) // TestAuthenticationBypass tests for CVE-TBD: Authentication Bypass Vulnerability // // VULNERABILITY: // A critical authentication bypass allows an attacker to impersonate any user // (including Admin) by "offering" the victim's public key during the SSH handshake // before authenticating with their own valid key. This occurs because the user // identity is stored in the session context during the "offer" phase in // PublicKeyHandler and is not properly cleared/validated in AuthenticationMiddleware. // // This test verifies that: // 1. User context is properly set based on the AUTHENTICATED key, not offered keys // 2. User context from failed authentication attempts is not preserved // 3. Non-admin users cannot gain admin privileges through this attack func TestAuthenticationBypass(t *testing.T) { is := is.New(t) ctx := context.Background() // Setup temporary database dp := t.TempDir() cfg := config.DefaultConfig() cfg.DataPath = dp cfg.DB.Driver = "sqlite" cfg.DB.DataSource = dp + "/test.db" ctx = config.WithContext(ctx, cfg) dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource) is.NoErr(err) defer dbx.Close() is.NoErr(migrate.Migrate(ctx, dbx)) dbstore := database.New(ctx, dbx) ctx = store.WithContext(ctx, dbstore) be := backend.New(ctx, cfg, dbx, dbstore) ctx = backend.WithContext(ctx, be) // Generate keys for admin and attacker adminKeyPath := dp + "/admin_key" adminPair, err := keygen.New(adminKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) is.NoErr(err) attackerKeyPath := dp + "/attacker_key" attackerPair, err := keygen.New(attackerKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) is.NoErr(err) // Parse public keys adminPubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(adminPair.AuthorizedKey())) is.NoErr(err) attackerPubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(attackerPair.AuthorizedKey())) is.NoErr(err) // Create admin user adminUser, err := be.CreateUser(ctx, "testadmin", proto.UserOptions{ Admin: true, PublicKeys: []gossh.PublicKey{adminPubKey}, }) is.NoErr(err) is.True(adminUser != nil) // Create attacker (non-admin) user attackerUser, err := be.CreateUser(ctx, "testattacker", proto.UserOptions{ Admin: false, PublicKeys: []gossh.PublicKey{attackerPubKey}, }) is.NoErr(err) is.True(attackerUser != nil) is.True(!attackerUser.IsAdmin()) // Verify attacker is NOT admin // Test: Verify that looking up user by key gives correct user t.Run("user_lookup_by_key", func(t *testing.T) { is := is.New(t) // Looking up admin key should return admin user user, err := be.UserByPublicKey(ctx, adminPubKey) is.NoErr(err) is.Equal(user.Username(), "testadmin") is.True(user.IsAdmin()) // Looking up attacker key should return attacker user user, err = be.UserByPublicKey(ctx, attackerPubKey) is.NoErr(err) is.Equal(user.Username(), "testattacker") is.True(!user.IsAdmin()) }) // Test: Simulate the authentication bypass vulnerability // This test documents the EXPECTED behavior to prevent regression t.Run("authentication_bypass_simulation", func(t *testing.T) { is := is.New(t) // Create a mock context mockCtx := &mockSSHContext{ Context: ctx, values: make(map[any]any), permissions: &ssh.Permissions{Permissions: &gossh.Permissions{Extensions: make(map[string]string)}}, } // ATTACK SIMULATION: // Step 1: SSH client offers admin's public key // PublicKeyHandler is called and sets admin user in context mockCtx.SetValue(proto.ContextKeyUser, adminUser) mockCtx.permissions.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(adminPubKey) // Step 2: Signature verification FAILS (attacker doesn't have admin's private key) // SSH protocol continues to next key... // Step 3: SSH client offers attacker's key (which SUCCEEDS) // PublicKeyHandler is called again, fingerprint is updated mockCtx.permissions.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(attackerPubKey) // BUG: Admin user is STILL in context from step 1! // Step 4: AuthenticationMiddleware should re-lookup user based on authenticated key // The middleware MUST NOT trust the user already in context authenticatedUser, err := be.UserByPublicKey(mockCtx, attackerPubKey) is.NoErr(err) // EXPECTED: User should be "attacker", NOT "admin" is.Equal(authenticatedUser.Username(), "testattacker") is.True(!authenticatedUser.IsAdmin()) // If the vulnerability exists, the context would still have admin user contextUser := proto.UserFromContext(mockCtx) if contextUser != nil && contextUser.Username() == "testadmin" { t.Logf("WARNING: Context still contains admin user! This indicates the vulnerability exists.") t.Logf("The authenticated key is attacker's, but context has admin user.") } }) } // mockSSHContext implements ssh.Context for testing type mockSSHContext struct { context.Context values map[any]any permissions *ssh.Permissions } func (m *mockSSHContext) SetValue(key, value any) { m.values[key] = value } func (m *mockSSHContext) Value(key any) any { if v, ok := m.values[key]; ok { return v } return m.Context.Value(key) } func (m *mockSSHContext) Permissions() *ssh.Permissions { return m.permissions } func (m *mockSSHContext) User() string { return "" } func (m *mockSSHContext) RemoteAddr() net.Addr { return &net.TCPAddr{} } func (m *mockSSHContext) LocalAddr() net.Addr { return &net.TCPAddr{} } func (m *mockSSHContext) ServerVersion() string { return "" } func (m *mockSSHContext) ClientVersion() string { return "" } func (m *mockSSHContext) SessionID() string { return "" } func (m *mockSSHContext) Lock() {} func (m *mockSSHContext) Unlock() {} ================================================ FILE: pkg/ssh/session.go ================================================ package ssh import ( "time" tea "charm.land/bubbletea/v2" "charm.land/wish/v2" bm "charm.land/wish/v2/bubbletea" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/ssh" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var tuiSessionCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "ssh", Name: "tui_session_total", Help: "The total number of TUI sessions", }, []string{"repo", "term"}) var tuiSessionDuration = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "ssh", Name: "tui_session_seconds_total", Help: "The total time spent in TUI sessions", }, []string{"repo", "term"}) // SessionHandler is the soft-serve bubbletea ssh session handler. // This middleware must be run after the ContextMiddleware. func SessionHandler(s ssh.Session) *tea.Program { pty, _, active := s.Pty() if !active { return nil } ctx := s.Context() be := backend.FromContext(ctx) cfg := config.FromContext(ctx) cmd := s.Command() initialRepo := "" if len(cmd) == 1 { initialRepo = cmd[0] } auth := be.AccessLevelByPublicKey(ctx, initialRepo, s.PublicKey()) if auth < access.ReadOnlyAccess { wish.Fatalln(s, proto.ErrUnauthorized) return nil } opts := bm.MakeOptions(s) opts = append(opts, tea.WithoutCatchPanics(), tea.WithContext(ctx), tea.WithColorProfile(common.DefaultColorProfile), ) c := common.NewCommon(ctx, pty.Window.Width, pty.Window.Height) c.SetValue(common.ConfigKey, cfg) m := NewUI(c, initialRepo) p := tea.NewProgram(m, opts...) tuiSessionCounter.WithLabelValues(initialRepo, pty.Term).Inc() start := time.Now() go func() { <-ctx.Done() tuiSessionDuration.WithLabelValues(initialRepo, pty.Term).Add(time.Since(start).Seconds()) }() return p } ================================================ FILE: pkg/ssh/session_test.go ================================================ package ssh import ( "context" "errors" "fmt" "os" "testing" "time" "charm.land/log/v2" bm "charm.land/wish/v2/bubbletea" "charm.land/wish/v2/testsession" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/migrate" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/store/database" "github.com/charmbracelet/soft-serve/pkg/test" "github.com/charmbracelet/ssh" "github.com/matryer/is" gossh "golang.org/x/crypto/ssh" _ "modernc.org/sqlite" // sqlite driver ) func TestSession(t *testing.T) { is := is.New(t) t.Run("authorized repo access", func(t *testing.T) { t.Log("setting up") s, close := setup(t) s.Stderr = os.Stderr t.Log("requesting pty") err := s.RequestPty("xterm", 80, 40, nil) is.NoErr(err) go func() { time.Sleep(1 * time.Second) // s.Signal(gossh.SIGTERM) s.Close() //nolint: errcheck }() t.Log("waiting for session to exit") _, err = s.Output("test") var ee *gossh.ExitMissingError is.True(errors.As(err, &ee)) t.Log("session exited") is.NoErr(close()) }) } func setup(tb testing.TB) (*gossh.Session, func() error) { tb.Helper() is := is.New(tb) dp := tb.TempDir() is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", dp)) is.NoErr(os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", ":9418")) is.NoErr(os.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", fmt.Sprintf(":%d", test.RandomPort()))) tb.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH")) is.NoErr(os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")) is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR")) is.NoErr(os.RemoveAll(dp)) }) ctx := context.TODO() cfg := config.DefaultConfig() if err := cfg.Validate(); err != nil { log.Fatal(err) } ctx = config.WithContext(ctx, cfg) dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource) if err != nil { tb.Fatal(err) } if err := migrate.Migrate(ctx, dbx); err != nil { tb.Fatal(err) } dbstore := database.New(ctx, dbx) ctx = store.WithContext(ctx, dbstore) be := backend.New(ctx, cfg, dbx, dbstore) return testsession.New(tb, &ssh.Server{ Handler: ContextMiddleware(cfg, dbx, dbstore, be, log.Default())(bm.MiddlewareWithProgramHandler(SessionHandler)(func(s ssh.Session) { _, _, active := s.Pty() if !active { os.Exit(1) } s.Exit(0) })), }, nil), dbx.Close } ================================================ FILE: pkg/ssh/ssh.go ================================================ package ssh import ( "context" "fmt" "net" "os" "strconv" "time" "charm.land/log/v2" "charm.land/wish/v2" bm "charm.land/wish/v2/bubbletea" rm "charm.land/wish/v2/recover" "github.com/charmbracelet/keygen" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/ssh" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" gossh "golang.org/x/crypto/ssh" ) var ( publicKeyCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "ssh", Name: "public_key_auth_total", Help: "The total number of public key auth requests", }, []string{"allowed"}) keyboardInteractiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "ssh", Name: "keyboard_interactive_auth_total", Help: "The total number of keyboard interactive auth requests", }, []string{"allowed"}) ) // SSHServer is a SSH server that implements the git protocol. type SSHServer struct { //nolint: revive srv *ssh.Server cfg *config.Config be *backend.Backend ctx context.Context logger *log.Logger } // NewSSHServer returns a new SSHServer. func NewSSHServer(ctx context.Context) (*SSHServer, error) { cfg := config.FromContext(ctx) logger := log.FromContext(ctx).WithPrefix("ssh") dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) be := backend.FromContext(ctx) var err error s := &SSHServer{ cfg: cfg, ctx: ctx, be: be, logger: logger, } mw := []wish.Middleware{ rm.MiddlewareWithLogger( logger, // BubbleTea middleware. bm.MiddlewareWithProgramHandler(SessionHandler), // CLI middleware. CommandMiddleware, // Logging middleware. LoggingMiddleware, // Authentication middleware. // gossh.PublicKeyHandler doesn't guarantee that the public key // is in fact the one used for authentication, so we need to // check it again here. AuthenticationMiddleware, // Context middleware. // This must come first to set up the context. ContextMiddleware(cfg, dbx, datastore, be, logger), ), } opts := []ssh.Option{ ssh.PublicKeyAuth(s.PublicKeyHandler), ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler), wish.WithAddress(cfg.SSH.ListenAddr), wish.WithHostKeyPath(cfg.SSH.KeyPath), wish.WithMiddleware(mw...), } // TODO: Support a real PTY in future version. opts = append(opts, ssh.EmulatePty()) s.srv, err = wish.NewServer(opts...) if err != nil { return nil, err } if config.IsDebug() { s.srv.ServerConfigCallback = func(_ ssh.Context) *gossh.ServerConfig { return &gossh.ServerConfig{ AuthLogCallback: func(conn gossh.ConnMetadata, method string, err error) { logger.Debug("authentication", "user", conn.User(), "method", method, "err", err) }, } } } if cfg.SSH.MaxTimeout > 0 { s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second } if cfg.SSH.IdleTimeout > 0 { s.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second } // Create client ssh key if _, err := os.Stat(cfg.SSH.ClientKeyPath); err != nil && os.IsNotExist(err) { _, err := keygen.New(cfg.SSH.ClientKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) if err != nil { return nil, fmt.Errorf("client ssh key: %w", err) } } return s, nil } // ListenAndServe starts the SSH server. func (s *SSHServer) ListenAndServe() error { return s.srv.ListenAndServe() } // Serve starts the SSH server on the given net.Listener. func (s *SSHServer) Serve(l net.Listener) error { return s.srv.Serve(l) } // Close closes the SSH server. func (s *SSHServer) Close() error { return s.srv.Close() } // Shutdown gracefully shuts down the SSH server. func (s *SSHServer) Shutdown(ctx context.Context) error { return s.srv.Shutdown(ctx) } func initializePermissions(ctx ssh.Context) { perms := ctx.Permissions() if perms == nil || perms.Permissions == nil { perms = &ssh.Permissions{Permissions: &gossh.Permissions{}} } if perms.Extensions == nil { perms.Extensions = make(map[string]string) } } // PublicKeyHandler handles public key authentication. func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed bool) { if pk == nil { return false } allowed = true // XXX: store the first "approved" public-key fingerprint in the // permissions block to use for authentication later. initializePermissions(ctx) perms := ctx.Permissions() // Set the public key fingerprint to be used for authentication. perms.Extensions["pubkey-fp"] = gossh.FingerprintSHA256(pk) ctx.SetValue(ssh.ContextKeyPermissions, perms) return } // KeyboardInteractiveHandler handles keyboard interactive authentication. // This is used after all public key authentication has failed. func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { ac := s.be.AllowKeyless(ctx) keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc() // If we're allowing keyless access, reset the public key fingerprint initializePermissions(ctx) perms := ctx.Permissions() if ac { // XXX: reset the public-key fingerprint. This is used to validate the // public key being used to authenticate. perms.Extensions["pubkey-fp"] = "" ctx.SetValue(ssh.ContextKeyPermissions, perms) } return ac } ================================================ FILE: pkg/ssh/ui.go ================================================ package ssh import ( "errors" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/footer" "github.com/charmbracelet/soft-serve/pkg/ui/components/header" "github.com/charmbracelet/soft-serve/pkg/ui/components/selector" "github.com/charmbracelet/soft-serve/pkg/ui/pages/repo" "github.com/charmbracelet/soft-serve/pkg/ui/pages/selection" ) type page int const ( selectionPage page = iota repoPage ) type sessionState int const ( loadingState sessionState = iota errorState readyState ) // UI is the main UI model. type UI struct { serverName string initialRepo string common common.Common pages []common.Component activePage page state sessionState header *header.Header footer *footer.Footer showFooter bool error error } // NewUI returns a new UI model. func NewUI(c common.Common, initialRepo string) *UI { serverName := c.Config().Name h := header.New(c, serverName) ui := &UI{ serverName: serverName, common: c, pages: make([]common.Component, 2), // selection & repo activePage: selectionPage, state: loadingState, header: h, initialRepo: initialRepo, showFooter: true, } ui.footer = footer.New(c, ui) return ui } func (ui *UI) getMargins() (wm, hm int) { style := ui.common.Styles.App switch ui.activePage { case selectionPage: hm += ui.common.Styles.ServerName.GetHeight() + ui.common.Styles.ServerName.GetVerticalFrameSize() case repoPage: } wm += style.GetHorizontalFrameSize() hm += style.GetVerticalFrameSize() if ui.showFooter { // NOTE: we don't use the footer's style to determine the margins // because footer.Height() is the height of the footer after applying // the styles. hm += ui.footer.Height() } return } // ShortHelp implements help.KeyMap. func (ui *UI) ShortHelp() []key.Binding { b := make([]key.Binding, 0) switch ui.state { case errorState: b = append(b, ui.common.KeyMap.Back) case readyState: b = append(b, ui.pages[ui.activePage].ShortHelp()...) } if !ui.IsFiltering() { b = append(b, ui.common.KeyMap.Quit) } b = append(b, ui.common.KeyMap.Help) return b } // FullHelp implements help.KeyMap. func (ui *UI) FullHelp() [][]key.Binding { b := make([][]key.Binding, 0) switch ui.state { case errorState: b = append(b, []key.Binding{ui.common.KeyMap.Back}) case readyState: b = append(b, ui.pages[ui.activePage].FullHelp()...) } h := []key.Binding{ ui.common.KeyMap.Help, } if !ui.IsFiltering() { h = append(h, ui.common.KeyMap.Quit) } b = append(b, h) return b } // SetSize implements common.Component. func (ui *UI) SetSize(width, height int) { ui.common.SetSize(width, height) wm, hm := ui.getMargins() ui.header.SetSize(width-wm, height-hm) ui.footer.SetSize(width-wm, height-hm) for _, p := range ui.pages { if p != nil { p.SetSize(width-wm, height-hm) } } } // Init implements tea.Model. func (ui *UI) Init() tea.Cmd { ui.pages[selectionPage] = selection.New(ui.common) ui.pages[repoPage] = repo.New(ui.common, repo.NewReadme(ui.common), repo.NewFiles(ui.common), repo.NewLog(ui.common), repo.NewRefs(ui.common, git.RefsHeads), repo.NewRefs(ui.common, git.RefsTags), ) ui.SetSize(ui.common.Width, ui.common.Height) cmds := make([]tea.Cmd, 0) cmds = append(cmds, ui.pages[selectionPage].Init(), ui.pages[repoPage].Init(), ) if ui.initialRepo != "" { cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo)) } ui.state = readyState ui.SetSize(ui.common.Width, ui.common.Height) return tea.Batch(cmds...) } // IsFiltering returns true if the selection page is filtering. func (ui *UI) IsFiltering() bool { if ui.activePage == selectionPage { if s, ok := ui.pages[selectionPage].(*selection.Selection); ok && s.FilterState() == list.Filtering { return true } } return false } // Update implements tea.Model. func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ui.common.Logger.Debugf("msg received: %T", msg) cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.WindowSizeMsg: ui.SetSize(msg.Width, msg.Height) for i, p := range ui.pages { m, cmd := p.Update(msg) ui.pages[i] = m.(common.Component) if cmd != nil { cmds = append(cmds, cmd) } } case tea.KeyPressMsg: switch { case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil: ui.error = nil ui.state = readyState // Always show the footer on error. ui.showFooter = ui.footer.ShowAll() case key.Matches(msg, ui.common.KeyMap.Help): cmds = append(cmds, footer.ToggleFooterCmd) case key.Matches(msg, ui.common.KeyMap.Quit): if !ui.IsFiltering() { // Stop bubblezone background workers. ui.common.Zone.Close() return ui, tea.Quit } case ui.activePage == repoPage && ui.pages[ui.activePage].(*repo.Repo).Path() == "" && key.Matches(msg, ui.common.KeyMap.Back): ui.activePage = selectionPage // Always show the footer on selection page. ui.showFooter = true } case tea.MouseClickMsg: switch msg.Button { case tea.MouseLeft: switch { case ui.common.Zone.Get("footer").InBounds(msg): cmds = append(cmds, footer.ToggleFooterCmd) } } case footer.ToggleFooterMsg: ui.footer.SetShowAll(!ui.footer.ShowAll()) // Show the footer when on repo page and shot all help. if ui.error == nil && ui.activePage == repoPage { ui.showFooter = !ui.showFooter } case repo.RepoMsg: ui.common.SetValue(common.RepoKey, msg) ui.activePage = repoPage // Show the footer on repo page if show all is set. ui.showFooter = ui.footer.ShowAll() cmds = append(cmds, repo.UpdateRefCmd(msg)) case common.ErrorMsg: ui.error = msg ui.state = errorState ui.showFooter = true case selector.SelectMsg: switch msg.IdentifiableItem.(type) { case selection.Item: if ui.activePage == selectionPage { cmds = append(cmds, ui.setRepoCmd(msg.ID())) } } } h, cmd := ui.header.Update(msg) ui.header = h.(*header.Header) if cmd != nil { cmds = append(cmds, cmd) } f, cmd := ui.footer.Update(msg) ui.footer = f.(*footer.Footer) if cmd != nil { cmds = append(cmds, cmd) } if ui.state != loadingState { m, cmd := ui.pages[ui.activePage].Update(msg) ui.pages[ui.activePage] = m.(common.Component) if cmd != nil { cmds = append(cmds, cmd) } } // This fixes determining the height margin of the footer. ui.SetSize(ui.common.Width, ui.common.Height) return ui, tea.Batch(cmds...) } // View implements tea.Model. func (ui *UI) View() tea.View { var v tea.View v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion var view string wm, hm := ui.getMargins() switch ui.state { case loadingState: view = "Loading..." case errorState: err := ui.common.Styles.ErrorTitle.Render("Bummer") err += ui.common.Styles.ErrorBody.Render(ui.error.Error()) view = ui.common.Styles.Error. Width(ui.common.Width - wm - ui.common.Styles.ErrorBody.GetHorizontalFrameSize()). Height(ui.common.Height - hm - ui.common.Styles.Error.GetVerticalFrameSize()). Render(err) case readyState: view = ui.pages[ui.activePage].View() default: view = "Unknown state :/ this is a bug!" } if ui.activePage == selectionPage { view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view) } if ui.showFooter { view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View()) } v.Content = ui.common.Zone.Scan( ui.common.Styles.App.Render(view), ) return v } func (ui *UI) openRepo(rn string) (proto.Repository, error) { cfg := ui.common.Config() if cfg == nil { return nil, errors.New("config is nil") } ctx := ui.common.Context() be := ui.common.Backend() repos, err := be.Repositories(ctx) if err != nil { ui.common.Logger.Debugf("ui: failed to list repos: %v", err) return nil, err } for _, r := range repos { if r.Name() == rn { return r, nil } } return nil, common.ErrMissingRepo } func (ui *UI) setRepoCmd(rn string) tea.Cmd { return func() tea.Msg { r, err := ui.openRepo(rn) if err != nil { return common.ErrorMsg(err) } return repo.RepoMsg(r) } } func (ui *UI) initialRepoCmd(rn string) tea.Cmd { return func() tea.Msg { r, err := ui.openRepo(rn) if err != nil { return nil } return repo.RepoMsg(r) } } ================================================ FILE: pkg/sshutils/utils.go ================================================ package sshutils import ( "bytes" "context" "github.com/charmbracelet/ssh" gossh "golang.org/x/crypto/ssh" ) // ParseAuthorizedKey parses an authorized key string into a public key. func ParseAuthorizedKey(ak string) (gossh.PublicKey, string, error) { pk, c, _, _, err := gossh.ParseAuthorizedKey([]byte(ak)) return pk, c, err } // MarshalAuthorizedKey marshals a public key into an authorized key string. // // This is the inverse of ParseAuthorizedKey. // This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline. // It returns an empty string if pk is nil. func MarshalAuthorizedKey(pk gossh.PublicKey) string { if pk == nil { return "" } return string(bytes.TrimSuffix(gossh.MarshalAuthorizedKey(pk), []byte("\n"))) } // KeysEqual returns whether the two public keys are equal. func KeysEqual(a, b gossh.PublicKey) bool { return ssh.KeysEqual(a, b) } // PublicKeyFromContext returns the public key from the context. func PublicKeyFromContext(ctx context.Context) gossh.PublicKey { if pk, ok := ctx.Value(ssh.ContextKeyPublicKey).(gossh.PublicKey); ok { return pk } return nil } // ContextKeySession is the context key for the SSH session. var ContextKeySession = &struct{ string }{"session"} // SessionFromContext returns the SSH session from the context. func SessionFromContext(ctx context.Context) ssh.Session { if s, ok := ctx.Value(ContextKeySession).(ssh.Session); ok { return s } return nil } ================================================ FILE: pkg/sshutils/utils_test.go ================================================ package sshutils import ( "testing" "github.com/charmbracelet/keygen" "golang.org/x/crypto/ssh" ) func generateKeys(tb testing.TB) (*keygen.SSHKeyPair, *keygen.SSHKeyPair) { goodKey1, err := keygen.New("", keygen.WithKeyType(keygen.Ed25519)) if err != nil { tb.Fatal(err) } goodKey2, err := keygen.New("", keygen.WithKeyType(keygen.RSA)) if err != nil { tb.Fatal(err) } return goodKey1, goodKey2 } func TestParseAuthorizedKey(t *testing.T) { goodKey1, goodKey2 := generateKeys(t) cases := []struct { in string good bool }{ { goodKey1.AuthorizedKey(), true, }, { goodKey2.AuthorizedKey(), true, }, { goodKey1.AuthorizedKey() + "test", false, }, { goodKey2.AuthorizedKey() + "bad", false, }, } for _, c := range cases { _, _, err := ParseAuthorizedKey(c.in) if c.good && err != nil { t.Errorf("ParseAuthorizedKey(%q) returned error: %v", c.in, err) } if !c.good && err == nil { t.Errorf("ParseAuthorizedKey(%q) did not return error", c.in) } } } func TestMarshalAuthorizedKey(t *testing.T) { goodKey1, goodKey2 := generateKeys(t) cases := []struct { in ssh.PublicKey expected string }{ { goodKey1.PublicKey(), goodKey1.AuthorizedKey(), }, { goodKey2.PublicKey(), goodKey2.AuthorizedKey(), }, { nil, "", }, } for _, c := range cases { out := MarshalAuthorizedKey(c.in) if out != c.expected { t.Errorf("MarshalAuthorizedKey(%v) returned %q, expected %q", c.in, out, c.expected) } } } func TestKeysEqual(t *testing.T) { goodKey1, goodKey2 := generateKeys(t) cases := []struct { in1 ssh.PublicKey in2 ssh.PublicKey expected bool }{ { goodKey1.PublicKey(), goodKey1.PublicKey(), true, }, { goodKey2.PublicKey(), goodKey2.PublicKey(), true, }, { goodKey1.PublicKey(), goodKey2.PublicKey(), false, }, { nil, nil, false, }, { nil, goodKey1.PublicKey(), false, }, } for _, c := range cases { out := KeysEqual(c.in1, c.in2) if out != c.expected { t.Errorf("KeysEqual(%v, %v) returned %v, expected %v", c.in1, c.in2, out, c.expected) } } } ================================================ FILE: pkg/ssrf/ssrf.go ================================================ package ssrf import ( "context" "errors" "fmt" "net" "net/http" "net/url" "slices" "strings" "time" ) var ( // ErrPrivateIP is returned when a connection to a private or internal IP is blocked. ErrPrivateIP = errors.New("connection to private or internal IP address is not allowed") // ErrInvalidScheme is returned when a URL scheme is not http or https. ErrInvalidScheme = errors.New("URL must use http or https scheme") // ErrInvalidURL is returned when a URL is invalid. ErrInvalidURL = errors.New("invalid URL") ) // NewSecureClient returns an HTTP client with SSRF protection. // It validates resolved IPs at dial time to block connections to private // and internal networks. Hostnames are resolved and the validated IP is // used directly in the dial call to prevent DNS rebinding (TOCTOU between // validation and connection). Redirects are disabled to match the webhook // client convention and prevent redirect-based SSRF. func NewSecureClient() *http.Client { return &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, err //nolint:wrapcheck } ip := net.ParseIP(host) if ip == nil { ips, err := net.LookupIP(host) //nolint if err != nil { return nil, fmt.Errorf("DNS resolution failed for host %s: %v", host, err) } if len(ips) == 0 { return nil, fmt.Errorf("no IP addresses found for host: %s", host) } ip = ips[0] // Use the first resolved IP address } if isPrivateOrInternal(ip) { return nil, fmt.Errorf("%w", ErrPrivateIP) } dialer := &net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, } // Dial using the validated IP to prevent DNS rebinding. // Without this, the dialer resolves the hostname again // independently, and the second resolution could return // a different (private) IP. return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) }, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } } // isPrivateOrInternal checks if an IP address is private, internal, or reserved. func isPrivateOrInternal(ip net.IP) bool { // Normalize IPv6-mapped IPv4 (e.g. ::ffff:127.0.0.1) to IPv4 form // so all checks apply consistently. if ip4 := ip.To4(); ip4 != nil { ip = ip4 } if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() { return true } if ip4 := ip.To4(); ip4 != nil { // 0.0.0.0/8 if ip4[0] == 0 { return true } // 100.64.0.0/10 (Shared Address Space / CGNAT) if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { return true } // 192.0.0.0/24 (IETF Protocol Assignments) if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 0 { return true } // 192.0.2.0/24 (TEST-NET-1) if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 2 { return true } // 198.18.0.0/15 (benchmarking) if ip4[0] == 198 && (ip4[1] == 18 || ip4[1] == 19) { return true } // 198.51.100.0/24 (TEST-NET-2) if ip4[0] == 198 && ip4[1] == 51 && ip4[2] == 100 { return true } // 203.0.113.0/24 (TEST-NET-3) if ip4[0] == 203 && ip4[1] == 0 && ip4[2] == 113 { return true } // 240.0.0.0/4 (Reserved, includes 255.255.255.255 broadcast) if ip4[0] >= 240 { return true } } return false } // ValidateURL validates that a URL is safe to make requests to. // It checks that the scheme is http/https, the hostname is not localhost, // and all resolved IPs are public. func ValidateURL(rawURL string) error { if rawURL == "" { return ErrInvalidURL } u, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("%w: %v", ErrInvalidURL, err) } if u.Scheme != "http" && u.Scheme != "https" { return ErrInvalidScheme } hostname := u.Hostname() if hostname == "" { return fmt.Errorf("%w: missing hostname", ErrInvalidURL) } if isLocalhost(hostname) { return ErrPrivateIP } if ip := net.ParseIP(hostname); ip != nil { if isPrivateOrInternal(ip) { return ErrPrivateIP } return nil } ips, err := net.DefaultResolver.LookupIPAddr(context.Background(), hostname) if err != nil { return fmt.Errorf("%w: cannot resolve hostname: %v", ErrInvalidURL, err) } if slices.ContainsFunc(ips, func(addr net.IPAddr) bool { return isPrivateOrInternal(addr.IP) }) { return ErrPrivateIP } return nil } // ValidateIPBeforeDial validates an IP address before establishing a connection. // This prevents DNS rebinding attacks by checking the resolved IP at dial time. func ValidateIPBeforeDial(ip net.IP) error { if isPrivateOrInternal(ip) { return ErrPrivateIP } return nil } // isLocalhost checks if the hostname is localhost or similar. func isLocalhost(hostname string) bool { hostname = strings.ToLower(hostname) return hostname == "localhost" || hostname == "localhost.localdomain" || strings.HasSuffix(hostname, ".localhost") } ================================================ FILE: pkg/ssrf/ssrf_test.go ================================================ package ssrf import ( "context" "errors" "net" "net/http" "net/http/httptest" "testing" "time" ) func TestNewSecureClientBlocksPrivateIPs(t *testing.T) { client := NewSecureClient() transport := client.Transport.(*http.Transport) tests := []struct { name string addr string wantErr bool }{ {"block loopback", "127.0.0.1:80", true}, {"block private 10.x", "10.0.0.1:80", true}, {"block link-local", "169.254.169.254:80", true}, {"block CGNAT", "100.64.0.1:80", true}, {"allow public IP", "8.8.8.8:80", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() conn, err := transport.DialContext(ctx, "tcp", tt.addr) if conn != nil { conn.Close() } if tt.wantErr { if err == nil { t.Errorf("expected error for %s, got none", tt.addr) } } else { if err != nil && errors.Is(err, ErrPrivateIP) { t.Errorf("should not block %s with SSRF error, got: %v", tt.addr, err) } } }) } } func TestNewSecureClientBlocksPrivateHostnames(t *testing.T) { client := NewSecureClient() transport := client.Transport.(*http.Transport) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() // "localhost" resolves to 127.0.0.1 (loopback) -- must be blocked. // This exercises the hostname resolution path in DialContext: // net.LookupIP("localhost") -> 127.0.0.1 -> isPrivateOrInternal -> blocked. conn, err := transport.DialContext(ctx, "tcp", "localhost:80") if conn != nil { conn.Close() } if !errors.Is(err, ErrPrivateIP) { t.Errorf("expected ErrPrivateIP for hostname resolving to loopback, got: %v", err) } } func TestNewSecureClientNilIPNotErrPrivateIP(t *testing.T) { client := NewSecureClient() transport := client.Transport.(*http.Transport) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() conn, err := transport.DialContext(ctx, "tcp", "not-an-ip:80") if conn != nil { conn.Close() } if err == nil { t.Fatal("expected error for non-IP address, got none") } if errors.Is(err, ErrPrivateIP) { t.Errorf("nil-IP path should not wrap ErrPrivateIP, got: %v", err) } } func TestNewSecureClientBlocksRedirects(t *testing.T) { redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "http://8.8.8.8:8080/safe", http.StatusFound) })) defer redirectServer.Close() client := NewSecureClient() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, redirectServer.URL, nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } resp, err := client.Do(req) if err != nil { // httptest uses 127.0.0.1, blocked by SSRF protection if !errors.Is(err, ErrPrivateIP) { t.Fatalf("Request failed with non-SSRF error: %v", err) } return } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Errorf("Expected redirect response (302), got %d", resp.StatusCode) } } func TestIsPrivateOrInternal(t *testing.T) { tests := []struct { ip string want bool }{ // Public {"8.8.8.8", false}, {"2001:4860:4860::8888", false}, // Loopback {"127.0.0.1", true}, {"::1", true}, // Private ranges {"10.0.0.1", true}, {"192.168.1.1", true}, {"172.16.0.1", true}, // Link-local (cloud metadata) {"169.254.169.254", true}, // CGNAT boundaries {"100.64.0.1", true}, {"100.127.255.255", true}, // IPv6-mapped IPv4 (bypass vector the old webhook code missed) {"::ffff:127.0.0.1", true}, {"::ffff:169.254.169.254", true}, {"::ffff:8.8.8.8", false}, // Reserved {"0.0.0.0", true}, {"240.0.0.1", true}, } for _, tt := range tests { t.Run(tt.ip, func(t *testing.T) { ip := net.ParseIP(tt.ip) if ip == nil { t.Fatalf("failed to parse IP: %s", tt.ip) } if got := isPrivateOrInternal(ip); got != tt.want { t.Errorf("isPrivateOrInternal(%s) = %v, want %v", tt.ip, got, tt.want) } }) } } func TestValidateURL(t *testing.T) { tests := []struct { name string url string wantErr bool errType error }{ // Valid {"valid https", "https://1.1.1.1/webhook", false, nil}, // Scheme validation {"ftp scheme", "ftp://example.com/webhook", true, ErrInvalidScheme}, {"no scheme", "example.com/webhook", true, ErrInvalidScheme}, // Localhost {"localhost", "http://localhost/webhook", true, ErrPrivateIP}, {"subdomain.localhost", "http://test.localhost/webhook", true, ErrPrivateIP}, // IP-based blocking (one per category -- range coverage is in TestIsPrivateOrInternal) {"loopback IP", "http://127.0.0.1/webhook", true, ErrPrivateIP}, {"metadata IP", "http://169.254.169.254/latest/meta-data/", true, ErrPrivateIP}, // Invalid URLs {"empty", "", true, ErrInvalidURL}, {"missing hostname", "http:///webhook", true, ErrInvalidURL}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidateURL(tt.url) if (err != nil) != tt.wantErr { t.Errorf("ValidateURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr) return } if tt.wantErr && tt.errType != nil { if !errors.Is(err, tt.errType) { t.Errorf("ValidateURL(%q) error = %v, want error type %v", tt.url, err, tt.errType) } } }) } } func TestIsLocalhost(t *testing.T) { tests := []struct { hostname string want bool }{ {"localhost", true}, {"LOCALHOST", true}, {"test.localhost", true}, {"example.com", false}, {"localhost.com", false}, } for _, tt := range tests { t.Run(tt.hostname, func(t *testing.T) { if got := isLocalhost(tt.hostname); got != tt.want { t.Errorf("isLocalhost(%s) = %v, want %v", tt.hostname, got, tt.want) } }) } } ================================================ FILE: pkg/stats/stats.go ================================================ package stats import ( "context" "net/http" "time" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/prometheus/client_golang/prometheus/promhttp" ) // StatsServer is a server for collecting and reporting statistics. type StatsServer struct { //nolint:revive ctx context.Context cfg *config.Config server *http.Server } // NewStatsServer returns a new StatsServer. func NewStatsServer(ctx context.Context) (*StatsServer, error) { cfg := config.FromContext(ctx) mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) return &StatsServer{ ctx: ctx, cfg: cfg, server: &http.Server{ Addr: cfg.Stats.ListenAddr, Handler: mux, ReadHeaderTimeout: time.Second * 10, ReadTimeout: time.Second * 10, WriteTimeout: time.Second * 10, MaxHeaderBytes: http.DefaultMaxHeaderBytes, }, }, nil } // ListenAndServe starts the StatsServer. func (s *StatsServer) ListenAndServe() error { return s.server.ListenAndServe() } // Shutdown gracefully shuts down the StatsServer. func (s *StatsServer) Shutdown(ctx context.Context) error { return s.server.Shutdown(ctx) } // Close closes the StatsServer. func (s *StatsServer) Close() error { return s.server.Close() } ================================================ FILE: pkg/storage/local.go ================================================ package storage import ( "errors" "io" "io/fs" "os" "path/filepath" "strings" ) // LocalStorage is a storage implementation that stores objects on the local // filesystem. type LocalStorage struct { root string } var _ Storage = (*LocalStorage)(nil) // NewLocalStorage creates a new LocalStorage. func NewLocalStorage(root string) *LocalStorage { return &LocalStorage{root: root} } // Delete implements Storage. func (l *LocalStorage) Delete(name string) error { name = l.fixPath(name) return os.Remove(name) } // Open implements Storage. func (l *LocalStorage) Open(name string) (Object, error) { name = l.fixPath(name) return os.Open(name) } // Stat implements Storage. func (l *LocalStorage) Stat(name string) (fs.FileInfo, error) { name = l.fixPath(name) return os.Stat(name) } // Put implements Storage. func (l *LocalStorage) Put(name string, r io.Reader) (int64, error) { name = l.fixPath(name) if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil { return 0, err } f, err := os.Create(name) if err != nil { return 0, err } defer f.Close() //nolint: errcheck return io.Copy(f, r) } // Exists implements Storage. func (l *LocalStorage) Exists(name string) (bool, error) { name = l.fixPath(name) _, err := os.Stat(name) if err == nil { return true, nil } if errors.Is(err, fs.ErrNotExist) { return false, nil } return false, err } // Rename implements Storage. func (l *LocalStorage) Rename(oldName, newName string) error { oldName = l.fixPath(oldName) newName = l.fixPath(newName) if err := os.MkdirAll(filepath.Dir(newName), os.ModePerm); err != nil { return err } return os.Rename(oldName, newName) } // Replace all slashes with the OS-specific separator func (l LocalStorage) fixPath(path string) string { path = strings.ReplaceAll(path, "/", string(os.PathSeparator)) if !filepath.IsAbs(path) { return filepath.Join(l.root, path) } return path } ================================================ FILE: pkg/storage/storage.go ================================================ package storage import ( "io" "io/fs" ) // Object is an interface for objects that can be stored. type Object interface { io.Seeker fs.File Name() string } // Storage is an interface for storing and retrieving objects. type Storage interface { Open(name string) (Object, error) Stat(name string) (fs.FileInfo, error) Put(name string, r io.Reader) (int64, error) Delete(name string) error Exists(name string) (bool, error) Rename(oldName, newName string) error } ================================================ FILE: pkg/store/access_token.go ================================================ package store import ( "context" "time" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" ) // AccessTokenStore is an interface for managing access tokens. type AccessTokenStore interface { GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error) GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error) GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error) CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error) DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error } ================================================ FILE: pkg/store/collab.go ================================================ package store import ( "context" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" ) // CollaboratorStore is an interface for managing collaborators. type CollaboratorStore interface { GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error) AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string, level access.AccessLevel) error RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error) ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error) } ================================================ FILE: pkg/store/context.go ================================================ package store import "context" // ContextKey is the store context key. var ContextKey = &struct{ string }{"store"} // FromContext returns the store from the given context. func FromContext(ctx context.Context) Store { if s, ok := ctx.Value(ContextKey).(Store); ok { return s } return nil } // WithContext returns a new context with the given store. func WithContext(ctx context.Context, s Store) context.Context { return context.WithValue(ctx, ContextKey, s) } ================================================ FILE: pkg/store/database/access_token.go ================================================ package database import ( "context" "time" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/store" ) type accessTokenStore struct{} var _ store.AccessTokenStore = (*accessTokenStore)(nil) // CreateAccessToken implements store.AccessTokenStore. func (s *accessTokenStore) CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error) { queryWithoutExpires := `INSERT INTO access_tokens (name, user_id, token, created_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id` queryWithExpires := `INSERT INTO access_tokens (name, user_id, token, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id` query := queryWithoutExpires values := []interface{}{name, userID, token} if !expiresAt.IsZero() { query = queryWithExpires values = append(values, expiresAt.UTC()) } var id int64 if err := h.GetContext(ctx, &id, h.Rebind(query), values...); err != nil { return models.AccessToken{}, err } return s.GetAccessToken(ctx, h, id) } // DeleteAccessToken implements store.AccessTokenStore. func (*accessTokenStore) DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error { query := h.Rebind(`DELETE FROM access_tokens WHERE id = ?`) _, err := h.ExecContext(ctx, query, id) return err } // DeleteAccessTokenForUser implements store.AccessTokenStore. func (*accessTokenStore) DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error { query := h.Rebind(`DELETE FROM access_tokens WHERE user_id = ? AND id = ?`) _, err := h.ExecContext(ctx, query, userID, id) return err } // GetAccessToken implements store.AccessTokenStore. func (*accessTokenStore) GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error) { query := h.Rebind(`SELECT * FROM access_tokens WHERE id = ?`) var m models.AccessToken err := h.GetContext(ctx, &m, query, id) return m, err } // GetAccessTokensByUserID implements store.AccessTokenStore. func (*accessTokenStore) GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error) { query := h.Rebind(`SELECT * FROM access_tokens WHERE user_id = ?`) var m []models.AccessToken err := h.SelectContext(ctx, &m, query, userID) return m, err } // GetAccessTokenByToken implements store.AccessTokenStore. func (*accessTokenStore) GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error) { query := h.Rebind(`SELECT * FROM access_tokens WHERE token = ?`) var m models.AccessToken err := h.GetContext(ctx, &m, query, token) return m, err } ================================================ FILE: pkg/store/database/collab.go ================================================ package database import ( "context" "strings" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/utils" ) type collabStore struct{} var _ store.CollaboratorStore = (*collabStore)(nil) // AddCollabByUsernameAndRepo implements store.CollaboratorStore. func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string, level access.AccessLevel) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } repo = utils.SanitizeRepo(repo) query := tx.Rebind(`INSERT INTO collabs (access_level, user_id, repo_id, updated_at) VALUES ( ?, ( SELECT id FROM users WHERE username = ? ), ( SELECT id FROM repos WHERE name = ? ), CURRENT_TIMESTAMP );`) _, err := tx.ExecContext(ctx, query, level, username, repo) return err } // GetCollabByUsernameAndRepo implements store.CollaboratorStore. func (*collabStore) GetCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) (models.Collab, error) { var m models.Collab username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return models.Collab{}, err } repo = utils.SanitizeRepo(repo) err := tx.GetContext(ctx, &m, tx.Rebind(` SELECT collabs.* FROM collabs INNER JOIN users ON users.id = collabs.user_id INNER JOIN repos ON repos.id = collabs.repo_id WHERE users.username = ? AND repos.name = ? `), username, repo) return m, err } // ListCollabsByRepo implements store.CollaboratorStore. func (*collabStore) ListCollabsByRepo(ctx context.Context, tx db.Handler, repo string) ([]models.Collab, error) { var m []models.Collab repo = utils.SanitizeRepo(repo) query := tx.Rebind(` SELECT collabs.* FROM collabs INNER JOIN repos ON repos.id = collabs.repo_id WHERE repos.name = ? `) err := tx.SelectContext(ctx, &m, query, repo) return m, err } // ListCollabsByRepoAsUsers implements store.CollaboratorStore. func (*collabStore) ListCollabsByRepoAsUsers(ctx context.Context, tx db.Handler, repo string) ([]models.User, error) { var m []models.User repo = utils.SanitizeRepo(repo) query := tx.Rebind(` SELECT users.* FROM users INNER JOIN collabs ON collabs.user_id = users.id INNER JOIN repos ON repos.id = collabs.repo_id WHERE repos.name = ? `) err := tx.SelectContext(ctx, &m, query, repo) return m, err } // RemoveCollabByUsernameAndRepo implements store.CollaboratorStore. func (*collabStore) RemoveCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } repo = utils.SanitizeRepo(repo) query := tx.Rebind(` DELETE FROM collabs WHERE user_id = ( SELECT id FROM users WHERE username = ? ) AND repo_id = ( SELECT id FROM repos WHERE name = ? ) `) _, err := tx.ExecContext(ctx, query, username, repo) return err } ================================================ FILE: pkg/store/database/database.go ================================================ package database import ( "context" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/store" ) type datastore struct { ctx context.Context cfg *config.Config db *db.DB logger *log.Logger *settingsStore *repoStore *userStore *collabStore *lfsStore *accessTokenStore *webhookStore } // New returns a new store.Store database. func New(ctx context.Context, db *db.DB) store.Store { cfg := config.FromContext(ctx) logger := log.FromContext(ctx).WithPrefix("store") s := &datastore{ ctx: ctx, cfg: cfg, db: db, logger: logger, settingsStore: &settingsStore{}, repoStore: &repoStore{}, userStore: &userStore{}, collabStore: &collabStore{}, lfsStore: &lfsStore{}, accessTokenStore: &accessTokenStore{}, } return s } ================================================ FILE: pkg/store/database/lfs.go ================================================ package database import ( "context" "strings" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/store" ) type lfsStore struct{} var _ store.LFSStore = (*lfsStore)(nil) func sanitizePath(path string) string { path = strings.TrimSpace(path) path = strings.TrimPrefix(path, "/") return path } // CreateLFSLockForUser implements store.LFSStore. func (*lfsStore) CreateLFSLockForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string, refname string) error { path = sanitizePath(path) query := tx.Rebind(`INSERT INTO lfs_locks (repo_id, user_id, path, refname, updated_at) VALUES ( ?, ?, ?, ?, CURRENT_TIMESTAMP ); `) _, err := tx.ExecContext(ctx, query, repoID, userID, path, refname) return db.WrapError(err) } // GetLFSLocks implements store.LFSStore. func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) { if page <= 0 { page = 1 } var locks []models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks WHERE repo_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?; `) err := tx.SelectContext(ctx, &locks, query, repoID, limit, (page-1)*limit) return locks, db.WrapError(err) } func (s *lfsStore) GetLFSLocksWithCount(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) { locks, err := s.GetLFSLocks(ctx, tx, repoID, page, limit) if err != nil { return nil, 0, err } var count int64 query := tx.Rebind(` SELECT COUNT(*) FROM lfs_locks WHERE repo_id = ?; `) err = tx.GetContext(ctx, &count, query, repoID) if err != nil { return nil, 0, db.WrapError(err) } return locks, count, nil } // GetLFSLocksForUser implements store.LFSStore. func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) { var locks []models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks WHERE repo_id = ? AND user_id = ?; `) err := tx.SelectContext(ctx, &locks, query, repoID, userID) return locks, db.WrapError(err) } // GetLFSLocksForPath implements store.LFSStore. func (*lfsStore) GetLFSLockForPath(ctx context.Context, tx db.Handler, repoID int64, path string) (models.LFSLock, error) { path = sanitizePath(path) var lock models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks WHERE repo_id = ? AND path = ?; `) err := tx.GetContext(ctx, &lock, query, repoID, path) return lock, db.WrapError(err) } // GetLFSLockForUserPath implements store.LFSStore. func (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error) { path = sanitizePath(path) var lock models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks WHERE repo_id = ? AND user_id = ? AND path = ?; `) err := tx.GetContext(ctx, &lock, query, repoID, userID, path) return lock, db.WrapError(err) } // GetLFSLockByID implements store.LFSStore. func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id int64) (models.LFSLock, error) { var lock models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks WHERE lfs_locks.id = ?; `) err := tx.GetContext(ctx, &lock, query, id) return lock, db.WrapError(err) } // GetLFSLockForUserByID implements store.LFSStore. func (*lfsStore) GetLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error) { var lock models.LFSLock query := tx.Rebind(` SELECT * FROM lfs_locks WHERE id = ? AND user_id = ? AND repo_id = ?; `) err := tx.GetContext(ctx, &lock, query, id, userID, repoID) return lock, db.WrapError(err) } // DeleteLFSLockForUserByID implements store.LFSStore. func (*lfsStore) DeleteLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) error { query := tx.Rebind(` DELETE FROM lfs_locks WHERE repo_id = ? AND user_id = ? AND id = ?; `) _, err := tx.ExecContext(ctx, query, repoID, userID, id) return db.WrapError(err) } // DeleteLFSLock implements store.LFSStore. func (*lfsStore) DeleteLFSLock(ctx context.Context, tx db.Handler, repoID int64, id int64) error { query := tx.Rebind(` DELETE FROM lfs_locks WHERE repo_id = ? AND id = ?; `) _, err := tx.ExecContext(ctx, query, repoID, id) return db.WrapError(err) } // CreateLFSObject implements store.LFSStore. func (*lfsStore) CreateLFSObject(ctx context.Context, tx db.Handler, repoID int64, oid string, size int64) error { query := tx.Rebind(`INSERT INTO lfs_objects (repo_id, oid, size, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP);`) _, err := tx.ExecContext(ctx, query, repoID, oid, size) return db.WrapError(err) } // DeleteLFSObjectByOid implements store.LFSStore. func (*lfsStore) DeleteLFSObjectByOid(ctx context.Context, tx db.Handler, repoID int64, oid string) error { query := tx.Rebind(`DELETE FROM lfs_objects WHERE repo_id = ? AND oid = ?;`) _, err := tx.ExecContext(ctx, query, repoID, oid) return db.WrapError(err) } // GetLFSObjectByOid implements store.LFSStore. func (*lfsStore) GetLFSObjectByOid(ctx context.Context, tx db.Handler, repoID int64, oid string) (models.LFSObject, error) { var obj models.LFSObject query := tx.Rebind(`SELECT * FROM lfs_objects WHERE repo_id = ? AND oid = ?;`) err := tx.GetContext(ctx, &obj, query, repoID, oid) return obj, db.WrapError(err) } // GetLFSObjects implements store.LFSStore. func (*lfsStore) GetLFSObjects(ctx context.Context, tx db.Handler, repoID int64) ([]models.LFSObject, error) { var objs []models.LFSObject query := tx.Rebind(`SELECT * FROM lfs_objects WHERE repo_id = ?;`) err := tx.SelectContext(ctx, &objs, query, repoID) return objs, db.WrapError(err) } // GetLFSObjectsByName implements store.LFSStore. func (*lfsStore) GetLFSObjectsByName(ctx context.Context, tx db.Handler, name string) ([]models.LFSObject, error) { var objs []models.LFSObject query := tx.Rebind(` SELECT lfs_objects.* FROM lfs_objects INNER JOIN repos ON lfs_objects.repo_id = repos.id WHERE repos.name = ?; `) err := tx.SelectContext(ctx, &objs, query, name) return objs, db.WrapError(err) } ================================================ FILE: pkg/store/database/repo.go ================================================ package database import ( "context" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/utils" ) type repoStore struct{} var _ store.RepositoryStore = (*repoStore)(nil) // CreateRepo implements store.RepositoryStore. func (*repoStore) CreateRepo(ctx context.Context, tx db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error { name = utils.SanitizeRepo(name) values := []interface{}{ name, projectName, description, isPrivate, isMirror, isHidden, } query := `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);` if userID > 0 { query = `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at, user_id) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?);` values = append(values, userID) } query = tx.Rebind(query) _, err := tx.ExecContext(ctx, query, values...) return db.WrapError(err) } // DeleteRepoByName implements store.RepositoryStore. func (*repoStore) DeleteRepoByName(ctx context.Context, tx db.Handler, name string) error { name = utils.SanitizeRepo(name) query := tx.Rebind("DELETE FROM repos WHERE name = ?;") _, err := tx.ExecContext(ctx, query, name) return db.WrapError(err) } // GetAllRepos implements store.RepositoryStore. func (*repoStore) GetAllRepos(ctx context.Context, tx db.Handler) ([]models.Repo, error) { var repos []models.Repo query := tx.Rebind("SELECT * FROM repos;") err := tx.SelectContext(ctx, &repos, query) return repos, db.WrapError(err) } // GetUserRepos implements store.RepositoryStore. func (*repoStore) GetUserRepos(ctx context.Context, tx db.Handler, userID int64) ([]models.Repo, error) { var repos []models.Repo query := tx.Rebind("SELECT * FROM repos WHERE user_id = ?;") err := tx.SelectContext(ctx, &repos, query, userID) return repos, db.WrapError(err) } // GetRepoByName implements store.RepositoryStore. func (*repoStore) GetRepoByName(ctx context.Context, tx db.Handler, name string) (models.Repo, error) { var repo models.Repo name = utils.SanitizeRepo(name) query := tx.Rebind("SELECT * FROM repos WHERE name = ?;") err := tx.GetContext(ctx, &repo, query, name) return repo, db.WrapError(err) } // GetRepoDescriptionByName implements store.RepositoryStore. func (*repoStore) GetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string) (string, error) { var description string name = utils.SanitizeRepo(name) query := tx.Rebind("SELECT description FROM repos WHERE name = ?;") err := tx.GetContext(ctx, &description, query, name) return description, db.WrapError(err) } // GetRepoIsHiddenByName implements store.RepositoryStore. func (*repoStore) GetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string) (bool, error) { var isHidden bool name = utils.SanitizeRepo(name) query := tx.Rebind("SELECT hidden FROM repos WHERE name = ?;") err := tx.GetContext(ctx, &isHidden, query, name) return isHidden, db.WrapError(err) } // GetRepoIsMirrorByName implements store.RepositoryStore. func (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx db.Handler, name string) (bool, error) { var isMirror bool name = utils.SanitizeRepo(name) query := tx.Rebind("SELECT mirror FROM repos WHERE name = ?;") err := tx.GetContext(ctx, &isMirror, query, name) return isMirror, db.WrapError(err) } // GetRepoIsPrivateByName implements store.RepositoryStore. func (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx db.Handler, name string) (bool, error) { var isPrivate bool name = utils.SanitizeRepo(name) query := tx.Rebind("SELECT private FROM repos WHERE name = ?;") err := tx.GetContext(ctx, &isPrivate, query, name) return isPrivate, db.WrapError(err) } // GetRepoProjectNameByName implements store.RepositoryStore. func (*repoStore) GetRepoProjectNameByName(ctx context.Context, tx db.Handler, name string) (string, error) { var pname string name = utils.SanitizeRepo(name) query := tx.Rebind("SELECT project_name FROM repos WHERE name = ?;") err := tx.GetContext(ctx, &pname, query, name) return pname, db.WrapError(err) } // SetRepoDescriptionByName implements store.RepositoryStore. func (*repoStore) SetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string, description string) error { name = utils.SanitizeRepo(name) query := tx.Rebind("UPDATE repos SET description = ? WHERE name = ?;") _, err := tx.ExecContext(ctx, query, description, name) return db.WrapError(err) } // SetRepoIsHiddenByName implements store.RepositoryStore. func (*repoStore) SetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string, isHidden bool) error { name = utils.SanitizeRepo(name) query := tx.Rebind("UPDATE repos SET hidden = ? WHERE name = ?;") _, err := tx.ExecContext(ctx, query, isHidden, name) return db.WrapError(err) } // SetRepoIsPrivateByName implements store.RepositoryStore. func (*repoStore) SetRepoIsPrivateByName(ctx context.Context, tx db.Handler, name string, isPrivate bool) error { name = utils.SanitizeRepo(name) query := tx.Rebind("UPDATE repos SET private = ? WHERE name = ?;") _, err := tx.ExecContext(ctx, query, isPrivate, name) return db.WrapError(err) } // SetRepoNameByName implements store.RepositoryStore. func (*repoStore) SetRepoNameByName(ctx context.Context, tx db.Handler, name string, newName string) error { name = utils.SanitizeRepo(name) newName = utils.SanitizeRepo(newName) query := tx.Rebind("UPDATE repos SET name = ? WHERE name = ?;") _, err := tx.ExecContext(ctx, query, newName, name) return db.WrapError(err) } // SetRepoProjectNameByName implements store.RepositoryStore. func (*repoStore) SetRepoProjectNameByName(ctx context.Context, tx db.Handler, name string, projectName string) error { name = utils.SanitizeRepo(name) query := tx.Rebind("UPDATE repos SET project_name = ? WHERE name = ?;") _, err := tx.ExecContext(ctx, query, projectName, name) return db.WrapError(err) } ================================================ FILE: pkg/store/database/settings.go ================================================ package database import ( "context" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/store" ) type settingsStore struct{} var _ store.SettingStore = (*settingsStore)(nil) // GetAllowKeylessAccess implements store.SettingStore. func (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx db.Handler) (bool, error) { var allow bool query := tx.Rebind(`SELECT value FROM settings WHERE "key" = 'allow_keyless'`) if err := tx.GetContext(ctx, &allow, query); err != nil { return false, db.WrapError(err) } return allow, nil } // GetAnonAccess implements store.SettingStore. func (*settingsStore) GetAnonAccess(ctx context.Context, tx db.Handler) (access.AccessLevel, error) { var level string query := tx.Rebind(`SELECT value FROM settings WHERE "key" = 'anon_access'`) if err := tx.GetContext(ctx, &level, query); err != nil { return access.NoAccess, db.WrapError(err) } return access.ParseAccessLevel(level), nil } // SetAllowKeylessAccess implements store.SettingStore. func (*settingsStore) SetAllowKeylessAccess(ctx context.Context, tx db.Handler, allow bool) error { query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE "key" = 'allow_keyless'`) _, err := tx.ExecContext(ctx, query, allow) return db.WrapError(err) } // SetAnonAccess implements store.SettingStore. func (*settingsStore) SetAnonAccess(ctx context.Context, tx db.Handler, level access.AccessLevel) error { query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE "key" = 'anon_access'`) _, err := tx.ExecContext(ctx, query, level.String()) return db.WrapError(err) } ================================================ FILE: pkg/store/database/user.go ================================================ package database import ( "context" "strings" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/utils" "golang.org/x/crypto/ssh" ) type userStore struct{} var _ store.UserStore = (*userStore)(nil) // AddPublicKeyByUsername implements store.UserStore. func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } var userID int64 if err := tx.GetContext(ctx, &userID, tx.Rebind(`SELECT id FROM users WHERE username = ?`), username); err != nil { return err } query := tx.Rebind(`INSERT INTO public_keys (user_id, public_key, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);`) ak := sshutils.MarshalAuthorizedKey(pk) _, err := tx.ExecContext(ctx, query, userID, ak) return err } // CreateUser implements store.UserStore. func (*userStore) CreateUser(ctx context.Context, tx db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } query := tx.Rebind(`INSERT INTO users (username, admin, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) RETURNING id;`) var userID int64 if err := tx.GetContext(ctx, &userID, query, username, isAdmin); err != nil { return err } for _, pk := range pks { query := tx.Rebind(`INSERT INTO public_keys (user_id, public_key, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);`) ak := sshutils.MarshalAuthorizedKey(pk) _, err := tx.ExecContext(ctx, query, userID, ak) if err != nil { return err } } return nil } // DeleteUserByUsername implements store.UserStore. func (*userStore) DeleteUserByUsername(ctx context.Context, tx db.Handler, username string) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } query := tx.Rebind(`DELETE FROM users WHERE username = ?;`) _, err := tx.ExecContext(ctx, query, username) return err } // GetUserByID implements store.UserStore. func (*userStore) GetUserByID(ctx context.Context, tx db.Handler, id int64) (models.User, error) { var m models.User query := tx.Rebind(`SELECT * FROM users WHERE id = ?;`) err := tx.GetContext(ctx, &m, query, id) return m, err } // FindUserByPublicKey implements store.UserStore. func (*userStore) FindUserByPublicKey(ctx context.Context, tx db.Handler, pk ssh.PublicKey) (models.User, error) { var m models.User query := tx.Rebind(`SELECT users.* FROM users INNER JOIN public_keys ON users.id = public_keys.user_id WHERE public_keys.public_key = ?;`) err := tx.GetContext(ctx, &m, query, sshutils.MarshalAuthorizedKey(pk)) return m, err } // FindUserByUsername implements store.UserStore. func (*userStore) FindUserByUsername(ctx context.Context, tx db.Handler, username string) (models.User, error) { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return models.User{}, err } var m models.User query := tx.Rebind(`SELECT * FROM users WHERE username = ?;`) err := tx.GetContext(ctx, &m, query, username) return m, err } // FindUserByAccessToken implements store.UserStore. func (*userStore) FindUserByAccessToken(ctx context.Context, tx db.Handler, token string) (models.User, error) { var m models.User query := tx.Rebind(`SELECT users.* FROM users INNER JOIN access_tokens ON users.id = access_tokens.user_id WHERE access_tokens.token = ?;`) err := tx.GetContext(ctx, &m, query, token) return m, err } // GetAllUsers implements store.UserStore. func (*userStore) GetAllUsers(ctx context.Context, tx db.Handler) ([]models.User, error) { var ms []models.User query := tx.Rebind(`SELECT * FROM users;`) err := tx.SelectContext(ctx, &ms, query) return ms, err } // ListPublicKeysByUserID implements store.UserStore.. func (*userStore) ListPublicKeysByUserID(ctx context.Context, tx db.Handler, id int64) ([]ssh.PublicKey, error) { var aks []string query := tx.Rebind(`SELECT public_key FROM public_keys WHERE user_id = ? ORDER BY public_keys.id ASC;`) err := tx.SelectContext(ctx, &aks, query, id) if err != nil { return nil, err } pks := make([]ssh.PublicKey, len(aks)) for i, ak := range aks { pk, _, err := sshutils.ParseAuthorizedKey(ak) if err != nil { return nil, err } pks[i] = pk } return pks, nil } // ListPublicKeysByUsername implements store.UserStore. func (*userStore) ListPublicKeysByUsername(ctx context.Context, tx db.Handler, username string) ([]ssh.PublicKey, error) { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return nil, err } var aks []string query := tx.Rebind(`SELECT public_key FROM public_keys INNER JOIN users ON users.id = public_keys.user_id WHERE users.username = ? ORDER BY public_keys.id ASC;`) err := tx.SelectContext(ctx, &aks, query, username) if err != nil { return nil, err } pks := make([]ssh.PublicKey, len(aks)) for i, ak := range aks { pk, _, err := sshutils.ParseAuthorizedKey(ak) if err != nil { return nil, err } pks[i] = pk } return pks, nil } // RemovePublicKeyByUsername implements store.UserStore. func (*userStore) RemovePublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } query := tx.Rebind(`DELETE FROM public_keys WHERE user_id = (SELECT id FROM users WHERE username = ?) AND public_key = ?;`) _, err := tx.ExecContext(ctx, query, username, sshutils.MarshalAuthorizedKey(pk)) return err } // SetAdminByUsername implements store.UserStore. func (*userStore) SetAdminByUsername(ctx context.Context, tx db.Handler, username string, isAdmin bool) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } query := tx.Rebind(`UPDATE users SET admin = ? WHERE username = ?;`) _, err := tx.ExecContext(ctx, query, isAdmin, username) return err } // SetUsernameByUsername implements store.UserStore. func (*userStore) SetUsernameByUsername(ctx context.Context, tx db.Handler, username string, newUsername string) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } newUsername = strings.ToLower(newUsername) if err := utils.ValidateUsername(newUsername); err != nil { return err } query := tx.Rebind(`UPDATE users SET username = ? WHERE username = ?;`) _, err := tx.ExecContext(ctx, query, newUsername, username) return err } // SetUserPassword implements store.UserStore. func (*userStore) SetUserPassword(ctx context.Context, tx db.Handler, userID int64, password string) error { query := tx.Rebind(`UPDATE users SET password = ? WHERE id = ?;`) _, err := tx.ExecContext(ctx, query, password, userID) return err } // SetUserPasswordByUsername implements store.UserStore. func (*userStore) SetUserPasswordByUsername(ctx context.Context, tx db.Handler, username string, password string) error { username = strings.ToLower(username) if err := utils.ValidateUsername(username); err != nil { return err } query := tx.Rebind(`UPDATE users SET password = ? WHERE username = ?;`) _, err := tx.ExecContext(ctx, query, password, username) return err } ================================================ FILE: pkg/store/database/webhooks.go ================================================ package database import ( "context" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) type webhookStore struct{} var _ store.WebhookStore = (*webhookStore)(nil) // CreateWebhook implements store.WebhookStore. func (*webhookStore) CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error) { var id int64 query := h.Rebind(`INSERT INTO webhooks (repo_id, url, secret, content_type, active, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) RETURNING id;`) err := h.GetContext(ctx, &id, query, repoID, url, secret, contentType, active) if err != nil { return 0, err } return id, nil } // CreateWebhookDelivery implements store.WebhookStore. func (*webhookStore) CreateWebhookDelivery(ctx context.Context, h db.Handler, id uuid.UUID, webhookID int64, event int, url string, method string, requestError error, requestHeaders string, requestBody string, responseStatus int, responseHeaders string, responseBody string) error { query := h.Rebind(`INSERT INTO webhook_deliveries (id, webhook_id, event, request_url, request_method, request_error, request_headers, request_body, response_status, response_headers, response_body) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`) var reqErr string if requestError != nil { reqErr = requestError.Error() } _, err := h.ExecContext(ctx, query, id, webhookID, event, url, method, reqErr, requestHeaders, requestBody, responseStatus, responseHeaders, responseBody) return err } // CreateWebhookEvents implements store.WebhookStore. func (*webhookStore) CreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error { query := h.Rebind(`INSERT INTO webhook_events (webhook_id, event) VALUES (?, ?);`) for _, event := range events { _, err := h.ExecContext(ctx, query, webhookID, event) if err != nil { return err } } return nil } // DeleteWebhookByID implements store.WebhookStore. func (*webhookStore) DeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error { query := h.Rebind(`DELETE FROM webhooks WHERE id = ?;`) _, err := h.ExecContext(ctx, query, id) return err } // DeleteWebhookForRepoByID implements store.WebhookStore. func (*webhookStore) DeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error { query := h.Rebind(`DELETE FROM webhooks WHERE repo_id = ? AND id = ?;`) _, err := h.ExecContext(ctx, query, repoID, id) return err } // DeleteWebhookDeliveryByID implements store.WebhookStore. func (*webhookStore) DeleteWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) error { query := h.Rebind(`DELETE FROM webhook_deliveries WHERE webhook_id = ? AND id = ?;`) _, err := h.ExecContext(ctx, query, webhookID, id) return err } // DeleteWebhookEventsByWebhookID implements store.WebhookStore. func (*webhookStore) DeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error { query, args, err := sqlx.In(`DELETE FROM webhook_events WHERE id IN (?);`, ids) if err != nil { return err } query = h.Rebind(query) _, err = h.ExecContext(ctx, query, args...) return err } // GetWebhookByID implements store.WebhookStore. func (*webhookStore) GetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error) { query := h.Rebind(`SELECT * FROM webhooks WHERE repo_id = ? AND id = ?;`) var wh models.Webhook err := h.GetContext(ctx, &wh, query, repoID, id) return wh, err } // GetWebhookDeliveriesByWebhookID implements store.WebhookStore. func (*webhookStore) GetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) { query := h.Rebind(`SELECT * FROM webhook_deliveries WHERE webhook_id = ?;`) var whds []models.WebhookDelivery err := h.SelectContext(ctx, &whds, query, webhookID) return whds, err } // GetWebhookDeliveryByID implements store.WebhookStore. func (*webhookStore) GetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error) { query := h.Rebind(`SELECT * FROM webhook_deliveries WHERE webhook_id = ? AND id = ?;`) var whd models.WebhookDelivery err := h.GetContext(ctx, &whd, query, webhookID, id) return whd, err } // GetWebhookEventByID implements store.WebhookStore. func (*webhookStore) GetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error) { query := h.Rebind(`SELECT * FROM webhook_events WHERE id = ?;`) var whe models.WebhookEvent err := h.GetContext(ctx, &whe, query, id) return whe, err } // GetWebhookEventsByWebhookID implements store.WebhookStore. func (*webhookStore) GetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error) { query := h.Rebind(`SELECT * FROM webhook_events WHERE webhook_id = ?;`) var whes []models.WebhookEvent err := h.SelectContext(ctx, &whes, query, webhookID) return whes, err } // GetWebhooksByRepoID implements store.WebhookStore. func (*webhookStore) GetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error) { query := h.Rebind(`SELECT * FROM webhooks WHERE repo_id = ?;`) var whs []models.Webhook err := h.SelectContext(ctx, &whs, query, repoID) return whs, err } // GetWebhooksByRepoIDWhereEvent implements store.WebhookStore. func (*webhookStore) GetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error) { query, args, err := sqlx.In(`SELECT webhooks.* FROM webhooks INNER JOIN webhook_events ON webhooks.id = webhook_events.webhook_id WHERE webhooks.repo_id = ? AND webhook_events.event IN (?);`, repoID, events) if err != nil { return nil, err } query = h.Rebind(query) var whs []models.Webhook err = h.SelectContext(ctx, &whs, query, args...) return whs, err } // ListWebhookDeliveriesByWebhookID implements store.WebhookStore. func (*webhookStore) ListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) { query := h.Rebind(`SELECT id, response_status, event FROM webhook_deliveries WHERE webhook_id = ?;`) var whds []models.WebhookDelivery err := h.SelectContext(ctx, &whds, query, webhookID) return whds, err } // UpdateWebhookByID implements store.WebhookStore. func (*webhookStore) UpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error { query := h.Rebind(`UPDATE webhooks SET url = ?, secret = ?, content_type = ?, active = ?, updated_at = CURRENT_TIMESTAMP WHERE repo_id = ? AND id = ?;`) _, err := h.ExecContext(ctx, query, url, secret, contentType, active, repoID, id) return err } ================================================ FILE: pkg/store/lfs.go ================================================ package store import ( "context" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" ) // LFSStore is the interface for the LFS store. type LFSStore interface { CreateLFSObject(ctx context.Context, h db.Handler, repoID int64, oid string, size int64) error GetLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) (models.LFSObject, error) GetLFSObjects(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSObject, error) GetLFSObjectsByName(ctx context.Context, h db.Handler, name string) ([]models.LFSObject, error) DeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error CreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error GetLFSLocks(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) GetLFSLocksWithCount(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) GetLFSLockForPath(ctx context.Context, h db.Handler, repoID int64, path string) (models.LFSLock, error) GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error) GetLFSLockByID(ctx context.Context, h db.Handler, id int64) (models.LFSLock, error) GetLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error) DeleteLFSLock(ctx context.Context, h db.Handler, repoID int64, id int64) error DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) error } ================================================ FILE: pkg/store/repo.go ================================================ package store import ( "context" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" ) // RepositoryStore is an interface for managing repositories. type RepositoryStore interface { GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error) GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error) GetUserRepos(ctx context.Context, h db.Handler, userID int64) ([]models.Repo, error) CreateRepo(ctx context.Context, h db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error DeleteRepoByName(ctx context.Context, h db.Handler, name string) error SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error GetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error) SetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error GetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error) SetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error GetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error) SetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error) SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error) } ================================================ FILE: pkg/store/settings.go ================================================ package store import ( "context" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/db" ) // SettingStore is an interface for managing settings. type SettingStore interface { GetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error) SetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error GetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error) SetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error } ================================================ FILE: pkg/store/store.go ================================================ package store // Store is an interface for managing repositories, users, and settings. type Store interface { RepositoryStore UserStore CollaboratorStore SettingStore LFSStore AccessTokenStore WebhookStore } ================================================ FILE: pkg/store/user.go ================================================ package store import ( "context" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "golang.org/x/crypto/ssh" ) // UserStore is an interface for managing users. type UserStore interface { GetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error) FindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error) FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error) FindUserByAccessToken(ctx context.Context, h db.Handler, token string) (models.User, error) GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error) CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error SetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error SetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error AddPublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error RemovePublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error ListPublicKeysByUserID(ctx context.Context, h db.Handler, id int64) ([]ssh.PublicKey, error) ListPublicKeysByUsername(ctx context.Context, h db.Handler, username string) ([]ssh.PublicKey, error) SetUserPassword(ctx context.Context, h db.Handler, userID int64, password string) error SetUserPasswordByUsername(ctx context.Context, h db.Handler, username string, password string) error } ================================================ FILE: pkg/store/webhooks.go ================================================ package store import ( "context" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/google/uuid" ) // WebhookStore is an interface for managing webhooks. type WebhookStore interface { // GetWebhookByID returns a webhook by its ID. GetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error) // GetWebhooksByRepoID returns all webhooks for a repository. GetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error) // GetWebhooksByRepoIDWhereEvent returns all webhooks for a repository where event is in the events. GetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error) // CreateWebhook creates a webhook. CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error) // UpdateWebhookByID updates a webhook by its ID. UpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error // DeleteWebhookByID deletes a webhook by its ID. DeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error // DeleteWebhookForRepoByID deletes a webhook for a repository by its ID. DeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error // GetWebhookEventByID returns a webhook event by its ID. GetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error) // GetWebhookEventsByWebhookID returns all webhook events for a webhook. GetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error) // CreateWebhookEvents creates webhook events for a webhook. CreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error // DeleteWebhookEventsByWebhookID deletes all webhook events for a webhook. DeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error // GetWebhookDeliveryByID returns a webhook delivery by its ID. GetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error) // GetWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook. GetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) // ListWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook. // This only returns the delivery ID, response status, and event. ListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) // CreateWebhookDelivery creates a webhook delivery. CreateWebhookDelivery(ctx context.Context, h db.Handler, id uuid.UUID, webhookID int64, event int, url string, method string, requestError error, requestHeaders string, requestBody string, responseStatus int, responseHeaders string, responseBody string) error // DeleteWebhookDeliveryByID deletes a webhook delivery by its ID. DeleteWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) error } ================================================ FILE: pkg/sync/workqueue.go ================================================ package sync import ( "context" "sync" "golang.org/x/sync/semaphore" ) // WorkPool is a pool of work to be done. type WorkPool struct { workers int work sync.Map sem *semaphore.Weighted ctx context.Context logger func(string, ...interface{}) } // WorkPoolOption is a function that configures a WorkPool. type WorkPoolOption func(*WorkPool) // WithWorkPoolLogger sets the logger to use. func WithWorkPoolLogger(logger func(string, ...interface{})) WorkPoolOption { return func(wq *WorkPool) { wq.logger = logger } } // NewWorkPool creates a new work pool. The workers argument specifies the // number of concurrent workers to run the work. // The queue will chunk the work into batches of workers size. func NewWorkPool(ctx context.Context, workers int, opts ...WorkPoolOption) *WorkPool { wq := &WorkPool{ workers: workers, ctx: ctx, } for _, opt := range opts { opt(wq) } if wq.workers <= 0 { wq.workers = 1 } wq.sem = semaphore.NewWeighted(int64(wq.workers)) return wq } // Run starts the workers and waits for them to finish. func (wq *WorkPool) Run() { wq.work.Range(func(key, value any) bool { id := key.(string) fn := value.(func()) if err := wq.sem.Acquire(wq.ctx, 1); err != nil { wq.logf("workpool: %v", err) return false } go func(id string, fn func()) { defer wq.sem.Release(1) fn() wq.work.Delete(id) }(id, fn) return true }) if err := wq.sem.Acquire(wq.ctx, int64(wq.workers)); err != nil { wq.logf("workpool: %v", err) } } // Add adds a new job to the pool. // If the job already exists, it is a no-op. func (wq *WorkPool) Add(id string, fn func()) { if _, ok := wq.work.Load(id); ok { return } wq.work.Store(id, fn) } // Status checks if a job is in the queue. func (wq *WorkPool) Status(id string) bool { _, ok := wq.work.Load(id) return ok } func (wq *WorkPool) logf(format string, args ...interface{}) { if wq.logger != nil { wq.logger(format, args...) } } ================================================ FILE: pkg/sync/workqueue_test.go ================================================ package sync import ( "context" "strconv" "sync" "testing" ) func TestWorkPool(t *testing.T) { mtx := &sync.Mutex{} values := make([]int, 0) wp := NewWorkPool(context.Background(), 3) for i := 0; i < 10; i++ { id := strconv.Itoa(i) i := i wp.Add(id, func() { mtx.Lock() values = append(values, i) mtx.Unlock() }) } wp.Run() if len(values) != 10 { t.Errorf("expected 10 values, got %d, %v", len(values), values) } for i := range values { id := strconv.Itoa(i) if wp.Status(id) { t.Errorf("expected %s to be false", id) } } } ================================================ FILE: pkg/task/manager.go ================================================ package task import ( "context" "errors" "sync" "sync/atomic" ) var ( // ErrNotFound is returned when a process is not found. ErrNotFound = errors.New("task not found") // ErrAlreadyStarted is returned when a process is already started. ErrAlreadyStarted = errors.New("task already started") ) // Task is a task that can be started and stopped. type Task struct { id string fn func(context.Context) error started atomic.Bool ctx context.Context cancel context.CancelFunc err error } // Manager manages tasks. type Manager struct { m sync.Map ctx context.Context } // NewManager returns a new task manager. func NewManager(ctx context.Context) *Manager { return &Manager{ m: sync.Map{}, ctx: ctx, } } // Add adds a task to the manager. // If the process already exists, it is a no-op. func (m *Manager) Add(id string, fn func(context.Context) error) { if m.Exists(id) { return } ctx, cancel := context.WithCancel(m.ctx) m.m.Store(id, &Task{ id: id, fn: fn, ctx: ctx, cancel: cancel, }) } // Stop stops the task and removes it from the manager. func (m *Manager) Stop(id string) error { v, ok := m.m.Load(id) if !ok { return ErrNotFound } p := v.(*Task) p.cancel() m.m.Delete(id) return nil } // Exists checks if a task exists. func (m *Manager) Exists(id string) bool { _, ok := m.m.Load(id) return ok } // Run starts the task if it exists. // Otherwise, it waits for the process to finish. func (m *Manager) Run(id string, done chan<- error) { v, ok := m.m.Load(id) if !ok { done <- ErrNotFound return } p := v.(*Task) if p.started.Load() { <-p.ctx.Done() if p.err != nil { done <- p.err return } done <- p.ctx.Err() } p.started.Store(true) m.m.Store(id, p) defer p.cancel() defer m.m.Delete(id) errc := make(chan error, 1) go func(ctx context.Context) { errc <- p.fn(ctx) }(p.ctx) select { case <-m.ctx.Done(): done <- m.ctx.Err() case err := <-errc: p.err = err m.m.Store(id, p) done <- err } } ================================================ FILE: pkg/test/test.go ================================================ package test import ( "net" "sync" ) var ( used = map[int]struct{}{} lock sync.Mutex ) // RandomPort returns a random port number. // This is mainly used for testing. func RandomPort() int { addr, _ := net.Listen("tcp", ":0") //nolint:gosec,noctx _ = addr.Close() port := addr.Addr().(*net.TCPAddr).Port lock.Lock() if _, ok := used[port]; ok { lock.Unlock() return RandomPort() } used[port] = struct{}{} lock.Unlock() return port } ================================================ FILE: pkg/ui/common/common.go ================================================ package common import ( "context" "fmt" "charm.land/log/v2" "github.com/alecthomas/chroma/v2/lexers" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/ui/keymap" "github.com/charmbracelet/soft-serve/pkg/ui/styles" "github.com/charmbracelet/ssh" zone "github.com/lrstanley/bubblezone/v2" ) type contextKey struct { name string } // Keys to use for context.Context. var ( ConfigKey = &contextKey{"config"} RepoKey = &contextKey{"repo"} ) // Common is a struct all components should embed. type Common struct { ctx context.Context Width, Height int Styles *styles.Styles KeyMap *keymap.KeyMap Zone *zone.Manager Logger *log.Logger HideCloneCmd bool } // NewCommon returns a new Common struct. func NewCommon(ctx context.Context, width, height int) Common { if ctx == nil { ctx = context.TODO() } return Common{ ctx: ctx, Width: width, Height: height, Styles: styles.DefaultStyles(), KeyMap: keymap.DefaultKeyMap(), Zone: zone.New(), Logger: log.FromContext(ctx).WithPrefix("ui"), } } // SetValue sets a value in the context. func (c *Common) SetValue(key, value interface{}) { c.ctx = context.WithValue(c.ctx, key, value) } // SetSize sets the width and height of the common struct. func (c *Common) SetSize(width, height int) { c.Width = width c.Height = height } // Context returns the context. func (c *Common) Context() context.Context { return c.ctx } // Config returns the server config. func (c *Common) Config() *config.Config { return config.FromContext(c.ctx) } // Backend returns the Soft Serve backend. func (c *Common) Backend() *backend.Backend { return backend.FromContext(c.ctx) } // Repo returns the repository. func (c *Common) Repo() *git.Repository { v := c.ctx.Value(RepoKey) if r, ok := v.(*git.Repository); ok { return r } return nil } // PublicKey returns the public key. func (c *Common) PublicKey() ssh.PublicKey { v := c.ctx.Value(ssh.ContextKeyPublicKey) if p, ok := v.(ssh.PublicKey); ok { return p } return nil } // CloneCmd returns the clone command string. func (c *Common) CloneCmd(publicURL, name string) string { if c.HideCloneCmd { return "" } return fmt.Sprintf("git clone %s", RepoURL(publicURL, name)) } // IsFileMarkdown returns true if the file is markdown. // It uses chroma lexers to analyze and determine the language. func IsFileMarkdown(content, ext string) bool { var lang string lexer := lexers.Match(ext) if lexer == nil { lexer = lexers.Analyse(content) } if lexer != nil && lexer.Config() != nil { lang = lexer.Config().Name } return lang == "markdown" } // ScrollPercent returns a string representing the scroll percentage of the // viewport. func ScrollPercent(position int) string { return fmt.Sprintf("≡ %d%%", position) } ================================================ FILE: pkg/ui/common/common_test.go ================================================ package common_test import ( "testing" "github.com/charmbracelet/soft-serve/pkg/ui/common" ) func TestIsFileMarkdown(t *testing.T) { cases := []struct { name string filename string content string // XXX: chroma doesn't correctly analyze mk files isMkd bool }{ {"simple", "README.md", "", true}, {"empty", "", "", false}, {"no extension", "README", "", false}, {"weird extension", "README.foo", "", false}, {"long ext", "README.markdown", "", true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := common.IsFileMarkdown(c.content, c.filename); got != c.isMkd { t.Errorf("IsFileMarkdown(%q, %q) = %v, want %v", c.content, c.filename, got, c.isMkd) } }) } } ================================================ FILE: pkg/ui/common/component.go ================================================ package common import ( "charm.land/bubbles/v2/help" tea "charm.land/bubbletea/v2" ) // Model represents a simple UI model. type Model interface { Init() tea.Cmd Update(tea.Msg) (Model, tea.Cmd) View() string } // Component represents a Bubble Tea model that implements a SetSize function. type Component interface { Model help.KeyMap SetSize(width, height int) } // TabComponenet represents a model that is mounted to a tab. // TODO: find a better name type TabComponent interface { Component // StatusBarValue returns the status bar value component. StatusBarValue() string // StatusBarInfo returns the status bar info component. StatusBarInfo() string // SpinnerID returns the ID of the spinner. SpinnerID() int // TabName returns the name of the tab. TabName() string // Path returns the hierarchical path of the tab. Path() string } ================================================ FILE: pkg/ui/common/error.go ================================================ package common import ( "errors" tea "charm.land/bubbletea/v2" ) // ErrMissingRepo indicates that the requested repository could not be found. var ErrMissingRepo = errors.New("missing repo") // ErrorMsg is a Bubble Tea message that represents an error. type ErrorMsg error // ErrorCmd returns an ErrorMsg from error. func ErrorCmd(err error) tea.Cmd { return func() tea.Msg { return ErrorMsg(err) } } ================================================ FILE: pkg/ui/common/format.go ================================================ package common import ( "fmt" "strconv" "strings" gansi "charm.land/glamour/v2/ansi" "github.com/alecthomas/chroma/v2/lexers" "github.com/charmbracelet/soft-serve/pkg/ui/styles" ) // FormatLineNumber adds line numbers to a string. func FormatLineNumber(styles *styles.Styles, s string, color bool) (string, int) { lines := strings.Split(s, "\n") // NB: len() is not a particularly safe way to count string width (because // it's counting bytes instead of runes) but in this case it's okay // because we're only dealing with digits, which are one byte each. mll := len(fmt.Sprintf("%d", len(lines))) for i, l := range lines { digit := fmt.Sprintf("%*d", mll, i+1) bar := "│" if color { digit = styles.Code.LineDigit.Render(digit) bar = styles.Code.LineBar.Render(bar) } if i < len(lines)-1 || len(l) != 0 { // If the final line was a newline we'll get an empty string for // the final line, so drop the newline altogether. lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l) } } return strings.Join(lines, "\n"), mll } // FormatHighlight adds syntax highlighting to a string. func FormatHighlight(p, c string) (string, error) { zero := uint(0) lang := "" lexer := lexers.Match(p) if lexer != nil && lexer.Config() != nil { lang = lexer.Config().Name } formatter := &gansi.CodeBlockElement{ Code: c, Language: lang, } r := strings.Builder{} styles := StyleConfig() styles.CodeBlock.Margin = &zero rctx := StyleRendererWithStyles(styles) err := formatter.Render(&r, rctx) if err != nil { return "", err } return r.String(), nil } // UnquoteFilename unquotes a filename. // When Git is with "core.quotePath" set to "true" (default), it will quote // the filename with double quotes if it contains control characters or unicode. // this function will unquote the filename. func UnquoteFilename(s string) string { name := s if n, err := strconv.Unquote(`"` + s + `"`); err == nil { name = n } name = strconv.Quote(name) return strings.Trim(name, `"`) } ================================================ FILE: pkg/ui/common/style.go ================================================ package common import ( gansi "charm.land/glamour/v2/ansi" "charm.land/glamour/v2/styles" "github.com/charmbracelet/colorprofile" ) // DefaultColorProfile is the default color profile used by the SSH server. var DefaultColorProfile = colorprofile.ANSI256 func strptr(s string) *string { return &s } // StyleConfig returns the default Glamour style configuration. func StyleConfig() gansi.StyleConfig { noColor := strptr("") s := styles.DarkStyleConfig // This fixes an issue with the default style config. For example // highlighting empty spaces with red in Dockerfile type. s.CodeBlock.Chroma.Error.BackgroundColor = noColor return s } // StyleRenderer returns a new Glamour renderer. func StyleRenderer() gansi.RenderContext { return StyleRendererWithStyles(StyleConfig()) } // StyleRendererWithStyles returns a new Glamour renderer. func StyleRendererWithStyles(styles gansi.StyleConfig) gansi.RenderContext { return gansi.NewRenderContext(gansi.Options{ Styles: styles, }) } ================================================ FILE: pkg/ui/common/utils.go ================================================ package common import ( "fmt" "net/url" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/muesli/reflow/truncate" ) // TruncateString is a convenient wrapper around truncate.TruncateString. func TruncateString(s string, max int) string { //nolint:revive if max < 0 { max = 0 //nolint:revive } return truncate.StringWithTail(s, uint(max), "…") //nolint:gosec } // RepoURL returns the URL of the repository. func RepoURL(publicURL, name string) string { name = utils.SanitizeRepo(name) + ".git" url, err := url.Parse(publicURL) if err == nil { switch url.Scheme { case "ssh": port := url.Port() if port == "" || port == "22" { return fmt.Sprintf("git@%s:%s", url.Hostname(), name) } return fmt.Sprintf("ssh://%s:%s/%s", url.Hostname(), url.Port(), name) } } return fmt.Sprintf("%s/%s", publicURL, name) } ================================================ FILE: pkg/ui/components/code/code.go ================================================ package code import ( "math" "strings" "sync" tea "charm.land/bubbletea/v2" "charm.land/glamour/v2" gansi "charm.land/glamour/v2/ansi" "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2/lexers" "github.com/charmbracelet/soft-serve/pkg/ui/common" vp "github.com/charmbracelet/soft-serve/pkg/ui/components/viewport" ) const ( defaultTabWidth = 4 defaultSideNotePercent = 0.3 ) // Code is a code snippet. type Code struct { *vp.Viewport common common.Common sidenote string content string extension string renderContext gansi.RenderContext renderMutex sync.Mutex styleConfig gansi.StyleConfig SideNotePercent float64 TabWidth int ShowLineNumber bool NoContentStyle lipgloss.Style UseGlamour bool } // New returns a new Code. func New(c common.Common, content, extension string) *Code { r := &Code{ common: c, content: content, extension: extension, TabWidth: defaultTabWidth, SideNotePercent: defaultSideNotePercent, Viewport: vp.New(c), NoContentStyle: c.Styles.NoContent.SetString("No Content."), } st := common.StyleConfig() r.styleConfig = st r.renderContext = common.StyleRendererWithStyles(st) r.SetSize(c.Width, c.Height) return r } // SetSize implements common.Component. func (r *Code) SetSize(width, height int) { r.common.SetSize(width, height) r.Viewport.SetSize(width, height) } // SetContent sets the content of the Code. func (r *Code) SetContent(c, ext string) tea.Cmd { r.content = c r.extension = ext return r.Init() } // SetSideNote sets the sidenote of the Code. func (r *Code) SetSideNote(s string) tea.Cmd { r.sidenote = s return r.Init() } // Init implements tea.Model. func (r *Code) Init() tea.Cmd { // XXX: We probably won't need the GetHorizontalFrameSize margin // subtraction if we get the new viewport soft wrapping to play nicely with // Glamour. This also introduces a bug where when it soft wraps, the // viewport scrolls left/right for 2 columns on each side of the screen. w := r.common.Width - r.common.Styles.App.GetHorizontalFrameSize() content := r.content if content == "" { r.Viewport.Model.SetContent(r.NoContentStyle.String()) return nil } // FIXME chroma & glamour might break wrapping when using tabs since tab // width depends on the terminal. This is a workaround to replace tabs with // 4-spaces. content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", r.TabWidth)) if r.UseGlamour && common.IsFileMarkdown(content, r.extension) { md, err := r.glamourize(w, content) if err != nil { return common.ErrorCmd(err) } content = md } else { f, err := r.renderFile(r.extension, content) if err != nil { return common.ErrorCmd(err) } content = f if r.ShowLineNumber { var ml int content, ml = common.FormatLineNumber(r.common.Styles, content, true) w -= ml } } if r.sidenote != "" { lines := strings.Split(r.sidenote, "\n") sideNoteWidth := int(math.Ceil(float64(r.Model.Width()) * r.SideNotePercent)) for i, l := range lines { lines[i] = common.TruncateString(l, sideNoteWidth) } content = lipgloss.JoinHorizontal(lipgloss.Top, strings.Join(lines, "\n"), content) } // Fix styles after hard wrapping // https://github.com/muesli/reflow/issues/43 // // TODO: solve this upstream in Glamour/Reflow. content = lipgloss.NewStyle().Width(w).Render(content) r.Viewport.Model.SetContent(content) return nil } // Update implements tea.Model. func (r *Code) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg.(type) { case tea.WindowSizeMsg: // Recalculate content width and line wrap. cmds = append(cmds, r.Init()) } v, cmd := r.Viewport.Update(msg) r.Viewport = v.(*vp.Viewport) if cmd != nil { cmds = append(cmds, cmd) } return r, tea.Batch(cmds...) } // View implements tea.View. func (r *Code) View() string { return r.Viewport.View() } // GotoTop moves the viewport to the top of the log. func (r *Code) GotoTop() { r.Viewport.GotoTop() } // GotoBottom moves the viewport to the bottom of the log. func (r *Code) GotoBottom() { r.Viewport.GotoBottom() } // HalfViewDown moves the viewport down by half the viewport height. func (r *Code) HalfViewDown() { r.Viewport.HalfViewDown() } // HalfViewUp moves the viewport up by half the viewport height. func (r *Code) HalfViewUp() { r.Viewport.HalfViewUp() } // ScrollPercent returns the viewport's scroll percentage. func (r *Code) ScrollPercent() float64 { return r.Viewport.ScrollPercent() } // ScrollPosition returns the viewport's scroll position. func (r *Code) ScrollPosition() int { scroll := r.ScrollPercent() * 100 if scroll < 0 || math.IsNaN(scroll) { scroll = 0 } return int(scroll) } func (r *Code) glamourize(w int, md string) (string, error) { r.renderMutex.Lock() defer r.renderMutex.Unlock() if w > 120 { w = 120 } tr, err := glamour.NewTermRenderer( glamour.WithStyles(r.styleConfig), glamour.WithWordWrap(w), ) if err != nil { return "", err } mdt, err := tr.Render(md) if err != nil { return "", err } return mdt, nil } func (r *Code) renderFile(path, content string) (string, error) { lexer := lexers.Match(path) if path == "" { lexer = lexers.Analyse(content) } lang := "" if lexer != nil && lexer.Config() != nil { lang = lexer.Config().Name } formatter := &gansi.CodeBlockElement{ Code: content, Language: lang, } s := strings.Builder{} rc := r.renderContext if r.ShowLineNumber { st := common.StyleConfig() var m uint st.CodeBlock.Margin = &m rc = gansi.NewRenderContext(gansi.Options{ Styles: st, }) } err := formatter.Render(&s, rc) if err != nil { return "", err } return s.String(), nil } ================================================ FILE: pkg/ui/components/footer/footer.go ================================================ package footer import ( "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/pkg/ui/common" ) // ToggleFooterMsg is a message sent to show/hide the footer. type ToggleFooterMsg struct{} // Footer is a Bubble Tea model that displays help and other info. type Footer struct { common common.Common help help.Model keymap help.KeyMap } // New creates a new Footer. func New(c common.Common, keymap help.KeyMap) *Footer { h := help.New() h.Styles.ShortKey = c.Styles.HelpKey h.Styles.ShortDesc = c.Styles.HelpValue h.Styles.FullKey = c.Styles.HelpKey h.Styles.FullDesc = c.Styles.HelpValue f := &Footer{ common: c, help: h, keymap: keymap, } f.SetSize(c.Width, c.Height) return f } // SetSize implements common.Component. func (f *Footer) SetSize(width, height int) { f.common.SetSize(width, height) f.help.SetWidth(width - f.common.Styles.Footer.GetHorizontalFrameSize()) } // Init implements tea.Model. func (f *Footer) Init() tea.Cmd { return nil } // Update implements tea.Model. func (f *Footer) Update(_ tea.Msg) (common.Model, tea.Cmd) { return f, nil } // View implements tea.Model. func (f *Footer) View() string { if f.keymap == nil { return "" } s := f.common.Styles.Footer. Width(f.common.Width) helpView := f.help.View(f.keymap) return f.common.Zone.Mark( "footer", s.Render(helpView), ) } // ShortHelp returns the short help key bindings. func (f *Footer) ShortHelp() []key.Binding { return f.keymap.ShortHelp() } // FullHelp returns the full help key bindings. func (f *Footer) FullHelp() [][]key.Binding { return f.keymap.FullHelp() } // ShowAll returns whether the full help is shown. func (f *Footer) ShowAll() bool { return f.help.ShowAll } // SetShowAll sets whether the full help is shown. func (f *Footer) SetShowAll(show bool) { f.help.ShowAll = show } // Height returns the height of the footer. func (f *Footer) Height() int { return lipgloss.Height(f.View()) } // ToggleFooterCmd sends a ToggleFooterMsg to show/hide the help footer. func ToggleFooterCmd() tea.Msg { return ToggleFooterMsg{} } ================================================ FILE: pkg/ui/components/header/header.go ================================================ package header import ( "strings" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/soft-serve/pkg/ui/common" ) // Header represents a header component. type Header struct { common common.Common text string } // New creates a new header component. func New(c common.Common, text string) *Header { return &Header{ common: c, text: text, } } // SetSize implements common.Component. func (h *Header) SetSize(width, height int) { h.common.SetSize(width, height) } // Init implements tea.Model. func (h *Header) Init() tea.Cmd { return nil } // Update implements tea.Model. func (h *Header) Update(_ tea.Msg) (common.Model, tea.Cmd) { return h, nil } // View implements tea.Model. func (h *Header) View() string { return h.common.Styles.ServerName.Render(strings.TrimSpace(h.text)) } ================================================ FILE: pkg/ui/components/selector/selector.go ================================================ package selector import ( "sync" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/soft-serve/pkg/ui/common" ) // Selector is a list of items that can be selected. type Selector struct { *list.Model common common.Common active int filterState list.FilterState // XXX: we use a mutex to support concurrent access to the model. This is // needed to implement pagination for the Log component. list.Model does // not support item pagination so we hack it ourselves on top of // list.Model. mtx sync.RWMutex } // IdentifiableItem is an item that can be identified by a string. Implements // list.DefaultItem. type IdentifiableItem interface { list.DefaultItem ID() string } // ItemDelegate is a wrapper around list.ItemDelegate. type ItemDelegate interface { list.ItemDelegate } // SelectMsg is a message that is sent when an item is selected. type SelectMsg struct{ IdentifiableItem } // ActiveMsg is a message that is sent when an item is active but not selected. type ActiveMsg struct{ IdentifiableItem } // New creates a new selector. func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector { itms := make([]list.Item, len(items)) for i, item := range items { itms[i] = item } l := list.New(itms, delegate, common.Width, common.Height) l.Styles.NoItems = common.Styles.NoContent s := &Selector{ Model: &l, common: common, } s.SetSize(common.Width, common.Height) return s } // PerPage returns the number of items per page. func (s *Selector) PerPage() int { s.mtx.RLock() defer s.mtx.RUnlock() return s.Model.Paginator.PerPage } // SetPage sets the current page. func (s *Selector) SetPage(page int) { s.mtx.Lock() defer s.mtx.Unlock() s.Model.Paginator.Page = page } // Page returns the current page. func (s *Selector) Page() int { s.mtx.RLock() defer s.mtx.RUnlock() return s.Model.Paginator.Page } // TotalPages returns the total number of pages. func (s *Selector) TotalPages() int { s.mtx.RLock() defer s.mtx.RUnlock() return s.Model.Paginator.TotalPages } // SetTotalPages sets the total number of pages given the number of items. func (s *Selector) SetTotalPages(items int) int { s.mtx.Lock() defer s.mtx.Unlock() return s.Model.Paginator.SetTotalPages(items) } // SelectedItem returns the currently selected item. func (s *Selector) SelectedItem() IdentifiableItem { s.mtx.RLock() defer s.mtx.RUnlock() item := s.Model.SelectedItem() i, ok := item.(IdentifiableItem) if !ok { return nil } return i } // Select selects the item at the given index. func (s *Selector) Select(index int) { s.mtx.RLock() defer s.mtx.RUnlock() s.Model.Select(index) } // SetShowTitle sets the show title flag. func (s *Selector) SetShowTitle(show bool) { s.mtx.Lock() defer s.mtx.Unlock() s.Model.SetShowTitle(show) } // SetShowHelp sets the show help flag. func (s *Selector) SetShowHelp(show bool) { s.mtx.Lock() defer s.mtx.Unlock() s.Model.SetShowHelp(show) } // SetShowStatusBar sets the show status bar flag. func (s *Selector) SetShowStatusBar(show bool) { s.mtx.Lock() defer s.mtx.Unlock() s.Model.SetShowStatusBar(show) } // DisableQuitKeybindings disables the quit keybindings. func (s *Selector) DisableQuitKeybindings() { s.mtx.Lock() defer s.mtx.Unlock() s.Model.DisableQuitKeybindings() } // SetShowFilter sets the show filter flag. func (s *Selector) SetShowFilter(show bool) { s.mtx.Lock() defer s.mtx.Unlock() s.Model.SetShowFilter(show) } // SetShowPagination sets the show pagination flag. func (s *Selector) SetShowPagination(show bool) { s.mtx.Lock() defer s.mtx.Unlock() s.Model.SetShowPagination(show) } // SetFilteringEnabled sets the filtering enabled flag. func (s *Selector) SetFilteringEnabled(enabled bool) { s.mtx.Lock() defer s.mtx.Unlock() s.Model.SetFilteringEnabled(enabled) } // SetSize implements common.Component. func (s *Selector) SetSize(width, height int) { s.mtx.Lock() defer s.mtx.Unlock() s.common.SetSize(width, height) s.Model.SetSize(width, height) } // SetItems sets the items in the selector. func (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd { its := make([]list.Item, len(items)) for i, item := range items { its[i] = item } s.mtx.Lock() defer s.mtx.Unlock() return s.Model.SetItems(its) } // Index returns the index of the selected item. func (s *Selector) Index() int { s.mtx.RLock() defer s.mtx.RUnlock() return s.Model.Index() } // Items returns the items in the selector. func (s *Selector) Items() []list.Item { s.mtx.RLock() defer s.mtx.RUnlock() return s.Model.Items() } // VisibleItems returns all the visible items in the selector. func (s *Selector) VisibleItems() []list.Item { s.mtx.RLock() defer s.mtx.RUnlock() return s.Model.VisibleItems() } // FilterState returns the filter state. func (s *Selector) FilterState() list.FilterState { s.mtx.RLock() defer s.mtx.RUnlock() return s.Model.FilterState() } // CursorUp moves the cursor up. func (s *Selector) CursorUp() { s.mtx.Lock() defer s.mtx.Unlock() s.Model.CursorUp() } // CursorDown moves the cursor down. func (s *Selector) CursorDown() { s.mtx.Lock() defer s.mtx.Unlock() s.Model.CursorDown() } // Init implements tea.Model. func (s *Selector) Init() tea.Cmd { return s.activeCmd } // Update implements tea.Model. func (s *Selector) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.MouseClickMsg: m := msg.Mouse() switch m.Button { case tea.MouseWheelUp: s.CursorUp() case tea.MouseWheelDown: s.CursorDown() case tea.MouseLeft: curIdx := s.Index() for i, item := range s.Items() { item, _ := item.(IdentifiableItem) // Check each item to see if it's in bounds. if item != nil && s.common.Zone.Get(item.ID()).InBounds(msg) { if i == curIdx { cmds = append(cmds, s.SelectItemCmd) } else { s.Select(i) } break } } } case tea.KeyPressMsg: filterState := s.FilterState() switch { case key.Matches(msg, s.common.KeyMap.Help): if filterState == list.Filtering { return s, tea.Batch(cmds...) } case key.Matches(msg, s.common.KeyMap.Select): if filterState != list.Filtering { cmds = append(cmds, s.SelectItemCmd) } } case list.FilterMatchesMsg: cmds = append(cmds, s.activeFilterCmd) } m, cmd := s.Model.Update(msg) s.mtx.Lock() s.Model = &m s.mtx.Unlock() if cmd != nil { cmds = append(cmds, cmd) } // Track filter state and update active item when filter state changes. filterState := s.FilterState() if s.filterState != filterState { cmds = append(cmds, s.activeFilterCmd) } s.filterState = filterState // Send ActiveMsg when index change. if s.active != s.Index() { cmds = append(cmds, s.activeCmd) } s.active = s.Index() return s, tea.Batch(cmds...) } // View implements tea.Model. func (s *Selector) View() string { return s.Model.View() } // SelectItemCmd is a command that selects the currently active item. func (s *Selector) SelectItemCmd() tea.Msg { return SelectMsg{s.SelectedItem()} } func (s *Selector) activeCmd() tea.Msg { item := s.SelectedItem() return ActiveMsg{item} } func (s *Selector) activeFilterCmd() tea.Msg { // Here we use VisibleItems because when list.FilterMatchesMsg is sent, // VisibleItems is the only way to get the list of filtered items. The list // bubble should export something like list.FilterMatchesMsg.Items(). items := s.VisibleItems() if len(items) == 0 { return nil } item := items[0] i, ok := item.(IdentifiableItem) if !ok { return nil } return ActiveMsg{i} } ================================================ FILE: pkg/ui/components/statusbar/statusbar.go ================================================ package statusbar import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/x/ansi" ) // Model is a status bar model. type Model struct { common common.Common key string value string info string extra string } // New creates a new status bar component. func New(c common.Common) *Model { s := &Model{ common: c, } return s } // SetSize implements common.Component. func (s *Model) SetSize(width, height int) { s.common.Width = width s.common.Height = height } // SetStatus sets the status bar status. func (s *Model) SetStatus(key, value, info, extra string) { if key != "" { s.key = key } if value != "" { s.value = value } if info != "" { s.info = info } if extra != "" { s.extra = extra } } // Init implements tea.Model. func (s *Model) Init() tea.Cmd { return nil } // Update implements tea.Model. func (s *Model) Update(msg tea.Msg) (common.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: s.SetSize(msg.Width, msg.Height) } return s, nil } // View implements tea.Model. func (s *Model) View() string { st := s.common.Styles w := lipgloss.Width help := s.common.Zone.Mark( "repo-help", st.StatusBarHelp.Render("? Help"), ) key := st.StatusBarKey.Render(s.key) info := "" if s.info != "" { info = st.StatusBarInfo.Render(s.info) } branch := st.StatusBarBranch.Render(s.extra) maxWidth := s.common.Width - w(key) - w(info) - w(branch) - w(help) v := ansi.Truncate(s.value, maxWidth-st.StatusBarValue.GetHorizontalFrameSize(), "…") value := st.StatusBarValue. Width(maxWidth). Render(v) return lipgloss.NewStyle().MaxWidth(s.common.Width). Render( lipgloss.JoinHorizontal(lipgloss.Top, key, value, info, branch, help, ), ) } ================================================ FILE: pkg/ui/components/tabs/tabs.go ================================================ package tabs import ( "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/pkg/ui/common" ) // SelectTabMsg is a message that contains the index of the tab to select. type SelectTabMsg int // ActiveTabMsg is a message that contains the index of the current active tab. type ActiveTabMsg int // Tabs is bubbletea component that displays a list of tabs. type Tabs struct { common common.Common tabs []string activeTab int TabSeparator lipgloss.Style TabInactive lipgloss.Style TabActive lipgloss.Style TabDot lipgloss.Style UseDot bool } // New creates a new Tabs component. func New(c common.Common, tabs []string) *Tabs { r := &Tabs{ common: c, tabs: tabs, activeTab: 0, TabSeparator: c.Styles.TabSeparator, TabInactive: c.Styles.TabInactive, TabActive: c.Styles.TabActive, } return r } // SetSize implements common.Component. func (t *Tabs) SetSize(width, height int) { t.common.SetSize(width, height) } // Init implements tea.Model. func (t *Tabs) Init() tea.Cmd { t.activeTab = 0 return nil } // Update implements tea.Model. func (t *Tabs) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "tab": t.activeTab = (t.activeTab + 1) % len(t.tabs) cmds = append(cmds, t.activeTabCmd) case "shift+tab": t.activeTab = (t.activeTab - 1 + len(t.tabs)) % len(t.tabs) cmds = append(cmds, t.activeTabCmd) } case tea.MouseClickMsg: switch msg.Button { case tea.MouseLeft: for i, tab := range t.tabs { if t.common.Zone.Get(tab).InBounds(msg) { t.activeTab = i cmds = append(cmds, t.activeTabCmd) } } } case SelectTabMsg: tab := int(msg) if tab >= 0 && tab < len(t.tabs) { t.activeTab = int(msg) } } return t, tea.Batch(cmds...) } // View implements tea.Model. func (t *Tabs) View() string { s := strings.Builder{} sep := t.TabSeparator for i, tab := range t.tabs { style := t.TabInactive prefix := " " if i == t.activeTab { style = t.TabActive prefix = t.TabDot.Render("• ") } if t.UseDot { s.WriteString(prefix) } s.WriteString( t.common.Zone.Mark( tab, style.Render(tab), ), ) if i != len(t.tabs)-1 { s.WriteString(sep.String()) } } return lipgloss.NewStyle(). MaxWidth(t.common.Width). Render(s.String()) } func (t *Tabs) activeTabCmd() tea.Msg { return ActiveTabMsg(t.activeTab) } // SelectTabCmd is a bubbletea command that selects the tab at the given index. func SelectTabCmd(tab int) tea.Cmd { return func() tea.Msg { return SelectTabMsg(tab) } } ================================================ FILE: pkg/ui/components/viewport/viewport.go ================================================ package viewport import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/soft-serve/pkg/ui/common" ) // Viewport represents a viewport component. type Viewport struct { common common.Common *viewport.Model } // New returns a new Viewport. func New(c common.Common) *Viewport { vp := viewport.New() vp.SetWidth(c.Width) vp.SetHeight(c.Height) vp.MouseWheelEnabled = true return &Viewport{ common: c, Model: &vp, } } // SetSize implements common.Component. func (v *Viewport) SetSize(width, height int) { v.common.SetSize(width, height) v.Model.SetWidth(width) v.Model.SetHeight(height) } // Init implements tea.Model. func (v *Viewport) Init() tea.Cmd { return nil } // Update implements tea.Model. func (v *Viewport) Update(msg tea.Msg) (common.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, v.common.KeyMap.GotoTop): v.GotoTop() case key.Matches(msg, v.common.KeyMap.GotoBottom): v.GotoBottom() } } vp, cmd := v.Model.Update(msg) v.Model = &vp return v, cmd } // View implements tea.Model. func (v *Viewport) View() string { return v.Model.View() } // SetContent sets the viewport's content. func (v *Viewport) SetContent(content string) { v.Model.SetContent(content) } // GotoTop moves the viewport to the top of the log. func (v *Viewport) GotoTop() { v.Model.GotoTop() } // GotoBottom moves the viewport to the bottom of the log. func (v *Viewport) GotoBottom() { v.Model.GotoBottom() } // HalfViewDown moves the viewport down by half the viewport height. func (v *Viewport) HalfViewDown() { v.Model.HalfPageDown() } // HalfViewUp moves the viewport up by half the viewport height. func (v *Viewport) HalfViewUp() { v.Model.HalfPageUp() } // ScrollPercent returns the viewport's scroll percentage. func (v *Viewport) ScrollPercent() float64 { return v.Model.ScrollPercent() } ================================================ FILE: pkg/ui/keymap/keymap.go ================================================ package keymap import "charm.land/bubbles/v2/key" // KeyMap is a map of key bindings for the UI. type KeyMap struct { Quit key.Binding Up key.Binding Down key.Binding UpDown key.Binding LeftRight key.Binding Arrows key.Binding GotoTop key.Binding GotoBottom key.Binding Select key.Binding Section key.Binding Back key.Binding PrevPage key.Binding NextPage key.Binding Help key.Binding SelectItem key.Binding BackItem key.Binding Copy key.Binding } // DefaultKeyMap returns the default key map. func DefaultKeyMap() *KeyMap { km := new(KeyMap) km.Quit = key.NewBinding( key.WithKeys( "q", "ctrl+c", ), key.WithHelp( "q", "quit", ), ) km.Up = key.NewBinding( key.WithKeys( "up", "k", ), key.WithHelp( "↑", "up", ), ) km.Down = key.NewBinding( key.WithKeys( "down", "j", ), key.WithHelp( "↓", "down", ), ) km.UpDown = key.NewBinding( key.WithKeys( "up", "down", "k", "j", ), key.WithHelp( "↑↓", "navigate", ), ) km.LeftRight = key.NewBinding( key.WithKeys( "left", "h", "right", "l", ), key.WithHelp( "←→", "navigate", ), ) km.Arrows = key.NewBinding( key.WithKeys( "up", "right", "down", "left", "k", "j", "h", "l", ), key.WithHelp( "↑←↓→", "navigate", ), ) km.GotoTop = key.NewBinding( key.WithKeys( "home", "g", ), key.WithHelp( "g/home", "goto top", ), ) km.GotoBottom = key.NewBinding( key.WithKeys( "end", "G", ), key.WithHelp( "G/end", "goto bottom", ), ) km.Select = key.NewBinding( key.WithKeys( "enter", ), key.WithHelp( "enter", "select", ), ) km.Section = key.NewBinding( key.WithKeys( "tab", "shift+tab", ), key.WithHelp( "tab", "section", ), ) km.Back = key.NewBinding( key.WithKeys( "esc", ), key.WithHelp( "esc", "back", ), ) km.PrevPage = key.NewBinding( key.WithKeys( "pgup", "b", "u", ), key.WithHelp( "pgup", "prev page", ), ) km.NextPage = key.NewBinding( key.WithKeys( "pgdown", "f", "d", ), key.WithHelp( "pgdn", "next page", ), ) km.Help = key.NewBinding( key.WithKeys( "?", ), key.WithHelp( "?", "toggle help", ), ) km.SelectItem = key.NewBinding( key.WithKeys( "l", "right", ), key.WithHelp( "→/l", "select", ), ) km.BackItem = key.NewBinding( key.WithKeys( "h", "left", "backspace", ), key.WithHelp( "←/h", "back", ), ) km.Copy = key.NewBinding( key.WithKeys( "c", "ctrl+c", ), key.WithHelp( "c", "copy text", ), ) return km } ================================================ FILE: pkg/ui/pages/repo/empty.go ================================================ package repo import ( "fmt" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/ui/common" ) func defaultEmptyRepoMsg(cfg *config.Config, repo string) string { return fmt.Sprintf(`# Quick Start Get started by cloning this repository, add your files, commit, and push. ## Clone this repository. `+"```"+`sh git clone %[1]s `+"```"+` ## Creating a new repository on the command line `+"```"+`sh touch README.md git init git add README.md git branch -M main git commit -m "first commit" git remote add origin %[1]s git push -u origin main `+"```"+` ## Pushing an existing repository from the command line `+"```"+`sh git remote add origin %[1]s git push -u origin main `+"```"+` `, common.RepoURL(cfg.SSH.PublicURL, repo)) } ================================================ FILE: pkg/ui/pages/repo/files.go ================================================ package repo import ( "errors" "fmt" "path/filepath" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" gitm "github.com/aymanbagabas/git-module" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/code" "github.com/charmbracelet/soft-serve/pkg/ui/components/selector" ) type filesView int const ( filesViewLoading filesView = iota filesViewFiles filesViewContent ) var ( errNoFileSelected = errors.New("no file selected") errBinaryFile = errors.New("binary file") errInvalidFile = errors.New("invalid file") ) var ( lineNo = key.NewBinding( key.WithKeys("l"), key.WithHelp("l", "toggle line numbers"), ) blameView = key.NewBinding( key.WithKeys("b"), key.WithHelp("b", "toggle blame view"), ) preview = key.NewBinding( key.WithKeys("p"), key.WithHelp("p", "toggle preview"), ) ) // FileItemsMsg is a message that contains a list of files. type FileItemsMsg []selector.IdentifiableItem // FileContentMsg is a message that contains the content of a file. type FileContentMsg struct { content string ext string } // FileBlameMsg is a message that contains the blame of a file. type FileBlameMsg *gitm.Blame // Files is the model for the files view. type Files struct { common common.Common selector *selector.Selector ref *git.Reference activeView filesView repo proto.Repository code *code.Code path string currentItem *FileItem currentContent FileContentMsg currentBlame FileBlameMsg lastSelected []int lineNumber bool spinner spinner.Model cursor int blameView bool } // NewFiles creates a new files model. func NewFiles(common common.Common) *Files { f := &Files{ common: common, code: code.New(common, "", ""), activeView: filesViewLoading, lastSelected: make([]int, 0), lineNumber: true, } selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common}) selector.SetShowFilter(false) selector.SetShowHelp(false) selector.SetShowPagination(false) selector.SetShowStatusBar(false) selector.SetShowTitle(false) selector.SetFilteringEnabled(false) selector.DisableQuitKeybindings() selector.KeyMap.NextPage = common.KeyMap.NextPage selector.KeyMap.PrevPage = common.KeyMap.PrevPage f.selector = selector f.code.ShowLineNumber = f.lineNumber s := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(common.Styles.Spinner)) f.spinner = s return f } // Path implements common.TabComponent. func (f *Files) Path() string { path := f.path if path == "." { return "" } return path } // TabName returns the tab name. func (f *Files) TabName() string { return "Files" } // SetSize implements common.Component. func (f *Files) SetSize(width, height int) { f.common.SetSize(width, height) f.selector.SetSize(width, height) f.code.SetSize(width, height) } // ShortHelp implements help.KeyMap. func (f *Files) ShortHelp() []key.Binding { k := f.selector.KeyMap switch f.activeView { case filesViewFiles: return []key.Binding{ f.common.KeyMap.SelectItem, f.common.KeyMap.BackItem, k.CursorUp, k.CursorDown, } case filesViewContent: b := []key.Binding{ f.common.KeyMap.UpDown, f.common.KeyMap.BackItem, } return b default: return []key.Binding{} } } // FullHelp implements help.KeyMap. func (f *Files) FullHelp() [][]key.Binding { b := make([][]key.Binding, 0) copyKey := f.common.KeyMap.Copy actionKeys := []key.Binding{} switch f.activeView { case filesViewFiles: copyKey.SetHelp("c", "copy name") k := f.selector.KeyMap b = append(b, [][]key.Binding{ { f.common.KeyMap.SelectItem, f.common.KeyMap.BackItem, }, { k.CursorUp, k.CursorDown, k.NextPage, k.PrevPage, }, { k.GoToStart, k.GoToEnd, }, }...) case filesViewContent: if !f.code.UseGlamour { actionKeys = append(actionKeys, lineNo) } actionKeys = append(actionKeys, blameView) if common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) && !f.blameView { actionKeys = append(actionKeys, preview) } copyKey.SetHelp("c", "copy content") k := f.code.KeyMap b = append(b, []key.Binding{ f.common.KeyMap.BackItem, }) b = append(b, [][]key.Binding{ { k.PageDown, k.PageUp, k.HalfPageDown, k.HalfPageUp, }, { k.Down, k.Up, f.common.KeyMap.GotoTop, f.common.KeyMap.GotoBottom, }, }...) } actionKeys = append([]key.Binding{ copyKey, }, actionKeys...) return append(b, actionKeys) } // Init implements tea.Model. func (f *Files) Init() tea.Cmd { f.path = "" f.currentItem = nil f.activeView = filesViewLoading f.lastSelected = make([]int, 0) f.blameView = false f.currentBlame = nil f.code.UseGlamour = false return tea.Batch(f.spinner.Tick, f.updateFilesCmd) } // Update implements tea.Model. func (f *Files) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: f.repo = msg case RefMsg: f.ref = msg f.selector.Select(0) cmds = append(cmds, f.Init()) case FileItemsMsg: cmds = append(cmds, f.selector.SetItems(msg), ) f.activeView = filesViewFiles if f.cursor >= 0 { f.selector.Select(f.cursor) f.cursor = -1 } case FileContentMsg: f.activeView = filesViewContent f.currentContent = msg f.code.UseGlamour = common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) cmds = append(cmds, f.code.SetContent(msg.content, msg.ext)) f.code.GotoTop() case FileBlameMsg: f.currentBlame = msg f.activeView = filesViewContent f.code.UseGlamour = false f.code.SetSideNote(renderBlame(f.common, f.currentItem, msg)) case selector.SelectMsg: switch sel := msg.IdentifiableItem.(type) { case FileItem: f.currentItem = &sel f.path = filepath.Join(f.path, sel.entry.Name()) if sel.entry.IsTree() { cmds = append(cmds, f.selectTreeCmd) } else { cmds = append(cmds, f.selectFileCmd) } } case GoBackMsg: switch f.activeView { case filesViewFiles, filesViewContent: cmds = append(cmds, f.deselectItemCmd()) } case tea.KeyPressMsg: switch f.activeView { case filesViewFiles: switch { case key.Matches(msg, f.common.KeyMap.SelectItem): cmds = append(cmds, f.selector.SelectItemCmd) case key.Matches(msg, f.common.KeyMap.BackItem): cmds = append(cmds, f.deselectItemCmd()) } case filesViewContent: switch { case key.Matches(msg, f.common.KeyMap.BackItem): cmds = append(cmds, f.deselectItemCmd()) case key.Matches(msg, f.common.KeyMap.Copy): cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard")) case key.Matches(msg, lineNo) && !f.code.UseGlamour: f.lineNumber = !f.lineNumber f.code.ShowLineNumber = f.lineNumber cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext)) case key.Matches(msg, blameView): f.activeView = filesViewLoading f.blameView = !f.blameView if f.blameView { cmds = append(cmds, f.fetchBlame) } else { f.activeView = filesViewContent cmds = append(cmds, f.code.SetSideNote("")) } cmds = append(cmds, f.spinner.Tick) case key.Matches(msg, preview) && common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) && !f.blameView: f.code.UseGlamour = !f.code.UseGlamour cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext)) } } case tea.WindowSizeMsg: f.SetSize(msg.Width, msg.Height) switch f.activeView { case filesViewFiles: if f.repo != nil { cmds = append(cmds, f.updateFilesCmd) } case filesViewContent: if f.currentContent.content != "" { m, cmd := f.code.Update(msg) f.code = m.(*code.Code) if cmd != nil { cmds = append(cmds, cmd) } } } case EmptyRepoMsg: f.ref = nil f.path = "" f.currentItem = nil f.activeView = filesViewFiles f.lastSelected = make([]int, 0) f.selector.Select(0) cmds = append(cmds, f.setItems([]selector.IdentifiableItem{})) case spinner.TickMsg: if f.activeView == filesViewLoading && f.spinner.ID() == msg.ID { s, cmd := f.spinner.Update(msg) f.spinner = s if cmd != nil { cmds = append(cmds, cmd) } } } switch f.activeView { case filesViewFiles: m, cmd := f.selector.Update(msg) f.selector = m.(*selector.Selector) if cmd != nil { cmds = append(cmds, cmd) } case filesViewContent: m, cmd := f.code.Update(msg) f.code = m.(*code.Code) if cmd != nil { cmds = append(cmds, cmd) } } return f, tea.Batch(cmds...) } // View implements tea.Model. func (f *Files) View() string { switch f.activeView { case filesViewLoading: return renderLoading(f.common, f.spinner) case filesViewFiles: return f.selector.View() case filesViewContent: return f.code.View() default: return "" } } // SpinnerID implements common.TabComponent. func (f *Files) SpinnerID() int { return f.spinner.ID() } // StatusBarValue returns the status bar value. func (f *Files) StatusBarValue() string { p := f.path if p == "." || p == "" { return " " } return p } // StatusBarInfo returns the status bar info. func (f *Files) StatusBarInfo() string { switch f.activeView { case filesViewFiles: return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems())) case filesViewContent: return common.ScrollPercent(f.code.ScrollPosition()) default: return "" } } func (f *Files) updateFilesCmd() tea.Msg { files := make([]selector.IdentifiableItem, 0) dirs := make([]selector.IdentifiableItem, 0) if f.ref == nil { return nil } r, err := f.repo.Open() if err != nil { return common.ErrorCmd(err) } path := f.path ref := f.ref t, err := r.TreePath(ref, path) if err != nil { return common.ErrorCmd(err) } ents, err := t.Entries() if err != nil { return common.ErrorCmd(err) } ents.Sort() for _, e := range ents { if e.IsTree() { dirs = append(dirs, FileItem{entry: e}) } else { files = append(files, FileItem{entry: e}) } } return FileItemsMsg(append(dirs, files...)) } func (f *Files) selectTreeCmd() tea.Msg { if f.currentItem != nil && f.currentItem.entry.IsTree() { f.lastSelected = append(f.lastSelected, f.selector.Index()) f.cursor = 0 return f.updateFilesCmd() } return common.ErrorMsg(errNoFileSelected) } func (f *Files) selectFileCmd() tea.Msg { i := f.currentItem if i != nil && !i.entry.IsTree() { fi := i.entry.File() if i.Mode().IsDir() || f == nil { return common.ErrorMsg(errInvalidFile) } var err error var bin bool r, err := f.repo.Open() if err == nil { attrs, err := r.CheckAttributes(f.ref, fi.Path()) if err == nil { for _, attr := range attrs { if (attr.Name == "binary" && attr.Value == "set") || (attr.Name == "text" && attr.Value == "unset") { bin = true break } } } } if !bin { bin, err = fi.IsBinary() if err != nil { f.path = filepath.Dir(f.path) return common.ErrorMsg(err) } } if bin { f.path = filepath.Dir(f.path) return common.ErrorMsg(errBinaryFile) } c, err := fi.Bytes() if err != nil { f.path = filepath.Dir(f.path) return common.ErrorMsg(err) } f.lastSelected = append(f.lastSelected, f.selector.Index()) return FileContentMsg{string(c), i.entry.Name()} } return common.ErrorMsg(errNoFileSelected) } func (f *Files) fetchBlame() tea.Msg { r, err := f.repo.Open() if err != nil { return common.ErrorMsg(err) } b, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path()) if err != nil { return common.ErrorMsg(err) } return FileBlameMsg(b) } func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string { if f == nil || f.entry.IsTree() || b == nil { return "" } lines := make([]string, 0) i := 1 var prev string for { commit := b.Line(i) if commit == nil { break } who := fmt.Sprintf("%s <%s>", commit.Author.Name, commit.Author.Email) line := fmt.Sprintf("%s %s %s", c.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]), c.Styles.Tree.Blame.Message.Render(commit.Summary()), c.Styles.Tree.Blame.Who.Render(who), ) if line != prev { lines = append(lines, line) } else { lines = append(lines, "") } prev = line i++ } return strings.Join(lines, "\n") } func (f *Files) deselectItemCmd() tea.Cmd { f.path = filepath.Dir(f.path) index := 0 if len(f.lastSelected) > 0 { index = f.lastSelected[len(f.lastSelected)-1] f.lastSelected = f.lastSelected[:len(f.lastSelected)-1] } f.cursor = index f.activeView = filesViewFiles f.code.SetSideNote("") f.blameView = false f.currentBlame = nil f.code.UseGlamour = false return f.updateFilesCmd } func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd { return func() tea.Msg { return FileItemsMsg(items) } } ================================================ FILE: pkg/ui/pages/repo/filesitem.go ================================================ package repo import ( "fmt" "io" "io/fs" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/dustin/go-humanize" ) // FileItem is a list item for a file. type FileItem struct { entry *git.TreeEntry } // ID returns the ID of the file item. func (i FileItem) ID() string { return i.entry.Name() } // Title returns the title of the file item. func (i FileItem) Title() string { return common.UnquoteFilename(i.entry.Name()) } // Description returns the description of the file item. func (i FileItem) Description() string { return "" } // Mode returns the mode of the file item. func (i FileItem) Mode() fs.FileMode { return i.entry.Mode() } // FilterValue implements list.Item. func (i FileItem) FilterValue() string { return i.Title() } // FileItems is a list of file items. type FileItems []FileItem // Len implements sort.Interface. func (cl FileItems) Len() int { return len(cl) } // Swap implements sort.Interface. func (cl FileItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } // Less implements sort.Interface. func (cl FileItems) Less(i, j int) bool { if cl[i].entry.IsTree() && cl[j].entry.IsTree() { return cl[i].Title() < cl[j].Title() } else if cl[i].entry.IsTree() { return true } else if cl[j].entry.IsTree() { return false } return cl[i].Title() < cl[j].Title() } // FileItemDelegate is the delegate for the file item list. type FileItemDelegate struct { common *common.Common } // Height returns the height of the file item list. Implements list.ItemDelegate. func (d FileItemDelegate) Height() int { return 1 } // Spacing returns the spacing of the file item list. Implements list.ItemDelegate. func (d FileItemDelegate) Spacing() int { return 0 } // Update implements list.ItemDelegate. func (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { item, ok := m.SelectedItem().(FileItem) if !ok { return nil } switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, d.common.KeyMap.Copy): return copyCmd(item.entry.Name(), fmt.Sprintf("File name %q copied to clipboard", item.entry.Name())) } } return nil } // Render implements list.ItemDelegate. func (d FileItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { i, ok := listItem.(FileItem) if !ok { return } s := d.common.Styles.Tree name := i.Title() size := humanize.Bytes(uint64(i.entry.Size())) //nolint:gosec size = strings.ReplaceAll(size, " ", "") sizeLen := lipgloss.Width(size) if i.entry.IsTree() { size = strings.Repeat(" ", sizeLen) if index == m.Index() { name = s.Active.FileDir.Render(name) } else { name = s.Normal.FileDir.Render(name) } } var nameStyle, sizeStyle, modeStyle lipgloss.Style mode := i.Mode() if index == m.Index() { nameStyle = s.Active.FileName sizeStyle = s.Active.FileSize modeStyle = s.Active.FileMode fmt.Fprint(w, s.Selector.Render(">")) //nolint:errcheck } else { nameStyle = s.Normal.FileName sizeStyle = s.Normal.FileSize modeStyle = s.Normal.FileMode fmt.Fprint(w, s.Selector.Render(" ")) //nolint:errcheck } sizeStyle = sizeStyle. Width(8). Align(lipgloss.Right). MarginLeft(1) leftMargin := s.Selector.GetMarginLeft() + s.Selector.GetWidth() + s.Normal.FileMode.GetMarginLeft() + s.Normal.FileMode.GetWidth() + nameStyle.GetMarginLeft() + sizeStyle.GetHorizontalFrameSize() name = common.TruncateString(name, m.Width()-leftMargin) name = nameStyle.Render(name) size = sizeStyle.Render(size) modeStr := modeStyle.Render(mode.String()) truncate := lipgloss.NewStyle().MaxWidth(m.Width() - s.Selector.GetHorizontalFrameSize() - s.Selector.GetWidth()) //nolint:errcheck fmt.Fprint(w, d.common.Zone.Mark( i.ID(), truncate.Render(fmt.Sprintf("%s%s%s", modeStr, size, name, )), ), ) } ================================================ FILE: pkg/ui/pages/repo/log.go ================================================ package repo import ( "fmt" "strings" "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" gansi "charm.land/glamour/v2/ansi" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/footer" "github.com/charmbracelet/soft-serve/pkg/ui/components/selector" "github.com/charmbracelet/soft-serve/pkg/ui/components/viewport" "github.com/charmbracelet/soft-serve/pkg/ui/styles" "github.com/muesli/reflow/wrap" ) var waitBeforeLoading = time.Millisecond * 100 type logView int const ( logViewLoading logView = iota logViewCommits logViewDiff ) // LogCountMsg is a message that contains the number of commits in a repo. type LogCountMsg int64 // LogItemsMsg is a message that contains a slice of LogItem. type LogItemsMsg []selector.IdentifiableItem // LogCommitMsg is a message that contains a git commit. type LogCommitMsg *git.Commit // LogDiffMsg is a message that contains a git diff. type LogDiffMsg *git.Diff // Log is a model that displays a list of commits and their diffs. type Log struct { common common.Common selector *selector.Selector vp *viewport.Viewport activeView logView repo proto.Repository ref *git.Reference count int64 nextPage int activeCommit *git.Commit selectedCommit *git.Commit currentDiff *git.Diff loadingTime time.Time spinner spinner.Model } // NewLog creates a new Log model. func NewLog(common common.Common) *Log { l := &Log{ common: common, vp: viewport.New(common), activeView: logViewCommits, } selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common}) selector.SetShowFilter(false) selector.SetShowHelp(false) selector.SetShowPagination(false) selector.SetShowStatusBar(false) selector.SetShowTitle(false) selector.SetFilteringEnabled(false) selector.DisableQuitKeybindings() selector.KeyMap.NextPage = common.KeyMap.NextPage selector.KeyMap.PrevPage = common.KeyMap.PrevPage l.selector = selector s := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(common.Styles.Spinner)) l.spinner = s return l } // Path implements common.TabComponent. func (l *Log) Path() string { switch l.activeView { case logViewCommits: return "" default: return "diff" // XXX: this is a place holder and doesn't mean anything } } // TabName returns the name of the tab. func (l *Log) TabName() string { return "Commits" } // SetSize implements common.Component. func (l *Log) SetSize(width, height int) { l.common.SetSize(width, height) l.selector.SetSize(width, height) l.vp.SetSize(width, height) } // ShortHelp implements help.KeyMap. func (l *Log) ShortHelp() []key.Binding { switch l.activeView { case logViewCommits: copyKey := l.common.KeyMap.Copy copyKey.SetHelp("c", "copy hash") return []key.Binding{ l.common.KeyMap.UpDown, l.common.KeyMap.SelectItem, copyKey, } case logViewDiff: copyKey := l.common.KeyMap.Copy copyKey.SetHelp("c", "copy diff") return []key.Binding{ l.common.KeyMap.UpDown, l.common.KeyMap.BackItem, copyKey, l.common.KeyMap.GotoTop, l.common.KeyMap.GotoBottom, } default: return []key.Binding{} } } // FullHelp implements help.KeyMap. func (l *Log) FullHelp() [][]key.Binding { k := l.selector.KeyMap b := make([][]key.Binding, 0) switch l.activeView { case logViewCommits: copyKey := l.common.KeyMap.Copy copyKey.SetHelp("c", "copy hash") b = append(b, []key.Binding{ l.common.KeyMap.SelectItem, l.common.KeyMap.BackItem, }) b = append(b, [][]key.Binding{ { copyKey, k.CursorUp, k.CursorDown, }, { k.NextPage, k.PrevPage, k.GoToStart, k.GoToEnd, }, }...) case logViewDiff: copyKey := l.common.KeyMap.Copy copyKey.SetHelp("c", "copy diff") k := l.vp.KeyMap b = append(b, []key.Binding{ l.common.KeyMap.BackItem, copyKey, }) b = append(b, [][]key.Binding{ { k.PageDown, k.PageUp, k.HalfPageDown, k.HalfPageUp, }, { k.Down, k.Up, l.common.KeyMap.GotoTop, l.common.KeyMap.GotoBottom, }, }...) } return b } func (l *Log) startLoading() tea.Cmd { l.loadingTime = time.Now() l.activeView = logViewLoading return l.spinner.Tick } // Init implements tea.Model. func (l *Log) Init() tea.Cmd { l.activeView = logViewCommits l.nextPage = 0 l.count = 0 l.activeCommit = nil l.selectedCommit = nil return tea.Batch( l.countCommitsCmd, // start loading on init l.startLoading(), ) } // Update implements tea.Model. func (l *Log) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: l.repo = msg case RefMsg: l.ref = msg l.selector.Select(0) cmds = append(cmds, l.Init()) case LogCountMsg: l.count = int64(msg) l.selector.SetTotalPages(int(msg)) l.selector.SetItems(make([]selector.IdentifiableItem, l.count)) cmds = append(cmds, l.updateCommitsCmd) case LogItemsMsg: // stop loading after receiving items l.activeView = logViewCommits cmds = append(cmds, l.selector.SetItems(msg)) l.selector.SetPage(l.nextPage) l.SetSize(l.common.Width, l.common.Height) i := l.selector.SelectedItem() if i != nil { l.activeCommit = i.(LogItem).Commit } case tea.KeyPressMsg, tea.MouseClickMsg: switch l.activeView { case logViewCommits: switch kmsg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(kmsg, l.common.KeyMap.SelectItem): cmds = append(cmds, l.selector.SelectItemCmd) } } // XXX: This is a hack for loading commits on demand based on // list.Pagination. curPage := l.selector.Page() s, cmd := l.selector.Update(msg) m := s.(*selector.Selector) l.selector = m if m.Page() != curPage { l.nextPage = m.Page() l.selector.SetPage(curPage) cmds = append(cmds, l.updateCommitsCmd, l.startLoading(), ) } cmds = append(cmds, cmd) case logViewDiff: switch kmsg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(kmsg, l.common.KeyMap.BackItem): l.goBack() case key.Matches(kmsg, l.common.KeyMap.Copy): if l.currentDiff != nil { cmds = append(cmds, copyCmd(l.currentDiff.Patch(), "Commit diff copied to clipboard")) } } } } case GoBackMsg: l.goBack() case selector.ActiveMsg: switch sel := msg.IdentifiableItem.(type) { case LogItem: l.activeCommit = sel.Commit } case selector.SelectMsg: switch sel := msg.IdentifiableItem.(type) { case LogItem: cmds = append(cmds, l.selectCommitCmd(sel.Commit), l.startLoading(), ) } case LogCommitMsg: l.selectedCommit = msg cmds = append(cmds, l.loadDiffCmd) case LogDiffMsg: l.currentDiff = msg l.vp.SetContent( lipgloss.JoinVertical(lipgloss.Left, l.renderCommit(l.selectedCommit), renderSummary(msg, l.common.Styles, l.common.Width), renderDiff(msg, l.common.Width), ), ) l.vp.GotoTop() l.activeView = logViewDiff case footer.ToggleFooterMsg: cmds = append(cmds, l.updateCommitsCmd) case tea.WindowSizeMsg: l.SetSize(msg.Width, msg.Height) if l.selectedCommit != nil && l.currentDiff != nil { l.vp.SetContent( lipgloss.JoinVertical(lipgloss.Left, l.renderCommit(l.selectedCommit), renderSummary(l.currentDiff, l.common.Styles, l.common.Width), renderDiff(l.currentDiff, l.common.Width), ), ) } if l.repo != nil && l.ref != nil { cmds = append(cmds, l.updateCommitsCmd, // start loading on resize since the number of commits per page // might change and we'd need to load more commits. l.startLoading(), ) } case EmptyRepoMsg: l.ref = nil l.activeView = logViewCommits l.nextPage = 0 l.count = 0 l.activeCommit = nil l.selectedCommit = nil l.selector.Select(0) cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}), ) case spinner.TickMsg: if l.activeView == logViewLoading && l.spinner.ID() == msg.ID { s, cmd := l.spinner.Update(msg) if cmd != nil { cmds = append(cmds, cmd) } l.spinner = s } } switch l.activeView { case logViewDiff: vp, cmd := l.vp.Update(msg) l.vp = vp.(*viewport.Viewport) if cmd != nil { cmds = append(cmds, cmd) } } return l, tea.Batch(cmds...) } // View implements tea.Model. func (l *Log) View() string { switch l.activeView { case logViewLoading: if l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) { msg := fmt.Sprintf("%s loading commit", l.spinner.View()) if l.selectedCommit == nil { msg += "s" } msg += "…" return l.common.Styles.SpinnerContainer. Height(l.common.Height). Render(msg) } fallthrough case logViewCommits: return l.selector.View() case logViewDiff: return l.vp.View() default: return "" } } // SpinnerID implements common.TabComponent. func (l *Log) SpinnerID() int { return l.spinner.ID() } // StatusBarValue returns the status bar value. func (l *Log) StatusBarValue() string { if l.activeView == logViewLoading { return "" } c := l.activeCommit if c == nil { return "" } who := c.Author.Name if email := c.Author.Email; email != "" { who += " <" + email + ">" } value := c.ID.String()[:7] if who != "" { value += " by " + who } return value } // StatusBarInfo returns the status bar info. func (l *Log) StatusBarInfo() string { switch l.activeView { case logViewLoading: if l.count == 0 { return "" } fallthrough case logViewCommits: // We're using l.nextPage instead of l.selector.Paginator.Page because // of the paginator hack above. return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages()) case logViewDiff: return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100) default: return "" } } func (l *Log) goBack() { if l.activeView == logViewDiff { l.activeView = logViewCommits l.selectedCommit = nil } } func (l *Log) countCommitsCmd() tea.Msg { if l.ref == nil { return nil } r, err := l.repo.Open() if err != nil { return common.ErrorMsg(err) } count, err := r.CountCommits(l.ref) if err != nil { l.common.Logger.Debugf("ui: error counting commits: %v", err) return common.ErrorMsg(err) } return LogCountMsg(count) } func (l *Log) updateCommitsCmd() tea.Msg { if l.ref == nil { return nil } r, err := l.repo.Open() if err != nil { return common.ErrorMsg(err) } count := l.count if count == 0 { return LogItemsMsg([]selector.IdentifiableItem{}) } page := l.nextPage limit := l.selector.PerPage() skip := page * limit ref := l.ref items := make([]selector.IdentifiableItem, count) // CommitsByPage pages start at 1 cc, err := r.CommitsByPage(ref, page+1, limit) if err != nil { l.common.Logger.Debugf("ui: error loading commits: %v", err) return common.ErrorMsg(err) } for i, c := range cc { idx := i + skip if int64(idx) >= count { break } items[idx] = LogItem{Commit: c} } return LogItemsMsg(items) } func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd { return func() tea.Msg { return LogCommitMsg(commit) } } func (l *Log) loadDiffCmd() tea.Msg { if l.selectedCommit == nil { return nil } r, err := l.repo.Open() if err != nil { l.common.Logger.Debugf("ui: error loading diff repository: %v", err) return common.ErrorMsg(err) } diff, err := r.Diff(l.selectedCommit) if err != nil { l.common.Logger.Debugf("ui: error loading diff: %v", err) return common.ErrorMsg(err) } return LogDiffMsg(diff) } func (l *Log) renderCommit(c *git.Commit) string { s := strings.Builder{} // FIXME: lipgloss prints empty lines when CRLF is used // sanitize commit message from CRLF msg := strings.ReplaceAll(c.Message, "\r\n", "\n") s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()), l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)), l.common.Styles.Log.CommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)), l.common.Styles.Log.CommitBody.Render(msg), )) return wrap.String(s.String(), l.common.Width-2) } func renderSummary(diff *git.Diff, styles *styles.Styles, width int) string { stats := strings.Split(diff.Stats().String(), "\n") for i, line := range stats { ch := strings.Split(line, "|") if len(ch) > 1 { adddel := ch[len(ch)-1] adddel = strings.ReplaceAll(adddel, "+", styles.Log.CommitStatsAdd.Render("+")) adddel = strings.ReplaceAll(adddel, "-", styles.Log.CommitStatsDel.Render("-")) stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel } } return wrap.String(strings.Join(stats, "\n"), width-2) } func renderDiff(diff *git.Diff, width int) string { var s strings.Builder var pr strings.Builder diffChroma := &gansi.CodeBlockElement{ Code: diff.Patch(), Language: "diff", } err := diffChroma.Render(&pr, common.StyleRenderer()) if err != nil { s.WriteString(fmt.Sprintf("\n%s", err.Error())) } else { s.WriteString(fmt.Sprintf("\n%s", pr.String())) } return wrap.String(s.String(), width) } func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd { return func() tea.Msg { return LogItemsMsg(items) } } ================================================ FILE: pkg/ui/pages/repo/logitem.go ================================================ package repo import ( "fmt" "io" "strings" "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/muesli/reflow/truncate" ) // LogItem is a item in the log list that displays a git commit. type LogItem struct { *git.Commit } // ID implements selector.IdentifiableItem. func (i LogItem) ID() string { return i.Hash() } // Hash returns the commit hash. func (i LogItem) Hash() string { return i.Commit.ID.String() } // Title returns the item title. Implements list.DefaultItem. func (i LogItem) Title() string { if i.Commit != nil { return strings.Split(i.Commit.Message, "\n")[0] } return "" } // Description returns the item description. Implements list.DefaultItem. func (i LogItem) Description() string { return "" } // FilterValue implements list.Item. func (i LogItem) FilterValue() string { return i.Title() } // LogItemDelegate is the delegate for LogItem. type LogItemDelegate struct { common *common.Common } // Height returns the item height. Implements list.ItemDelegate. func (d LogItemDelegate) Height() int { return 2 } // Spacing returns the item spacing. Implements list.ItemDelegate. func (d LogItemDelegate) Spacing() int { return 1 } // Update updates the item. Implements list.ItemDelegate. func (d LogItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { item, ok := m.SelectedItem().(LogItem) if !ok { return nil } switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, d.common.KeyMap.Copy): return copyCmd(item.Hash(), "Commit hash copied to clipboard") } } return nil } // Render renders the item. Implements list.ItemDelegate. func (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { i, ok := listItem.(LogItem) if !ok { return } if i.Commit == nil { return } styles := d.common.Styles.LogItem.Normal if index == m.Index() { styles = d.common.Styles.LogItem.Active } horizontalFrameSize := styles.Base.GetHorizontalFrameSize() hash := i.Commit.ID.String()[:7] title := styles.Title.Render( common.TruncateString(i.Title(), m.Width()- horizontalFrameSize- // 9 is the length of the hash (7) + the left padding (1) + the // title truncation symbol (1) 9), ) hashStyle := styles.Hash. Align(lipgloss.Right). PaddingLeft(1). Width(m.Width() - horizontalFrameSize - lipgloss.Width(title) - 1) // 1 is for the left padding if index == m.Index() { hashStyle = hashStyle.Bold(true) } hash = hashStyle.Render(hash) if m.Width()-horizontalFrameSize-hashStyle.GetHorizontalFrameSize()-hashStyle.GetWidth() <= 0 { hash = "" title = styles.Title.Render( common.TruncateString(i.Title(), m.Width()-horizontalFrameSize), ) } author := i.Author.Name committer := i.Committer.Name who := "" if author != "" && committer != "" { who = styles.Keyword.Render(committer) + styles.Desc.Render(" committed") if author != committer { who = styles.Keyword.Render(author) + styles.Desc.Render(" authored and ") + who } who += " " } date := i.Committer.When.Format("Jan 02") if i.Committer.When.Year() != time.Now().Year() { date += fmt.Sprintf(" %d", i.Committer.When.Year()) } who += styles.Desc.Render("on ") + styles.Keyword.Render(date) who = common.TruncateString(who, m.Width()-horizontalFrameSize) fmt.Fprint(w, //nolint:errcheck d.common.Zone.Mark( i.ID(), styles.Base.Render( lipgloss.JoinVertical(lipgloss.Left, truncate.String(fmt.Sprintf("%s%s", title, hash, ), uint(m.Width()-horizontalFrameSize)), //nolint:gosec who, ), ), ), ) } ================================================ FILE: pkg/ui/pages/repo/readme.go ================================================ package repo import ( "path/filepath" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/code" ) // ReadmeMsg is a message sent when the readme is loaded. type ReadmeMsg struct { Content string Path string } // Readme is the readme component page. type Readme struct { common common.Common code *code.Code ref RefMsg repo proto.Repository readmePath string spinner spinner.Model isLoading bool } // NewReadme creates a new readme model. func NewReadme(common common.Common) *Readme { readme := code.New(common, "", "") readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.") readme.UseGlamour = true s := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(common.Styles.Spinner)) return &Readme{ code: readme, common: common, spinner: s, isLoading: true, } } // Path implements common.TabComponent. func (r *Readme) Path() string { return "" } // TabName returns the name of the tab. func (r *Readme) TabName() string { return "Readme" } // SetSize implements common.Component. func (r *Readme) SetSize(width, height int) { r.common.SetSize(width, height) r.code.SetSize(width, height) } // ShortHelp implements help.KeyMap. func (r *Readme) ShortHelp() []key.Binding { b := []key.Binding{ r.common.KeyMap.UpDown, } return b } // FullHelp implements help.KeyMap. func (r *Readme) FullHelp() [][]key.Binding { k := r.code.KeyMap b := [][]key.Binding{ { k.PageDown, k.PageUp, k.HalfPageDown, k.HalfPageUp, }, { k.Down, k.Up, r.common.KeyMap.GotoTop, r.common.KeyMap.GotoBottom, }, } return b } // Init implements tea.Model. func (r *Readme) Init() tea.Cmd { r.isLoading = true return tea.Batch(r.spinner.Tick, r.updateReadmeCmd) } // Update implements tea.Model. func (r *Readme) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: r.repo = msg case RefMsg: r.ref = msg cmds = append(cmds, r.Init()) case tea.WindowSizeMsg: r.SetSize(msg.Width, msg.Height) case EmptyRepoMsg: cmds = append(cmds, r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(), r.repo.Name()), ".md"), ) case ReadmeMsg: r.isLoading = false r.readmePath = msg.Path r.code.GotoTop() cmds = append(cmds, r.code.SetContent(msg.Content, msg.Path)) case spinner.TickMsg: if r.isLoading && r.spinner.ID() == msg.ID { s, cmd := r.spinner.Update(msg) r.spinner = s if cmd != nil { cmds = append(cmds, cmd) } } } c, cmd := r.code.Update(msg) r.code = c.(*code.Code) if cmd != nil { cmds = append(cmds, cmd) } return r, tea.Batch(cmds...) } // View implements tea.Model. func (r *Readme) View() string { if r.isLoading { return renderLoading(r.common, r.spinner) } return r.code.View() } // SpinnerID implements common.TabComponent. func (r *Readme) SpinnerID() int { return r.spinner.ID() } // StatusBarValue implements statusbar.StatusBar. func (r *Readme) StatusBarValue() string { dir := filepath.Dir(r.readmePath) if dir == "." || dir == "" { return " " } return dir } // StatusBarInfo implements statusbar.StatusBar. func (r *Readme) StatusBarInfo() string { return common.ScrollPercent(r.code.ScrollPosition()) } func (r *Readme) updateReadmeCmd() tea.Msg { m := ReadmeMsg{} if r.repo == nil { return common.ErrorMsg(common.ErrMissingRepo) } rm, rp, _ := backend.Readme(r.repo, r.ref) m.Content = rm m.Path = rp return m } ================================================ FILE: pkg/ui/pages/repo/refs.go ================================================ package repo import ( "fmt" "sort" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/selector" ) // RefMsg is a message that contains a git.Reference. type RefMsg *git.Reference // RefItemsMsg is a message that contains a list of RefItem. type RefItemsMsg struct { prefix string items []selector.IdentifiableItem } // Refs is a component that displays a list of references. type Refs struct { common common.Common selector *selector.Selector repo proto.Repository ref *git.Reference activeRef *git.Reference refPrefix string spinner spinner.Model isLoading bool } // NewRefs creates a new Refs component. func NewRefs(common common.Common, refPrefix string) *Refs { r := &Refs{ common: common, refPrefix: refPrefix, isLoading: true, } s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common}) s.SetShowFilter(false) s.SetShowHelp(false) s.SetShowPagination(false) s.SetShowStatusBar(false) s.SetShowTitle(false) s.SetFilteringEnabled(false) s.DisableQuitKeybindings() r.selector = s sp := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(common.Styles.Spinner)) r.spinner = sp return r } // Path implements common.TabComponent. func (r *Refs) Path() string { return "" } // TabName returns the name of the tab. func (r *Refs) TabName() string { switch r.refPrefix { case git.RefsHeads: return "Branches" case git.RefsTags: return "Tags" } return "Refs" } // SetSize implements common.Component. func (r *Refs) SetSize(width, height int) { r.common.SetSize(width, height) r.selector.SetSize(width, height) } // ShortHelp implements help.KeyMap. func (r *Refs) ShortHelp() []key.Binding { copyKey := r.common.KeyMap.Copy copyKey.SetHelp("c", "copy ref") k := r.selector.KeyMap return []key.Binding{ r.common.KeyMap.SelectItem, k.CursorUp, k.CursorDown, copyKey, } } // FullHelp implements help.KeyMap. func (r *Refs) FullHelp() [][]key.Binding { copyKey := r.common.KeyMap.Copy copyKey.SetHelp("c", "copy ref") k := r.selector.KeyMap return [][]key.Binding{ {r.common.KeyMap.SelectItem}, { k.CursorUp, k.CursorDown, k.NextPage, k.PrevPage, }, { k.GoToStart, k.GoToEnd, copyKey, }, } } // Init implements tea.Model. func (r *Refs) Init() tea.Cmd { r.isLoading = true return tea.Batch(r.spinner.Tick, r.updateItemsCmd) } // Update implements tea.Model. func (r *Refs) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: r.selector.Select(0) r.repo = msg case RefMsg: r.ref = msg cmds = append(cmds, r.Init()) case tea.WindowSizeMsg: r.SetSize(msg.Width, msg.Height) case RefItemsMsg: if r.refPrefix == msg.prefix { cmds = append(cmds, r.selector.SetItems(msg.items)) i := r.selector.SelectedItem() if i != nil { r.activeRef = i.(RefItem).Reference } r.isLoading = false } case selector.ActiveMsg: switch sel := msg.IdentifiableItem.(type) { case RefItem: r.activeRef = sel.Reference } case selector.SelectMsg: switch i := msg.IdentifiableItem.(type) { case RefItem: cmds = append(cmds, switchRefCmd(i.Reference), switchTabCmd(&Files{}), ) } case tea.KeyPressMsg: switch { case key.Matches(msg, r.common.KeyMap.SelectItem): cmds = append(cmds, r.selector.SelectItemCmd) } case EmptyRepoMsg: r.ref = nil cmds = append(cmds, r.setItems([]selector.IdentifiableItem{})) case spinner.TickMsg: if r.isLoading && r.spinner.ID() == msg.ID { s, cmd := r.spinner.Update(msg) if cmd != nil { cmds = append(cmds, cmd) } r.spinner = s } } m, cmd := r.selector.Update(msg) r.selector = m.(*selector.Selector) if cmd != nil { cmds = append(cmds, cmd) } return r, tea.Batch(cmds...) } // View implements tea.Model. func (r *Refs) View() string { if r.isLoading { return renderLoading(r.common, r.spinner) } return r.selector.View() } // SpinnerID implements common.TabComponent. func (r *Refs) SpinnerID() int { return r.spinner.ID() } // StatusBarValue implements statusbar.StatusBar. func (r *Refs) StatusBarValue() string { if r.activeRef == nil { return "" } return r.activeRef.Name().String() } // StatusBarInfo implements statusbar.StatusBar. func (r *Refs) StatusBarInfo() string { totalPages := r.selector.TotalPages() if totalPages <= 1 { return "p. 1/1" } return fmt.Sprintf("p. %d/%d", r.selector.Page()+1, totalPages) } func (r *Refs) updateItemsCmd() tea.Msg { its := make(RefItems, 0) rr, err := r.repo.Open() if err != nil { return common.ErrorMsg(err) } refs, err := rr.References() if err != nil { r.common.Logger.Debugf("ui: error getting references: %v", err) return common.ErrorMsg(err) } for _, ref := range refs { if strings.HasPrefix(ref.Name().String(), r.refPrefix) { refItem := RefItem{ Reference: ref, } if ref.IsTag() { refItem.Tag, _ = rr.Tag(ref.Name().Short()) if refItem.Tag != nil { refItem.Commit, _ = refItem.Tag.Commit() } } else { refItem.Commit, _ = rr.CatFileCommit(ref.ID) } its = append(its, refItem) } } sort.Sort(its) items := make([]selector.IdentifiableItem, len(its)) for i, it := range its { items[i] = it } return RefItemsMsg{ items: items, prefix: r.refPrefix, } } func (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd { return func() tea.Msg { return RefItemsMsg{ items: items, prefix: r.refPrefix, } } } func switchRefCmd(ref *git.Reference) tea.Cmd { return func() tea.Msg { return RefMsg(ref) } } // UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg. func UpdateRefCmd(repo proto.Repository) tea.Cmd { return func() tea.Msg { r, err := repo.Open() if err != nil { return common.ErrorMsg(err) } bs, _ := r.Branches() if len(bs) == 0 { return EmptyRepoMsg{} } ref, err := r.HEAD() if err != nil { return common.ErrorMsg(err) } return RefMsg(ref) } } ================================================ FILE: pkg/ui/pages/repo/refsitem.go ================================================ package repo import ( "fmt" "io" "strings" "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/dustin/go-humanize" "github.com/muesli/reflow/truncate" ) // RefItem is a git reference item. type RefItem struct { *git.Reference *git.Tag *git.Commit } // ID implements selector.IdentifiableItem. func (i RefItem) ID() string { return i.Reference.Name().String() } // Title implements list.DefaultItem. func (i RefItem) Title() string { return i.Reference.Name().Short() } // Description implements list.DefaultItem. func (i RefItem) Description() string { return "" } // Short returns the short name of the reference. func (i RefItem) Short() string { return i.Reference.Name().Short() } // FilterValue implements list.Item. func (i RefItem) FilterValue() string { return i.Short() } // RefItems is a list of git references. type RefItems []RefItem // Len implements sort.Interface. func (cl RefItems) Len() int { return len(cl) } // Swap implements sort.Interface. func (cl RefItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } // Less implements sort.Interface. func (cl RefItems) Less(i, j int) bool { if cl[i].Commit != nil && cl[j].Commit != nil { return cl[i].Commit.Author.When.After(cl[j].Commit.Author.When) } else if cl[i].Commit != nil && cl[j].Commit == nil { return true } return false } // RefItemDelegate is the delegate for the ref item. type RefItemDelegate struct { common *common.Common } // Height implements list.ItemDelegate. func (d RefItemDelegate) Height() int { return 1 } // Spacing implements list.ItemDelegate. func (d RefItemDelegate) Spacing() int { return 0 } // Update implements list.ItemDelegate. func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { item, ok := m.SelectedItem().(RefItem) if !ok { return nil } switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, d.common.KeyMap.Copy): return copyCmd(item.ID(), fmt.Sprintf("Reference %q copied to clipboard", item.ID())) } } return nil } // Render implements list.ItemDelegate. func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { i, ok := listItem.(RefItem) if !ok { return } isTag := i.Reference.IsTag() isActive := index == m.Index() s := d.common.Styles.Ref st := s.Normal selector := " " if isActive { st = s.Active selector = s.ItemSelector.String() } horizontalFrameSize := st.Base.GetHorizontalFrameSize() var itemSt lipgloss.Style if isTag && isActive { itemSt = st.ItemTag } else if isTag { itemSt = st.ItemTag } else if isActive { itemSt = st.Item } else { itemSt = st.Item } var sha string c := i.Commit if c != nil { sha = c.ID.String()[:7] } ref := i.Short() var desc string if isTag { if c != nil { date := c.Committer.When.Format("Jan 02") if c.Committer.When.Year() != time.Now().Year() { date += fmt.Sprintf(" %d", c.Committer.When.Year()) } desc += " " + st.ItemDesc.Render(date) } t := i.Tag if t != nil { msgSt := st.ItemDesc.Faint(false) msg := t.Message() nl := strings.Index(msg, "\n") if nl > 0 { msg = msg[:nl] } msg = strings.TrimSpace(msg) if msg != "" { msgMargin := m.Width() - horizontalFrameSize - lipgloss.Width(selector) - lipgloss.Width(ref) - lipgloss.Width(desc) - lipgloss.Width(sha) - 3 // 3 is for the paddings and truncation symbol if msgMargin >= 0 { msg = common.TruncateString(msg, msgMargin) desc = " " + msgSt.Render(msg) + desc } } } } else if c != nil { onMargin := m.Width() - horizontalFrameSize - lipgloss.Width(selector) - lipgloss.Width(ref) - lipgloss.Width(desc) - lipgloss.Width(sha) - 2 // 2 is for the padding and truncation symbol if onMargin >= 0 { on := common.TruncateString("updated "+humanize.Time(c.Committer.When), onMargin) desc += " " + st.ItemDesc.Render(on) } } var hash string ref = itemSt.Render(ref) hashMargin := m.Width() - horizontalFrameSize - lipgloss.Width(selector) - lipgloss.Width(ref) - lipgloss.Width(desc) - lipgloss.Width(sha) - 1 // 1 is for the left padding if hashMargin >= 0 { hash = strings.Repeat(" ", hashMargin) + st.ItemHash. Align(lipgloss.Right). PaddingLeft(1). Render(sha) } fmt.Fprint(w, //nolint:errcheck d.common.Zone.Mark( i.ID(), st.Base.Render( lipgloss.JoinHorizontal(lipgloss.Top, truncate.String(selector+ref+desc+hash, uint(m.Width()-horizontalFrameSize)), //nolint:gosec ), ), ), ) } ================================================ FILE: pkg/ui/pages/repo/repo.go ================================================ package repo import ( "fmt" "strings" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/footer" "github.com/charmbracelet/soft-serve/pkg/ui/components/selector" "github.com/charmbracelet/soft-serve/pkg/ui/components/statusbar" "github.com/charmbracelet/soft-serve/pkg/ui/components/tabs" ) type state int const ( loadingState state = iota readyState ) // EmptyRepoMsg is a message to indicate that the repository is empty. type EmptyRepoMsg struct{} // CopyURLMsg is a message to copy the URL of the current repository. type CopyURLMsg struct{} // RepoMsg is a message that contains a git.Repository. type RepoMsg proto.Repository //nolint:revive // GoBackMsg is a message to go back to the previous view. type GoBackMsg struct{} // CopyMsg is a message to indicate copied text. type CopyMsg struct { Text string Message string } // SwitchTabMsg is a message to switch tabs. type SwitchTabMsg common.TabComponent // Repo is a view for a git repository. type Repo struct { common common.Common selectedRepo proto.Repository activeTab int tabs *tabs.Tabs statusbar *statusbar.Model panes []common.TabComponent ref *git.Reference state state spinner spinner.Model panesReady []bool } // New returns a new Repo. func New(c common.Common, comps ...common.TabComponent) *Repo { sb := statusbar.New(c) ts := make([]string, 0) for _, c := range comps { ts = append(ts, c.TabName()) } c.Logger = c.Logger.WithPrefix("ui.repo") tb := tabs.New(c, ts) // Make sure the order matches the order of tab constants above. s := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(c.Styles.Spinner)) r := &Repo{ common: c, tabs: tb, statusbar: sb, panes: comps, state: loadingState, spinner: s, panesReady: make([]bool, len(comps)), } return r } func (r *Repo) getMargins() (int, int) { hh := lipgloss.Height(r.headerView()) hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() + hh + r.common.Styles.Repo.Header.GetVerticalFrameSize() + r.common.Styles.StatusBar.GetHeight() return 0, hm } // SetSize implements common.Component. func (r *Repo) SetSize(width, height int) { r.common.SetSize(width, height) _, hm := r.getMargins() r.tabs.SetSize(width, height-hm) r.statusbar.SetSize(width, height-hm) for _, p := range r.panes { p.SetSize(width, height-hm) } } // Path returns the current component path. func (r *Repo) Path() string { return r.panes[r.activeTab].Path() } func (r *Repo) commonHelp() []key.Binding { b := make([]key.Binding, 0) back := r.common.KeyMap.Back back.SetHelp("esc", "back to menu") tab := r.common.KeyMap.Section tab.SetHelp("tab", "switch tab") b = append(b, back) b = append(b, tab) return b } // ShortHelp implements help.KeyMap. func (r *Repo) ShortHelp() []key.Binding { b := r.commonHelp() b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...) return b } // FullHelp implements help.KeyMap. func (r *Repo) FullHelp() [][]key.Binding { b := make([][]key.Binding, 0) b = append(b, r.commonHelp()) b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...) return b } // Init implements tea.View. func (r *Repo) Init() tea.Cmd { r.state = loadingState r.activeTab = 0 return tea.Batch( r.tabs.Init(), r.statusbar.Init(), r.spinner.Tick, ) } // Update implements tea.Model. func (r *Repo) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: // Set the state to loading when we get a new repository. r.selectedRepo = msg cmds = append(cmds, r.Init(), // This will set the selected repo in each pane's model. r.updateModels(msg), ) case RefMsg: r.ref = msg cmds = append(cmds, r.updateModels(msg)) r.state = readyState case tabs.SelectTabMsg: r.activeTab = int(msg) t, cmd := r.tabs.Update(msg) r.tabs = t.(*tabs.Tabs) if cmd != nil { cmds = append(cmds, cmd) } case tabs.ActiveTabMsg: r.activeTab = int(msg) case tea.KeyPressMsg, tea.MouseClickMsg: t, cmd := r.tabs.Update(msg) r.tabs = t.(*tabs.Tabs) if cmd != nil { cmds = append(cmds, cmd) } if r.selectedRepo != nil { urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name()) cmd := r.common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name()) if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) { cmds = append(cmds, copyCmd(cmd, "Command copied to clipboard")) } } switch msg := msg.(type) { case tea.MouseClickMsg: switch msg.Button { case tea.MouseLeft: switch { case r.common.Zone.Get("repo-help").InBounds(msg): cmds = append(cmds, footer.ToggleFooterCmd) } case tea.MouseRight: switch { case r.common.Zone.Get("repo-main").InBounds(msg): cmds = append(cmds, goBackCmd) } } } switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, r.common.KeyMap.Back): cmds = append(cmds, goBackCmd) } } case CopyMsg: txt := msg.Text if cfg := r.common.Config(); cfg != nil { cmds = append(cmds, tea.SetClipboard(txt)) } r.statusbar.SetStatus("", msg.Message, "", "") case ReadmeMsg: cmds = append(cmds, r.updateTabComponent(&Readme{}, msg)) case FileItemsMsg, FileContentMsg: cmds = append(cmds, r.updateTabComponent(&Files{}, msg)) case LogItemsMsg, LogDiffMsg, LogCountMsg: cmds = append(cmds, r.updateTabComponent(&Log{}, msg)) case RefItemsMsg: cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg)) case StashListMsg, StashPatchMsg: cmds = append(cmds, r.updateTabComponent(&Stash{}, msg)) // We have two spinners, one is used to when loading the repository and the // other is used when loading the log. // Check if the spinner ID matches the spinner model. case spinner.TickMsg: if r.state == loadingState && r.spinner.ID() == msg.ID { s, cmd := r.spinner.Update(msg) r.spinner = s if cmd != nil { cmds = append(cmds, cmd) } } else { for i, c := range r.panes { if c.SpinnerID() == msg.ID { m, cmd := c.Update(msg) r.panes[i] = m.(common.TabComponent) if cmd != nil { cmds = append(cmds, cmd) } break } } } case tea.WindowSizeMsg: r.SetSize(msg.Width, msg.Height) cmds = append(cmds, r.updateModels(msg)) case EmptyRepoMsg: r.ref = nil r.state = readyState cmds = append(cmds, r.updateModels(msg)) case common.ErrorMsg: r.state = readyState case SwitchTabMsg: for i, c := range r.panes { if c.TabName() == msg.TabName() { cmds = append(cmds, tabs.SelectTabCmd(i)) break } } } active := r.panes[r.activeTab] m, cmd := active.Update(msg) r.panes[r.activeTab] = m.(common.TabComponent) if cmd != nil { cmds = append(cmds, cmd) } // Update the status bar on these events // Must come after we've updated the active tab switch msg.(type) { case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyPressMsg, tea.MouseClickMsg, tea.MouseWheelMsg, FileItemsMsg, FileContentMsg, FileBlameMsg, selector.ActiveMsg, LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg, StashListMsg, StashPatchMsg: r.setStatusBarInfo() } s, cmd := r.statusbar.Update(msg) r.statusbar = s.(*statusbar.Model) if cmd != nil { cmds = append(cmds, cmd) } return r, tea.Batch(cmds...) } // View implements tea.Model. func (r *Repo) View() string { wm, hm := r.getMargins() hm += r.common.Styles.Tabs.GetHeight() + r.common.Styles.Tabs.GetVerticalFrameSize() s := r.common.Styles.Repo.Base. Width(r.common.Width - wm). Height(r.common.Height - hm) mainStyle := r.common.Styles.Repo.Body. Height(r.common.Height - hm) var main string var statusbar string switch r.state { case loadingState: main = fmt.Sprintf("%s loading…", r.spinner.View()) case readyState: main = r.panes[r.activeTab].View() statusbar = r.statusbar.View() } main = r.common.Zone.Mark( "repo-main", mainStyle.Render(main), ) view := lipgloss.JoinVertical(lipgloss.Left, r.headerView(), r.tabs.View(), main, statusbar, ) return s.Render(view) } func (r *Repo) headerView() string { if r.selectedRepo == nil { return "" } truncate := lipgloss.NewStyle().MaxWidth(r.common.Width) header := r.selectedRepo.ProjectName() if header == "" { header = r.selectedRepo.Name() } header = r.common.Styles.Repo.HeaderName.Render(header) desc := strings.TrimSpace(r.selectedRepo.Description()) if desc != "" { header = lipgloss.JoinVertical(lipgloss.Left, header, r.common.Styles.Repo.HeaderDesc.Render(desc), ) } urlStyle := r.common.Styles.URLStyle. Width(r.common.Width - lipgloss.Width(header) - 1). Align(lipgloss.Right) var url string if cfg := r.common.Config(); cfg != nil { url = r.common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name()) } url = common.TruncateString(url, r.common.Width-lipgloss.Width(header)-1) url = r.common.Zone.Mark( fmt.Sprintf("%s-url", r.selectedRepo.Name()), urlStyle.Render(url), ) header = lipgloss.JoinHorizontal(lipgloss.Top, header, url) style := r.common.Styles.Repo.Header.Width(r.common.Width) return style.Render( truncate.Render(header), ) } func (r *Repo) setStatusBarInfo() { if r.selectedRepo == nil { return } active := r.panes[r.activeTab] key := r.selectedRepo.Name() value := active.StatusBarValue() info := active.StatusBarInfo() extra := "*" if r.ref != nil { extra += " " + r.ref.Name().Short() } r.statusbar.SetStatus(key, value, info, extra) } func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd { cmds := make([]tea.Cmd, 0) for i, b := range r.panes { if b.TabName() == c.TabName() { m, cmd := b.Update(msg) r.panes[i] = m.(common.TabComponent) if cmd != nil { cmds = append(cmds, cmd) } break } } return tea.Batch(cmds...) } func (r *Repo) updateModels(msg tea.Msg) tea.Cmd { cmds := make([]tea.Cmd, 0) for i, b := range r.panes { m, cmd := b.Update(msg) r.panes[i] = m.(common.TabComponent) if cmd != nil { cmds = append(cmds, cmd) } } return tea.Batch(cmds...) } func copyCmd(text, msg string) tea.Cmd { return func() tea.Msg { return CopyMsg{ Text: text, Message: msg, } } } func goBackCmd() tea.Msg { return GoBackMsg{} } func switchTabCmd(m common.TabComponent) tea.Cmd { return func() tea.Msg { return SwitchTabMsg(m) } } func renderLoading(c common.Common, s spinner.Model) string { msg := fmt.Sprintf("%s loading…", s.View()) return c.Styles.SpinnerContainer. Height(c.Height). Render(msg) } ================================================ FILE: pkg/ui/pages/repo/stash.go ================================================ package repo import ( "fmt" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" gitm "github.com/aymanbagabas/git-module" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/code" "github.com/charmbracelet/soft-serve/pkg/ui/components/selector" ) type stashState int const ( stashStateLoading stashState = iota stashStateList stashStatePatch ) // StashListMsg is a message sent when the stash list is loaded. type StashListMsg []*gitm.Stash // StashPatchMsg is a message sent when the stash patch is loaded. type StashPatchMsg struct{ *git.Diff } // Stash is the stash component page. type Stash struct { common common.Common code *code.Code ref RefMsg repo proto.Repository spinner spinner.Model list *selector.Selector state stashState currentPatch StashPatchMsg } // NewStash creates a new stash model. func NewStash(common common.Common) *Stash { code := code.New(common, "", "") s := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(common.Styles.Spinner)) selector := selector.New(common, []selector.IdentifiableItem{}, StashItemDelegate{&common}) selector.SetShowFilter(false) selector.SetShowHelp(false) selector.SetShowPagination(false) selector.SetShowStatusBar(false) selector.SetShowTitle(false) selector.SetFilteringEnabled(false) selector.DisableQuitKeybindings() selector.KeyMap.NextPage = common.KeyMap.NextPage selector.KeyMap.PrevPage = common.KeyMap.PrevPage return &Stash{ code: code, common: common, spinner: s, list: selector, } } // Path implements common.TabComponent. func (s *Stash) Path() string { return "" } // TabName returns the name of the tab. func (s *Stash) TabName() string { return "Stash" } // SetSize implements common.Component. func (s *Stash) SetSize(width, height int) { s.common.SetSize(width, height) s.code.SetSize(width, height) s.list.SetSize(width, height) } // ShortHelp implements help.KeyMap. func (s *Stash) ShortHelp() []key.Binding { return []key.Binding{ s.common.KeyMap.Select, s.common.KeyMap.Back, s.common.KeyMap.UpDown, } } // FullHelp implements help.KeyMap. func (s *Stash) FullHelp() [][]key.Binding { b := [][]key.Binding{ { s.common.KeyMap.Select, s.common.KeyMap.Back, s.common.KeyMap.Copy, }, { s.code.KeyMap.Down, s.code.KeyMap.Up, s.common.KeyMap.GotoTop, s.common.KeyMap.GotoBottom, }, } return b } // StatusBarValue implements common.Component. func (s *Stash) StatusBarValue() string { item, ok := s.list.SelectedItem().(StashItem) if !ok { return " " } idx := s.list.Index() return fmt.Sprintf("stash@{%d}: %s", idx, item.Title()) } // StatusBarInfo implements common.Component. func (s *Stash) StatusBarInfo() string { switch s.state { case stashStateList: totalPages := s.list.TotalPages() if totalPages <= 1 { return "p. 1/1" } return fmt.Sprintf("p. %d/%d", s.list.Page()+1, totalPages) case stashStatePatch: return common.ScrollPercent(s.code.ScrollPosition()) default: return "" } } // SpinnerID implements common.Component. func (s *Stash) SpinnerID() int { return s.spinner.ID() } // Init initializes the model. func (s *Stash) Init() tea.Cmd { s.state = stashStateLoading return tea.Batch(s.spinner.Tick, s.fetchStash) } // Update updates the model. func (s *Stash) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: s.repo = msg case RefMsg: s.ref = msg s.list.Select(0) cmds = append(cmds, s.Init()) case tea.WindowSizeMsg: s.SetSize(msg.Width, msg.Height) case spinner.TickMsg: if s.state == stashStateLoading && s.spinner.ID() == msg.ID { sp, cmd := s.spinner.Update(msg) s.spinner = sp if cmd != nil { cmds = append(cmds, cmd) } } case tea.KeyPressMsg: switch s.state { case stashStateList: switch { case key.Matches(msg, s.common.KeyMap.BackItem): cmds = append(cmds, goBackCmd) case key.Matches(msg, s.common.KeyMap.Copy): cmds = append(cmds, copyCmd(s.list.SelectedItem().(StashItem).Title(), "Stash message copied to clipboard")) } case stashStatePatch: switch { case key.Matches(msg, s.common.KeyMap.BackItem): cmds = append(cmds, goBackCmd) case key.Matches(msg, s.common.KeyMap.Copy): if s.currentPatch.Diff != nil { patch := s.currentPatch.Diff cmds = append(cmds, copyCmd(patch.Patch(), "Stash patch copied to clipboard")) } } } case StashListMsg: s.state = stashStateList items := make([]selector.IdentifiableItem, len(msg)) for i, stash := range msg { items[i] = StashItem{stash} } cmds = append(cmds, s.list.SetItems(items)) case StashPatchMsg: s.state = stashStatePatch s.currentPatch = msg if msg.Diff != nil { title := s.common.Styles.Stash.Title.Render(s.list.SelectedItem().(StashItem).Title()) content := lipgloss.JoinVertical(lipgloss.Left, title, "", renderSummary(msg.Diff, s.common.Styles, s.common.Width), renderDiff(msg.Diff, s.common.Width), ) cmds = append(cmds, s.code.SetContent(content, ".diff")) s.code.GotoTop() } case selector.SelectMsg: switch msg.IdentifiableItem.(type) { case StashItem: cmds = append(cmds, s.fetchStashPatch) } case GoBackMsg: if s.state == stashStateList { s.list.Select(0) } s.state = stashStateList } switch s.state { case stashStateList: l, cmd := s.list.Update(msg) s.list = l.(*selector.Selector) if cmd != nil { cmds = append(cmds, cmd) } case stashStatePatch: c, cmd := s.code.Update(msg) s.code = c.(*code.Code) if cmd != nil { cmds = append(cmds, cmd) } } return s, tea.Batch(cmds...) } // View returns the view. func (s *Stash) View() string { switch s.state { case stashStateLoading: return renderLoading(s.common, s.spinner) case stashStateList: return s.list.View() case stashStatePatch: return s.code.View() } return "" } func (s *Stash) fetchStash() tea.Msg { if s.repo == nil { return StashListMsg(nil) } r, err := s.repo.Open() if err != nil { return common.ErrorMsg(err) } stash, err := r.StashList() if err != nil { return common.ErrorMsg(err) } return StashListMsg(stash) } func (s *Stash) fetchStashPatch() tea.Msg { if s.repo == nil { return StashPatchMsg{nil} } r, err := s.repo.Open() if err != nil { return common.ErrorMsg(err) } diff, err := r.StashDiff(s.list.Index()) if err != nil { return common.ErrorMsg(err) } return StashPatchMsg{diff} } ================================================ FILE: pkg/ui/pages/repo/stashitem.go ================================================ package repo import ( "fmt" "io" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" gitm "github.com/aymanbagabas/git-module" "github.com/charmbracelet/soft-serve/pkg/ui/common" ) // StashItem represents a stash item. type StashItem struct{ *gitm.Stash } // ID returns the ID of the stash item. func (i StashItem) ID() string { return fmt.Sprintf("stash@{%d}", i.Index) } // Title returns the title of the stash item. func (i StashItem) Title() string { return i.Message } // Description returns the description of the stash item. func (i StashItem) Description() string { return "" } // FilterValue implements list.Item. func (i StashItem) FilterValue() string { return i.Title() } // StashItems is a list of stash items. type StashItems []StashItem // Len implements sort.Interface. func (cl StashItems) Len() int { return len(cl) } // Swap implements sort.Interface. func (cl StashItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } // Less implements sort.Interface. func (cl StashItems) Less(i, j int) bool { return cl[i].Index < cl[j].Index } // StashItemDelegate is a delegate for stash items. type StashItemDelegate struct { common *common.Common } // Height returns the height of the stash item list. Implements list.ItemDelegate. func (d StashItemDelegate) Height() int { return 1 } // Spacing implements list.ItemDelegate. func (d StashItemDelegate) Spacing() int { return 0 } // Update implements list.ItemDelegate. func (d StashItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { item, ok := m.SelectedItem().(StashItem) if !ok { return nil } switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, d.common.KeyMap.Copy): return copyCmd(item.Title(), fmt.Sprintf("Stash message %q copied to clipboard", item.Title())) } } return nil } // Render implements list.ItemDelegate. func (d StashItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { item, ok := listItem.(StashItem) if !ok { return } s := d.common.Styles.Stash st := s.Normal.Message selector := " " if index == m.Index() { selector = "> " st = s.Active.Message } selector = s.Selector.Render(selector) title := st.Render(item.Title()) fmt.Fprint(w, d.common.Zone.Mark( //nolint:errcheck item.ID(), common.TruncateString(fmt.Sprintf("%s%s", selector, title, ), m.Width()- s.Selector.GetWidth()- st.GetHorizontalFrameSize(), ), )) } ================================================ FILE: pkg/ui/pages/selection/item.go ================================================ package selection import ( "fmt" "io" "sort" "strings" "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/dustin/go-humanize" ) var _ sort.Interface = Items{} // Items is a list of Item. type Items []Item // Len implements sort.Interface. func (it Items) Len() int { return len(it) } // Less implements sort.Interface. func (it Items) Less(i int, j int) bool { if it[i].lastUpdate == nil && it[j].lastUpdate != nil { return false } if it[i].lastUpdate != nil && it[j].lastUpdate == nil { return true } if it[i].lastUpdate == nil && it[j].lastUpdate == nil { return it[i].repo.Name() < it[j].repo.Name() } return it[i].lastUpdate.After(*it[j].lastUpdate) } // Swap implements sort.Interface. func (it Items) Swap(i int, j int) { it[i], it[j] = it[j], it[i] } // Item represents a single item in the selector. type Item struct { repo proto.Repository lastUpdate *time.Time cmd string } // New creates a new Item. func NewItem(c common.Common, repo proto.Repository) (Item, error) { var lastUpdate *time.Time lu := repo.UpdatedAt() if !lu.IsZero() { lastUpdate = &lu } var cmd string if cfg := c.Config(); cfg != nil { cmd = c.CloneCmd(cfg.SSH.PublicURL, repo.Name()) } return Item{ repo: repo, lastUpdate: lastUpdate, cmd: cmd, }, nil } // ID implements selector.IdentifiableItem. func (i Item) ID() string { return i.repo.Name() } // Title returns the item title. Implements list.DefaultItem. func (i Item) Title() string { name := i.repo.ProjectName() if name == "" { name = i.repo.Name() } return name } // Description returns the item description. Implements list.DefaultItem. func (i Item) Description() string { return strings.TrimSpace(i.repo.Description()) } // FilterValue implements list.Item. func (i Item) FilterValue() string { return i.Title() } // Command returns the item Command view. func (i Item) Command() string { return i.cmd } // ItemDelegate is the delegate for the item. type ItemDelegate struct { common *common.Common activePane *pane copiedIdx int } // NewItemDelegate creates a new ItemDelegate. func NewItemDelegate(common *common.Common, activePane *pane) *ItemDelegate { return &ItemDelegate{ common: common, activePane: activePane, copiedIdx: -1, } } // Width returns the item width. func (d ItemDelegate) Width() int { width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth() return width } // Height returns the item height. Implements list.ItemDelegate. func (d *ItemDelegate) Height() int { height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight() return height } // Spacing returns the spacing between items. Implements list.ItemDelegate. func (d *ItemDelegate) Spacing() int { return 1 } // Update implements list.ItemDelegate. func (d *ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { idx := m.Index() item, ok := m.SelectedItem().(Item) if !ok { return nil } switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, d.common.KeyMap.Copy): d.copiedIdx = idx return tea.Batch( tea.SetClipboard(item.Command()), m.SetItem(idx, item), ) } } return nil } // Render implements list.ItemDelegate. func (d *ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { i := listItem.(Item) s := strings.Builder{} var matchedRunes []int // Conditions var ( isSelected = index == m.Index() isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied ) styles := d.common.Styles.RepoSelector.Normal if isSelected { styles = d.common.Styles.RepoSelector.Active } title := i.Title() title = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize()) if i.repo.IsPrivate() { title += " 🔒" } if isSelected { title += " " } var updatedStr string if i.lastUpdate != nil { updatedStr = fmt.Sprintf(" Updated %s", humanize.Time(*i.lastUpdate)) } if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 { updatedStr = "" } updatedStyle := styles.Updated. Align(lipgloss.Right). Width(m.Width() - styles.Base.GetHorizontalFrameSize() - lipgloss.Width(title)) updated := updatedStyle.Render(updatedStr) if isFiltered && index < len(m.VisibleItems()) { // Get indices of matched characters matchedRunes = m.MatchesForItem(index) } if isFiltered { unmatched := styles.Title.Inline(true) matched := unmatched.Underline(true) title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) } title = styles.Title.Render(title) desc := i.Description() desc = common.TruncateString(desc, m.Width()-styles.Base.GetHorizontalFrameSize()) desc = styles.Desc.Render(desc) s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated)) s.WriteRune('\n') s.WriteString(desc) s.WriteRune('\n') cmd := i.Command() cmdStyler := styles.Command.Render if d.copiedIdx == index { cmd = "(copied to clipboard)" cmdStyler = styles.Desc.Render d.copiedIdx = -1 } cmd = common.TruncateString(cmd, m.Width()-styles.Base.GetHorizontalFrameSize()) s.WriteString(cmdStyler(cmd)) fmt.Fprint(w, //nolint:errcheck d.common.Zone.Mark(i.ID(), styles.Base.Render(s.String()), ), ) } ================================================ FILE: pkg/ui/pages/selection/selection.go ================================================ package selection import ( "fmt" "sort" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/code" "github.com/charmbracelet/soft-serve/pkg/ui/components/selector" "github.com/charmbracelet/soft-serve/pkg/ui/components/tabs" ) const ( defaultNoContent = "No readme found.\n\nCreate a `.soft-serve` repository and add a `README.md` file to display readme." ) type pane int const ( selectorPane pane = iota readmePane lastPane ) func (p pane) String() string { return []string{ "Repositories", "About", }[p] } // Selection is the model for the selection screen/page. type Selection struct { common common.Common readme *code.Code selector *selector.Selector activePane pane tabs *tabs.Tabs } // New creates a new selection model. func New(c common.Common) *Selection { ts := make([]string, lastPane) for i, b := range []pane{selectorPane, readmePane} { ts[i] = b.String() } t := tabs.New(c, ts) t.TabSeparator = lipgloss.NewStyle() t.TabInactive = c.Styles.TopLevelNormalTab t.TabActive = c.Styles.TopLevelActiveTab t.TabDot = c.Styles.TopLevelActiveTabDot t.UseDot = true sel := &Selection{ common: c, activePane: selectorPane, // start with the selector focused tabs: t, } readme := code.New(c, "", "") readme.UseGlamour = true readme.NoContentStyle = c.Styles.NoContent. SetString(defaultNoContent) selector := selector.New(c, []selector.IdentifiableItem{}, NewItemDelegate(&c, &sel.activePane)) selector.SetShowTitle(false) selector.SetShowHelp(false) selector.SetShowStatusBar(false) selector.DisableQuitKeybindings() sel.selector = selector sel.readme = readme return sel } func (s *Selection) getMargins() (wm, hm int) { wm = 0 hm = s.common.Styles.Tabs.GetVerticalFrameSize() + s.common.Styles.Tabs.GetHeight() if s.activePane == selectorPane && s.IsFiltering() { // hide tabs when filtering hm = 0 } return } // FilterState returns the current filter state. func (s *Selection) FilterState() list.FilterState { return s.selector.FilterState() } // SetSize implements common.Component. func (s *Selection) SetSize(width, height int) { s.common.SetSize(width, height) wm, hm := s.getMargins() s.tabs.SetSize(width, height-hm) s.selector.SetSize(width-wm, height-hm) s.readme.SetSize(width-wm, height-hm-1) // -1 for readme status line } // IsFiltering returns true if the selector is currently filtering. func (s *Selection) IsFiltering() bool { return s.FilterState() == list.Filtering } // ShortHelp implements help.KeyMap. func (s *Selection) ShortHelp() []key.Binding { k := s.selector.KeyMap kb := make([]key.Binding, 0) kb = append(kb, s.common.KeyMap.UpDown, s.common.KeyMap.Section, ) if s.activePane == selectorPane { copyKey := s.common.KeyMap.Copy copyKey.SetHelp("c", "copy command") kb = append(kb, s.common.KeyMap.Select, k.Filter, k.ClearFilter, copyKey, ) } return kb } // FullHelp implements help.KeyMap. func (s *Selection) FullHelp() [][]key.Binding { b := [][]key.Binding{ { s.common.KeyMap.Section, }, } switch s.activePane { case readmePane: k := s.readme.KeyMap b = append(b, []key.Binding{ k.PageDown, k.PageUp, }) b = append(b, []key.Binding{ k.HalfPageDown, k.HalfPageUp, }) b = append(b, []key.Binding{ k.Down, k.Up, }) case selectorPane: copyKey := s.common.KeyMap.Copy copyKey.SetHelp("c", "copy command") k := s.selector.KeyMap if !s.IsFiltering() { b[0] = append(b[0], s.common.KeyMap.Select, copyKey, ) } b = append(b, []key.Binding{ k.CursorUp, k.CursorDown, }) b = append(b, []key.Binding{ k.NextPage, k.PrevPage, k.GoToStart, k.GoToEnd, }) b = append(b, []key.Binding{ k.Filter, k.ClearFilter, k.CancelWhileFiltering, k.AcceptWhileFiltering, }) } return b } // Init implements tea.Model. func (s *Selection) Init() tea.Cmd { var readmeCmd tea.Cmd cfg := s.common.Config() if cfg == nil { return nil } ctx := s.common.Context() be := s.common.Backend() pk := s.common.PublicKey() if pk == nil && !be.AllowKeyless(ctx) { return nil } repos, err := be.Repositories(ctx) if err != nil { return common.ErrorCmd(err) } sortedItems := make(Items, 0) for _, r := range repos { if r.Name() == ".soft-serve" { readme, path, err := backend.Readme(r, nil) if err != nil { continue } readmeCmd = s.readme.SetContent(readme, path) } if r.IsHidden() { continue } al := be.AccessLevelByPublicKey(ctx, r.Name(), pk) if al >= access.ReadOnlyAccess { item, err := NewItem(s.common, r) if err != nil { s.common.Logger.Debugf("ui: failed to create item for %s: %v", r.Name(), err) continue } sortedItems = append(sortedItems, item) } } sort.Sort(sortedItems) items := make([]selector.IdentifiableItem, len(sortedItems)) for i, it := range sortedItems { items[i] = it } return tea.Batch( s.selector.Init(), s.selector.SetItems(items), readmeCmd, ) } // Update implements tea.Model. func (s *Selection) Update(msg tea.Msg) (common.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.WindowSizeMsg: r, cmd := s.readme.Update(msg) s.readme = r.(*code.Code) if cmd != nil { cmds = append(cmds, cmd) } m, cmd := s.selector.Update(msg) s.selector = m.(*selector.Selector) if cmd != nil { cmds = append(cmds, cmd) } case tea.KeyPressMsg, tea.MouseMsg: switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, s.common.KeyMap.Back): cmds = append(cmds, s.selector.Init()) } } t, cmd := s.tabs.Update(msg) s.tabs = t.(*tabs.Tabs) if cmd != nil { cmds = append(cmds, cmd) } case tabs.ActiveTabMsg: s.activePane = pane(msg) } switch s.activePane { case readmePane: r, cmd := s.readme.Update(msg) s.readme = r.(*code.Code) if cmd != nil { cmds = append(cmds, cmd) } case selectorPane: m, cmd := s.selector.Update(msg) s.selector = m.(*selector.Selector) if cmd != nil { cmds = append(cmds, cmd) } } return s, tea.Batch(cmds...) } // View implements tea.Model. func (s *Selection) View() string { var view string wm, hm := s.getMargins() switch s.activePane { case selectorPane: ss := lipgloss.NewStyle(). Width(s.common.Width - wm). Height(s.common.Height - hm) view = ss.Render(s.selector.View()) case readmePane: rs := lipgloss.NewStyle(). Height(s.common.Height - hm) status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100) readmeStatus := lipgloss.NewStyle(). Align(lipgloss.Right). Width(s.common.Width - wm). Foreground(s.common.Styles.InactiveBorderColor). Render(status) view = rs.Render(lipgloss.JoinVertical(lipgloss.Left, s.readme.View(), readmeStatus, )) } if s.activePane != selectorPane || s.FilterState() != list.Filtering { tabs := s.common.Styles.Tabs.Render(s.tabs.View()) view = lipgloss.JoinVertical(lipgloss.Left, tabs, view, ) } return lipgloss.JoinVertical( lipgloss.Left, view, ) } ================================================ FILE: pkg/ui/styles/styles.go ================================================ package styles import ( "image/color" "charm.land/lipgloss/v2" ) // XXX: For now, this is in its own package so that it can be shared between // different packages without incurring an illegal import cycle. // Styles defines styles for the UI. type Styles struct { ActiveBorderColor color.Color InactiveBorderColor color.Color App lipgloss.Style ServerName lipgloss.Style TopLevelNormalTab lipgloss.Style TopLevelActiveTab lipgloss.Style TopLevelActiveTabDot lipgloss.Style MenuItem lipgloss.Style MenuLastUpdate lipgloss.Style RepoSelector struct { Normal struct { Base lipgloss.Style Title lipgloss.Style Desc lipgloss.Style Command lipgloss.Style Updated lipgloss.Style } Active struct { Base lipgloss.Style Title lipgloss.Style Desc lipgloss.Style Command lipgloss.Style Updated lipgloss.Style } } Repo struct { Base lipgloss.Style Title lipgloss.Style Command lipgloss.Style Body lipgloss.Style Header lipgloss.Style HeaderName lipgloss.Style HeaderDesc lipgloss.Style } Footer lipgloss.Style Branch lipgloss.Style HelpKey lipgloss.Style HelpValue lipgloss.Style HelpDivider lipgloss.Style URLStyle lipgloss.Style Error lipgloss.Style ErrorTitle lipgloss.Style ErrorBody lipgloss.Style LogItem struct { Normal struct { Base lipgloss.Style Hash lipgloss.Style Title lipgloss.Style Desc lipgloss.Style Keyword lipgloss.Style } Active struct { Base lipgloss.Style Hash lipgloss.Style Title lipgloss.Style Desc lipgloss.Style Keyword lipgloss.Style } } Log struct { Commit lipgloss.Style CommitHash lipgloss.Style CommitAuthor lipgloss.Style CommitDate lipgloss.Style CommitBody lipgloss.Style CommitStatsAdd lipgloss.Style CommitStatsDel lipgloss.Style Paginator lipgloss.Style } Ref struct { Normal struct { Base lipgloss.Style Item lipgloss.Style ItemTag lipgloss.Style ItemDesc lipgloss.Style ItemHash lipgloss.Style } Active struct { Base lipgloss.Style Item lipgloss.Style ItemTag lipgloss.Style ItemDesc lipgloss.Style ItemHash lipgloss.Style } ItemSelector lipgloss.Style Paginator lipgloss.Style Selector lipgloss.Style } Tree struct { Normal struct { FileName lipgloss.Style FileDir lipgloss.Style FileMode lipgloss.Style FileSize lipgloss.Style } Active struct { FileName lipgloss.Style FileDir lipgloss.Style FileMode lipgloss.Style FileSize lipgloss.Style } Selector lipgloss.Style FileContent lipgloss.Style Paginator lipgloss.Style Blame struct { Hash lipgloss.Style Message lipgloss.Style Who lipgloss.Style } } Stash struct { Normal struct { Message lipgloss.Style } Active struct { Message lipgloss.Style } Title lipgloss.Style Selector lipgloss.Style } Spinner lipgloss.Style SpinnerContainer lipgloss.Style NoContent lipgloss.Style StatusBar lipgloss.Style StatusBarKey lipgloss.Style StatusBarValue lipgloss.Style StatusBarInfo lipgloss.Style StatusBarBranch lipgloss.Style StatusBarHelp lipgloss.Style Tabs lipgloss.Style TabInactive lipgloss.Style TabActive lipgloss.Style TabSeparator lipgloss.Style Code struct { LineDigit lipgloss.Style LineBar lipgloss.Style } } // DefaultStyles returns default styles for the UI. func DefaultStyles() *Styles { highlightColor := lipgloss.Color("210") highlightColorDim := lipgloss.Color("174") selectorColor := lipgloss.Color("167") hashColor := lipgloss.Color("185") s := new(Styles) s.ActiveBorderColor = lipgloss.Color("62") s.InactiveBorderColor = lipgloss.Color("241") s.App = lipgloss.NewStyle(). Margin(1, 2) s.ServerName = lipgloss.NewStyle(). Height(1). MarginLeft(1). MarginBottom(1). Padding(0, 1). Background(lipgloss.Color("57")). Foreground(lipgloss.Color("229")). Bold(true) s.TopLevelNormalTab = lipgloss.NewStyle(). MarginRight(2) s.TopLevelActiveTab = s.TopLevelNormalTab. Foreground(lipgloss.Color("36")) s.TopLevelActiveTabDot = lipgloss.NewStyle(). Foreground(lipgloss.Color("36")) s.RepoSelector.Normal.Base = lipgloss.NewStyle(). PaddingLeft(1). Border(lipgloss.Border{Left: " "}, false, false, false, true). Height(3) s.RepoSelector.Normal.Title = lipgloss.NewStyle().Bold(true) s.RepoSelector.Normal.Desc = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")) s.RepoSelector.Normal.Command = lipgloss.NewStyle(). Foreground(lipgloss.Color("132")) s.RepoSelector.Normal.Updated = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")) s.RepoSelector.Active.Base = s.RepoSelector.Normal.Base. BorderStyle(lipgloss.Border{Left: "┃"}). BorderForeground(lipgloss.Color("176")) s.RepoSelector.Active.Title = s.RepoSelector.Normal.Title. Foreground(lipgloss.Color("212")) s.RepoSelector.Active.Desc = s.RepoSelector.Normal.Desc. Foreground(lipgloss.Color("246")) s.RepoSelector.Active.Updated = s.RepoSelector.Normal.Updated. Foreground(lipgloss.Color("212")) s.RepoSelector.Active.Command = s.RepoSelector.Normal.Command. Foreground(lipgloss.Color("204")) s.MenuItem = lipgloss.NewStyle(). PaddingLeft(1). Border(lipgloss.Border{ Left: " ", }, false, false, false, true). Height(3) s.MenuLastUpdate = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). Align(lipgloss.Right) s.Repo.Base = lipgloss.NewStyle() s.Repo.Title = lipgloss.NewStyle(). Padding(0, 2) s.Repo.Command = lipgloss.NewStyle(). Foreground(lipgloss.Color("168")) s.Repo.Body = lipgloss.NewStyle(). Margin(1, 0) s.Repo.Header = lipgloss.NewStyle(). MaxHeight(2). Border(lipgloss.NormalBorder(), false, false, true, false). BorderForeground(lipgloss.Color("236")) s.Repo.HeaderName = lipgloss.NewStyle(). Foreground(lipgloss.Color("212")). Bold(true) s.Repo.HeaderDesc = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")) s.Footer = lipgloss.NewStyle(). MarginTop(1). Padding(0, 1). Height(1) s.Branch = lipgloss.NewStyle(). Foreground(lipgloss.Color("203")). Background(lipgloss.Color("236")). Padding(0, 1) s.HelpKey = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")) s.HelpValue = lipgloss.NewStyle(). Foreground(lipgloss.Color("239")) s.HelpDivider = lipgloss.NewStyle(). Foreground(lipgloss.Color("237")). SetString(" • ") s.URLStyle = lipgloss.NewStyle(). MarginLeft(1). Foreground(lipgloss.Color("168")) s.Error = lipgloss.NewStyle(). MarginTop(2) s.ErrorTitle = lipgloss.NewStyle(). Foreground(lipgloss.Color("230")). Background(lipgloss.Color("204")). Bold(true). Padding(0, 1) s.ErrorBody = lipgloss.NewStyle(). Foreground(lipgloss.Color("252")). MarginLeft(2) s.LogItem.Normal.Base = lipgloss.NewStyle(). Border(lipgloss.Border{ Left: " ", }, false, false, false, true). PaddingLeft(1) s.LogItem.Active.Base = s.LogItem.Normal.Base. Border(lipgloss.Border{ Left: "┃", }, false, false, false, true). BorderForeground(selectorColor) s.LogItem.Active.Hash = s.LogItem.Normal.Hash. Foreground(hashColor) s.LogItem.Active.Hash = lipgloss.NewStyle(). Bold(true). Foreground(highlightColor) s.LogItem.Normal.Title = lipgloss.NewStyle(). Foreground(lipgloss.Color("105")) s.LogItem.Active.Title = lipgloss.NewStyle(). Foreground(highlightColor). Bold(true) s.LogItem.Normal.Desc = lipgloss.NewStyle(). Foreground(lipgloss.Color("246")) s.LogItem.Active.Desc = lipgloss.NewStyle(). Foreground(lipgloss.Color("95")) s.LogItem.Active.Keyword = s.LogItem.Active.Desc. Foreground(highlightColorDim) s.LogItem.Normal.Hash = lipgloss.NewStyle(). Foreground(hashColor) s.LogItem.Active.Hash = lipgloss.NewStyle(). Foreground(highlightColor) s.Log.Commit = lipgloss.NewStyle(). Margin(0, 2) s.Log.CommitHash = lipgloss.NewStyle(). Foreground(hashColor). Bold(true) s.Log.CommitBody = lipgloss.NewStyle(). MarginTop(1). MarginLeft(2) s.Log.CommitStatsAdd = lipgloss.NewStyle(). Foreground(lipgloss.Color("42")). Bold(true) s.Log.CommitStatsDel = lipgloss.NewStyle(). Foreground(lipgloss.Color("203")). Bold(true) s.Log.Paginator = lipgloss.NewStyle(). Margin(0). Align(lipgloss.Center) s.Ref.Normal.Item = lipgloss.NewStyle() s.Ref.ItemSelector = lipgloss.NewStyle(). Foreground(selectorColor). SetString("> ") s.Ref.Active.Item = lipgloss.NewStyle(). Foreground(highlightColorDim) s.Ref.Normal.Base = lipgloss.NewStyle() s.Ref.Active.Base = lipgloss.NewStyle() s.Ref.Normal.ItemTag = lipgloss.NewStyle(). Foreground(lipgloss.Color("39")) s.Ref.Active.ItemTag = lipgloss.NewStyle(). Bold(true). Foreground(highlightColor) s.Ref.Active.Item = lipgloss.NewStyle(). Bold(true). Foreground(highlightColor) s.Ref.Normal.ItemDesc = lipgloss.NewStyle(). Faint(true) s.Ref.Active.ItemDesc = lipgloss.NewStyle(). Foreground(highlightColor). Faint(true) s.Ref.Normal.ItemHash = lipgloss.NewStyle(). Foreground(hashColor). Bold(true) s.Ref.Active.ItemHash = lipgloss.NewStyle(). Foreground(highlightColor). Bold(true) s.Ref.Paginator = s.Log.Paginator s.Ref.Selector = lipgloss.NewStyle() s.Tree.Selector = s.Tree.Normal.FileName. Width(1). Foreground(selectorColor) s.Tree.Normal.FileName = lipgloss.NewStyle(). MarginLeft(1) s.Tree.Active.FileName = s.Tree.Normal.FileName. Bold(true). Foreground(highlightColor) s.Tree.Normal.FileDir = lipgloss.NewStyle(). Foreground(lipgloss.Color("39")) s.Tree.Active.FileDir = lipgloss.NewStyle(). Foreground(highlightColor) s.Tree.Normal.FileMode = s.Tree.Active.FileName. Width(10). Foreground(lipgloss.Color("243")) s.Tree.Active.FileMode = s.Tree.Normal.FileMode. Foreground(highlightColorDim) s.Tree.Normal.FileSize = s.Tree.Normal.FileName. Foreground(lipgloss.Color("243")) s.Tree.Active.FileSize = s.Tree.Normal.FileName. Foreground(highlightColorDim) s.Tree.FileContent = lipgloss.NewStyle() s.Tree.Paginator = s.Log.Paginator s.Tree.Blame.Hash = lipgloss.NewStyle(). Foreground(hashColor). Bold(true) s.Tree.Blame.Message = lipgloss.NewStyle() s.Tree.Blame.Who = lipgloss.NewStyle(). Faint(true) s.Spinner = lipgloss.NewStyle(). MarginTop(1). MarginLeft(2). Foreground(lipgloss.Color("205")) s.SpinnerContainer = lipgloss.NewStyle() s.NoContent = lipgloss.NewStyle(). MarginTop(1). MarginLeft(2). Foreground(lipgloss.Color("242")) s.StatusBar = lipgloss.NewStyle(). Height(1) s.StatusBarKey = lipgloss.NewStyle(). Bold(true). Padding(0, 1). Background(lipgloss.Color("206")). Foreground(lipgloss.Color("228")) s.StatusBarValue = lipgloss.NewStyle(). Padding(0, 1). Background(lipgloss.Color("235")). Foreground(lipgloss.Color("243")) s.StatusBarInfo = lipgloss.NewStyle(). Padding(0, 1). Background(lipgloss.Color("212")). Foreground(lipgloss.Color("230")) s.StatusBarBranch = lipgloss.NewStyle(). Padding(0, 1). Background(lipgloss.Color("62")). Foreground(lipgloss.Color("230")) s.StatusBarHelp = lipgloss.NewStyle(). Padding(0, 1). Background(lipgloss.Color("237")). Foreground(lipgloss.Color("243")) s.Tabs = lipgloss.NewStyle(). Height(1) s.TabInactive = lipgloss.NewStyle() s.TabActive = lipgloss.NewStyle(). Underline(true). Foreground(lipgloss.Color("36")) s.TabSeparator = lipgloss.NewStyle(). SetString("│"). Padding(0, 1). Foreground(lipgloss.Color("238")) s.Code.LineDigit = lipgloss.NewStyle().Foreground(lipgloss.Color("239")) s.Code.LineBar = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) s.Stash.Normal.Message = lipgloss.NewStyle().MarginLeft(1) s.Stash.Active.Message = s.Stash.Normal.Message.Foreground(selectorColor) s.Stash.Title = lipgloss.NewStyle(). Foreground(hashColor). Bold(true) s.Stash.Selector = lipgloss.NewStyle(). Width(1). Foreground(selectorColor) return s } ================================================ FILE: pkg/utils/utils.go ================================================ package utils import ( "fmt" "path" "strings" "unicode" "github.com/charmbracelet/x/ansi" ) // SanitizeRepo returns a sanitized version of the given repository name. func SanitizeRepo(repo string) string { repo = Sanitize(repo) // We need to use an absolute path for the path to be cleaned correctly. repo = strings.TrimPrefix(repo, "/") repo = "/" + repo // We're using path instead of filepath here because this is not OS dependent // looking at you Windows repo = path.Clean(repo) repo = strings.TrimSuffix(repo, ".git") return repo[1:] } // Sanitize strips ANSI escape codes from the given string. func Sanitize(s string) string { return ansi.Strip(s) } // ValidateUsername returns an error if any of the given usernames are invalid. func ValidateUsername(username string) error { if username == "" { return fmt.Errorf("username cannot be empty") } if !unicode.IsLetter(rune(username[0])) { return fmt.Errorf("username must start with a letter") } for _, r := range username { if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' { return fmt.Errorf("username can only contain letters, numbers, and hyphens") } } return nil } // ValidateRepo returns an error if the given repository name is invalid. func ValidateRepo(repo string) error { if repo == "" { return fmt.Errorf("repo cannot be empty") } for _, r := range repo { if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' && r != '.' && r != '/' { return fmt.Errorf("repo can only contain letters, numbers, hyphens, underscores, periods, and slashes") } } return nil } ================================================ FILE: pkg/utils/utils_test.go ================================================ package utils import "testing" func TestValidateRepo(t *testing.T) { t.Run("valid", func(t *testing.T) { for _, repo := range []string{ "lower", "Upper", "with-dash", "with/slash", "withnumb3r5", "with.dot", "with_underline", } { t.Run(repo, func(t *testing.T) { if err := ValidateRepo(repo); err != nil { t.Errorf("expected no error, got %v", err) } }) } }) t.Run("invalid", func(t *testing.T) { for _, repo := range []string{ "with$", "with@", "with!", } { t.Run(repo, func(t *testing.T) { if err := ValidateRepo(repo); err == nil { t.Error("expected an error, got nil") } }) } }) } func TestSanitizeRepo(t *testing.T) { cases := []struct { in, out string }{ {"lower", "lower"}, {"Upper", "Upper"}, {"with/slash", "with/slash"}, {"with.dot", "with.dot"}, {"/with_forward_slash", "with_forward_slash"}, {"withgitsuffix.git", "withgitsuffix"}, } for _, c := range cases { t.Run(c.in, func(t *testing.T) { if got := SanitizeRepo(c.in); got != c.out { t.Errorf("expected %q, got %q", c.out, got) } }) } } ================================================ FILE: pkg/version/version.go ================================================ // Package version is used to store the version of the server during runtime. // The values are set during runtime in the main package. package version var ( // Version is the version of the server. Version = "" // CommitSHA is the commit SHA of the server. CommitSHA = "" // CommitDate is the commit date of the server. CommitDate = "" ) ================================================ FILE: pkg/web/auth.go ================================================ package web import ( "context" "errors" "fmt" "net/http" "strings" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/golang-jwt/jwt/v5" ) // authenticate authenticates the user from the request. func authenticate(r *http.Request) (proto.User, error) { // Prefer the Authorization header user, err := parseAuthHdr(r) if err != nil || user == nil { if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) { return nil, err } return nil, proto.ErrUserNotFound } return user, nil } // ErrInvalidPassword is returned when the password is invalid. var ErrInvalidPassword = errors.New("invalid password") func parseUsernamePassword(ctx context.Context, username, password string) (proto.User, error) { logger := log.FromContext(ctx) be := backend.FromContext(ctx) if username != "" && password != "" { user, err := be.User(ctx, username) if err == nil && user != nil && backend.VerifyPassword(password, user.Password()) { return user, nil } // Try to authenticate using access token as the password user, err = be.UserByAccessToken(ctx, password) if err == nil { return user, nil } logger.Error("invalid password or token", "username", username, "err", err) return nil, ErrInvalidPassword } else if username != "" { // Try to authenticate using access token as the username logger.Debug("trying to authenticate using access token as username", "username", username) user, err := be.UserByAccessToken(ctx, username) if err == nil { return user, nil } logger.Error("failed to get user", "err", err) return nil, ErrInvalidToken } return nil, proto.ErrUserNotFound } // ErrInvalidHeader is returned when the authorization header is invalid. var ErrInvalidHeader = errors.New("invalid authorization header") func parseAuthHdr(r *http.Request) (proto.User, error) { // Check for auth header header := r.Header.Get("Authorization") if header == "" { return nil, ErrInvalidHeader } ctx := r.Context() logger := log.FromContext(ctx).WithPrefix("http.auth") be := backend.FromContext(ctx) logger.Debug("authorization auth header", "header", header) parts := strings.SplitN(header, " ", 2) if len(parts) != 2 { return nil, errors.New("invalid authorization header") } switch strings.ToLower(parts[0]) { case "token": user, err := be.UserByAccessToken(ctx, parts[1]) if err != nil { logger.Error("failed to get user", "err", err) return nil, err } return user, nil case "bearer": claims, err := parseJWT(ctx, parts[1]) if err != nil { return nil, err } // Find the user parts := strings.SplitN(claims.Subject, "#", 2) if len(parts) != 2 { logger.Error("invalid jwt subject", "subject", claims.Subject) return nil, errors.New("invalid jwt subject") } user, err := be.User(ctx, parts[0]) if err != nil { logger.Error("failed to get user", "err", err) return nil, err } expectedSubject := fmt.Sprintf("%s#%d", user.Username(), user.ID()) if expectedSubject != claims.Subject { logger.Error("invalid jwt subject", "subject", claims.Subject, "expected", expectedSubject) return nil, errors.New("invalid jwt subject") } return user, nil default: username, password, ok := r.BasicAuth() if !ok { return nil, ErrInvalidHeader } return parseUsernamePassword(ctx, username, password) } } // ErrInvalidToken is returned when a token is invalid. var ErrInvalidToken = errors.New("invalid token") func parseJWT(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error) { cfg := config.FromContext(ctx) logger := log.FromContext(ctx).WithPrefix("http.auth") kp, err := config.KeyPair(cfg) if err != nil { return nil, err } repo := proto.RepositoryFromContext(ctx) if repo == nil { return nil, errors.New("missing repository") } token, err := jwt.ParseWithClaims(bearer, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok { return nil, errors.New("invalid signing method") } return kp.CryptoPublicKey(), nil }, jwt.WithIssuer(cfg.HTTP.PublicURL), jwt.WithIssuedAt(), jwt.WithAudience(repo.Name()), ) if err != nil { logger.Error("failed to parse jwt", "err", err) return nil, ErrInvalidToken } claims, ok := token.Claims.(*jwt.RegisteredClaims) if !token.Valid || !ok { return nil, ErrInvalidToken } return claims, nil } ================================================ FILE: pkg/web/context.go ================================================ package web import ( "context" "net/http" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/store" ) // NewContextHandler returns a new context middleware. // This middleware adds the config, backend, and logger to the request context. func NewContextHandler(ctx context.Context) func(http.Handler) http.Handler { cfg := config.FromContext(ctx) be := backend.FromContext(ctx) logger := log.FromContext(ctx).WithPrefix("http") dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ctx = config.WithContext(ctx, cfg) ctx = backend.WithContext(ctx, be) ctx = log.WithContext(ctx, logger.With( "method", r.Method, "path", r.URL, "addr", r.RemoteAddr, )) ctx = db.WithContext(ctx, dbx) ctx = store.WithContext(ctx, datastore) r = r.WithContext(ctx) next.ServeHTTP(w, r) }) } } ================================================ FILE: pkg/web/git.go ================================================ package web import ( "bytes" "compress/gzip" "context" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "charm.land/log/v2" gitb "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/git" "github.com/charmbracelet/soft-serve/pkg/lfs" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) // GitRoute is a route for git services. type GitRoute struct { method []string handler http.HandlerFunc path string } var _ http.Handler = GitRoute{} // ServeHTTP implements http.Handler. func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) { var hasMethod bool for _, m := range g.method { if m == r.Method { hasMethod = true break } } if !hasMethod { renderMethodNotAllowed(w, r) return } g.handler(w, r) } var ( //nolint:revive gitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "http", Name: "git_receive_pack_total", Help: "The total number of git push requests", }, []string{"repo"}) //nolint:revive gitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "http", Name: "git_upload_pack_total", Help: "The total number of git fetch/pull requests", }, []string{"repo", "file"}) ) func withParams(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cfg := config.FromContext(ctx) vars := mux.Vars(r) repo := vars["repo"] // Construct "file" param from path vars["file"] = strings.TrimPrefix(r.URL.Path, "/"+repo+"/") // Set service type switch { case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()): vars["service"] = git.UploadPackService.String() case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()): vars["service"] = git.ReceivePackService.String() } repo = utils.SanitizeRepo(repo) vars["repo"] = repo vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git") // Add repo suffix (.git) r.URL.Path = fmt.Sprintf("%s.git/%s", repo, vars["file"]) r = mux.SetURLVars(r, vars) next.ServeHTTP(w, r) }) } // GitController is a router for git services. func GitController(_ context.Context, r *mux.Router) { basePrefix := "/{repo:.*}" for _, route := range gitRoutes { // NOTE: withParam must always be the outermost wrapper, otherwise the // request vars will not be set. r.Handle(basePrefix+route.path, withParams(withAccess(route))) } // Handle go-get r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).Methods(http.MethodGet) } var gitRoutes = []GitRoute{ // Git services // These routes don't handle authentication/authorization. // This is handled through wrapping the handlers for each route. // See below (withAccess). { method: []string{http.MethodPost}, handler: serviceRpc, path: "/{service:(?:git-upload-archive|git-upload-pack|git-receive-pack)$}", }, { method: []string{http.MethodGet}, handler: getInfoRefs, path: "/info/refs", }, { method: []string{http.MethodGet}, handler: getTextFile, path: "/{_:(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$}", }, { method: []string{http.MethodGet}, handler: getInfoPacks, path: "/objects/info/packs", }, { method: []string{http.MethodGet}, handler: getLooseObject, path: "/objects/{_:[0-9a-f]{2}/[0-9a-f]{38}$}", }, { method: []string{http.MethodGet}, handler: getPackFile, path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.pack$}", }, { method: []string{http.MethodGet}, handler: getIdxFile, path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.idx$}", }, // Git LFS { method: []string{http.MethodPost}, handler: serviceLfsBatch, path: "/info/lfs/objects/batch", }, { // Git LFS basic object handler method: []string{http.MethodGet, http.MethodPut}, handler: serviceLfsBasic, path: "/info/lfs/objects/basic/{oid:[0-9a-f]{64}$}", }, { method: []string{http.MethodPost}, handler: serviceLfsBasicVerify, path: "/info/lfs/objects/basic/verify", }, // Git LFS locks { method: []string{http.MethodPost, http.MethodGet}, handler: serviceLfsLocks, path: "/info/lfs/locks", }, { method: []string{http.MethodPost}, handler: serviceLfsLocksVerify, path: "/info/lfs/locks/verify", }, { method: []string{http.MethodPost}, handler: serviceLfsLocksDelete, path: "/info/lfs/locks/{lock_id:[0-9]+}/unlock", }, } func askCredentials(w http.ResponseWriter, _ *http.Request) { w.Header().Set("WWW-Authenticate", `Basic realm="Git" charset="UTF-8", Token, Bearer`) w.Header().Set("LFS-Authenticate", `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`) } // withAccess handles auth. func withAccess(next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cfg := config.FromContext(ctx) logger := log.FromContext(ctx) be := backend.FromContext(ctx) // Store repository in context // We're not checking for errors here because we want to allow // repo creation on the fly. repoName := mux.Vars(r)["repo"] repo, _ := be.Repository(ctx, repoName) ctx = proto.WithRepositoryContext(ctx, repo) r = r.WithContext(ctx) user, err := authenticate(r) if err != nil { switch { case errors.Is(err, ErrInvalidToken): case errors.Is(err, proto.ErrUserNotFound): default: logger.Error("failed to authenticate", "err", err) } } if user == nil && !be.AllowKeyless(ctx) { askCredentials(w, r) renderUnauthorized(w, r) return } // Store user in context ctx = proto.WithUserContext(ctx, user) r = r.WithContext(ctx) if user != nil { logger.Debug("authenticated", "username", user.Username()) } service := git.Service(mux.Vars(r)["service"]) if service == "" { // Get service from request params service = getServiceType(r) } accessLevel := be.AccessLevelForUser(ctx, repoName, user) ctx = access.WithContext(ctx, accessLevel) r = r.WithContext(ctx) file := mux.Vars(r)["file"] // We only allow these services to proceed any other services should return 403 // - git-upload-pack // - git-receive-pack // - git-lfs switch { case service == git.ReceivePackService: if accessLevel < access.ReadWriteAccess { askCredentials(w, r) renderUnauthorized(w, r) return } // Create the repo if it doesn't exist. if repo == nil { repo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{}) if err != nil { logger.Error("failed to create repository", "repo", repoName, "err", err) renderInternalServerError(w, r) return } ctx = proto.WithRepositoryContext(ctx, repo) r = r.WithContext(ctx) } fallthrough case service == git.UploadPackService || service == git.UploadArchiveService: if repo == nil { // If the repo doesn't exist, return 404 renderNotFound(w, r) return } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) { // return 403 when bad credentials are provided renderForbidden(w, r) return } else if accessLevel < access.ReadOnlyAccess { askCredentials(w, r) renderUnauthorized(w, r) return } case strings.HasPrefix(file, "info/lfs"): if !cfg.LFS.Enabled { logger.Debug("LFS is not enabled, skipping") renderNotFound(w, r) return } switch { case strings.HasPrefix(file, "info/lfs/locks"): switch { case strings.HasSuffix(file, "lfs/locks"), strings.HasSuffix(file, "/unlock") && r.Method == http.MethodPost: // Create lock, list locks, and delete lock require write access fallthrough case strings.HasSuffix(file, "lfs/locks/verify"): // Locks verify requires write access // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2 if accessLevel < access.ReadWriteAccess { renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ Message: "write access required", }) return } } case strings.HasPrefix(file, "info/lfs/objects/basic"): switch r.Method { case http.MethodPut: // Basic upload if accessLevel < access.ReadWriteAccess { renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ Message: "write access required", }) return } case http.MethodGet: // Basic download case http.MethodPost: // Basic verify } } if accessLevel < access.ReadOnlyAccess { if repo == nil { renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "repository not found", }) } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) { renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ Message: "bad credentials", }) } else { askCredentials(w, r) renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{ Message: "credentials needed", }) } return } } switch { case r.URL.Query().Get("go-get") == "1" && accessLevel >= access.ReadOnlyAccess: // Allow go-get requests to passthrough. break case errors.Is(err, ErrInvalidToken), errors.Is(err, ErrInvalidPassword): // return 403 when bad credentials are provided renderForbidden(w, r) return case repo == nil, accessLevel < access.ReadOnlyAccess: // Don't hint that the repo exists if the user doesn't have access renderNotFound(w, r) return } next.ServeHTTP(w, r) } } //nolint:revive func serviceRpc(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cfg := config.FromContext(ctx) logger := log.FromContext(ctx) service, dir, repoName := git.Service(mux.Vars(r)["service"]), mux.Vars(r)["dir"], mux.Vars(r)["repo"] if !isSmart(r, service) { renderForbidden(w, r) return } if service == git.ReceivePackService { gitHttpReceiveCounter.WithLabelValues(repoName) } w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service)) w.Header().Set("Connection", "Keep-Alive") w.Header().Set("Transfer-Encoding", "chunked") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) version := r.Header.Get("Git-Protocol") var stdout bytes.Buffer cmd := git.ServiceCommand{ Stdout: &stdout, Dir: dir, } switch service { case git.UploadPackService, git.ReceivePackService: cmd.Args = append(cmd.Args, "--stateless-rpc") } user := proto.UserFromContext(ctx) cmd.Env = cfg.Environ() cmd.Env = append(cmd.Env, []string{ "SOFT_SERVE_REPO_NAME=" + repoName, "SOFT_SERVE_REPO_PATH=" + dir, "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"), }...) if user != nil { cmd.Env = append(cmd.Env, []string{ "SOFT_SERVE_USERNAME=" + user.Username(), }...) } if len(version) != 0 { cmd.Env = append(cmd.Env, []string{ fmt.Sprintf("GIT_PROTOCOL=%s", version), }...) } var ( err error reader io.ReadCloser ) // Handle gzip encoding reader = r.Body switch r.Header.Get("Content-Encoding") { case "gzip": reader, err = gzip.NewReader(reader) if err != nil { logger.Errorf("failed to create gzip reader: %v", err) renderInternalServerError(w, r) return } defer reader.Close() //nolint: errcheck } cmd.Stdin = reader cmd.Stdout = &flushResponseWriter{w} if err := service.Handler(ctx, cmd); err != nil { logger.Errorf("failed to handle service: %v", err) return } if service == git.ReceivePackService { if err := git.EnsureDefaultBranch(ctx, cmd.Dir); err != nil { logger.Errorf("failed to ensure default branch: %s", err) } } } // Handle buffered output // Useful when using proxies type flushResponseWriter struct { http.ResponseWriter } func (f *flushResponseWriter) ReadFrom(r io.Reader) (int64, error) { flusher := http.NewResponseController(f.ResponseWriter) var n int64 p := make([]byte, 1024) for { nRead, err := r.Read(p) if err == io.EOF { break } nWrite, err := f.ResponseWriter.Write(p[:nRead]) if err != nil { return n, err } if nRead != nWrite { return n, err } n += int64(nRead) // ResponseWriter must support http.Flusher to handle buffered output. if err := flusher.Flush(); err != nil { return n, fmt.Errorf("%w: error while flush", err) } } return n, nil } func getInfoRefs(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cfg := config.FromContext(ctx) dir, repoName, file := mux.Vars(r)["dir"], mux.Vars(r)["repo"], mux.Vars(r)["file"] service := getServiceType(r) protocol := r.Header.Get("Git-Protocol") gitHttpUploadCounter.WithLabelValues(repoName, file).Inc() if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) { // Smart HTTP var refs bytes.Buffer cmd := git.ServiceCommand{ Stdout: &refs, Dir: dir, Args: []string{"--stateless-rpc", "--advertise-refs"}, } user := proto.UserFromContext(ctx) cmd.Env = cfg.Environ() cmd.Env = append(cmd.Env, []string{ "SOFT_SERVE_REPO_NAME=" + repoName, "SOFT_SERVE_REPO_PATH=" + dir, "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"), }...) if user != nil { cmd.Env = append(cmd.Env, []string{ "SOFT_SERVE_USERNAME=" + user.Username(), }...) } if len(protocol) != 0 { cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", protocol)) } var version int for _, p := range strings.Split(protocol, ":") { if strings.HasPrefix(p, "version=") { if v, _ := strconv.Atoi(p[8:]); v > version { version = v } } } if err := service.Handler(ctx, cmd); err != nil { renderNotFound(w, r) return } hdrNocache(w) w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service)) w.WriteHeader(http.StatusOK) if version < 2 { git.WritePktline(w, "# service="+service.String()) //nolint: errcheck } w.Write(refs.Bytes()) //nolint: errcheck } else { // Dumb HTTP updateServerInfo(ctx, dir) //nolint: errcheck hdrNocache(w) sendFile("text/plain; charset=utf-8", w, r) } } func getInfoPacks(w http.ResponseWriter, r *http.Request) { hdrCacheForever(w) sendFile("text/plain; charset=utf-8", w, r) } func getLooseObject(w http.ResponseWriter, r *http.Request) { hdrCacheForever(w) sendFile("application/x-git-loose-object", w, r) } func getPackFile(w http.ResponseWriter, r *http.Request) { hdrCacheForever(w) sendFile("application/x-git-packed-objects", w, r) } func getIdxFile(w http.ResponseWriter, r *http.Request) { hdrCacheForever(w) sendFile("application/x-git-packed-objects-toc", w, r) } func getTextFile(w http.ResponseWriter, r *http.Request) { hdrNocache(w) sendFile("text/plain", w, r) } func sendFile(contentType string, w http.ResponseWriter, r *http.Request) { dir, file := mux.Vars(r)["dir"], mux.Vars(r)["file"] reqFile := filepath.Join(dir, file) f, err := os.Stat(reqFile) if os.IsNotExist(err) { renderNotFound(w, r) return } w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size())) w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat)) http.ServeFile(w, r, reqFile) } func getServiceType(r *http.Request) git.Service { service := r.FormValue("service") if !strings.HasPrefix(service, "git-") { return "" } return git.Service(service) } func isSmart(r *http.Request, service git.Service) bool { contentType := r.Header.Get("Content-Type") return strings.HasPrefix(contentType, fmt.Sprintf("application/x-%s-request", service)) } func updateServerInfo(ctx context.Context, dir string) error { return gitb.UpdateServerInfo(ctx, dir) } // HTTP error response handling functions func renderBadRequest(w http.ResponseWriter, r *http.Request) { renderStatus(http.StatusBadRequest)(w, r) } func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) { if r.Proto == "HTTP/1.1" { renderStatus(http.StatusMethodNotAllowed)(w, r) } else { renderBadRequest(w, r) } } func renderNotFound(w http.ResponseWriter, r *http.Request) { renderStatus(http.StatusNotFound)(w, r) } func renderUnauthorized(w http.ResponseWriter, r *http.Request) { renderStatus(http.StatusUnauthorized)(w, r) } func renderForbidden(w http.ResponseWriter, r *http.Request) { renderStatus(http.StatusForbidden)(w, r) } func renderInternalServerError(w http.ResponseWriter, r *http.Request) { renderStatus(http.StatusInternalServerError)(w, r) } // Header writing functions func hdrNocache(w http.ResponseWriter) { w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") w.Header().Set("Pragma", "no-cache") w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") } func hdrCacheForever(w http.ResponseWriter) { now := time.Now().Unix() expires := now + 31536000 w.Header().Set("Date", fmt.Sprintf("%d", now)) w.Header().Set("Expires", fmt.Sprintf("%d", expires)) w.Header().Set("Cache-Control", "public, max-age=31536000") } ================================================ FILE: pkg/web/git_lfs.go ================================================ package web import ( "encoding/json" "errors" "fmt" "io" "io/fs" "net/http" "net/url" "path" "path/filepath" "strconv" "strings" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/lfs" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/storage" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/gorilla/mux" ) // serviceLfsBatch handles a Git LFS batch requests. // https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md // TODO: support refname // POST: /.git/info/lfs/objects/batch func serviceLfsBatch(w http.ResponseWriter, r *http.Request) { ctx := r.Context() logger := log.FromContext(ctx).WithPrefix("http.lfs") if !isLfs(r) { logger.Errorf("invalid content type: %s", r.Header.Get("Content-Type")) renderNotAcceptable(w) return } var batchRequest lfs.BatchRequest defer r.Body.Close() //nolint: errcheck if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil { logger.Errorf("error decoding json: %s", err) renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{ Message: "validation error in request: " + err.Error(), }) return } // We only accept basic transfers for now // Default to basic if no transfer is specified if len(batchRequest.Transfers) > 0 { var isBasic bool for _, t := range batchRequest.Transfers { if t == lfs.TransferBasic { isBasic = true break } } if !isBasic { renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{ Message: "unsupported transfer", }) return } } if len(batchRequest.Objects) == 0 { renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{ Message: "no objects found", }) return } name := mux.Vars(r)["repo"] repo := proto.RepositoryFromContext(ctx) if repo == nil { renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "repository not found", }) return } cfg := config.FromContext(ctx) dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) // TODO: support S3 storage repoID := strconv.FormatInt(repo.ID(), 10) strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git") var batchResponse lfs.BatchResponse batchResponse.Transfer = lfs.TransferBasic batchResponse.HashAlgo = lfs.HashAlgorithmSHA256 objects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects)) // XXX: We don't support objects TTL for now, probably implement that with // S3 using object "expires_at" & "expires_in" switch batchRequest.Operation { case lfs.OperationDownload: for _, o := range batchRequest.Objects { exist, err := strg.Exists(path.Join("objects", o.RelativePath())) if err != nil && !errors.Is(err, fs.ErrNotExist) { logger.Error("error getting object stat", "oid", o.Oid, "repo", name, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), o.Oid) if err != nil && !errors.Is(err, db.ErrRecordNotFound) { logger.Error("error getting object from database", "oid", o.Oid, "repo", name, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } if !exist { objects = append(objects, &lfs.ObjectResponse{ Pointer: o, Error: &lfs.ObjectError{ Code: http.StatusNotFound, Message: "object not found", }, }) } else if obj.Size != o.Size { objects = append(objects, &lfs.ObjectResponse{ Pointer: o, Error: &lfs.ObjectError{ Code: http.StatusUnprocessableEntity, Message: "size mismatch", }, }) } else if o.IsValid() { download := &lfs.Link{ Href: fmt.Sprintf("%s/%s", baseHref, o.Oid), } if auth := r.Header.Get("Authorization"); auth != "" { download.Header = map[string]string{ "Authorization": auth, } } objects = append(objects, &lfs.ObjectResponse{ Pointer: o, Actions: map[string]*lfs.Link{ lfs.ActionDownload: download, }, }) // If the object doesn't exist in the database, create it if exist && obj.ID == 0 { if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), o.Oid, o.Size); err != nil { logger.Error("error creating object in datastore", "oid", o.Oid, "repo", name, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } } } else { logger.Error("invalid object", "oid", o.Oid, "repo", name) objects = append(objects, &lfs.ObjectResponse{ Pointer: o, Error: &lfs.ObjectError{ Code: http.StatusUnprocessableEntity, Message: "invalid object", }, }) } } case lfs.OperationUpload: // Check authorization accessLevel := access.FromContext(ctx) if accessLevel < access.ReadWriteAccess { askCredentials(w, r) renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ Message: "write access required", }) return } // Object upload logic happens in the "basic" API route for _, o := range batchRequest.Objects { if !o.IsValid() { objects = append(objects, &lfs.ObjectResponse{ Pointer: o, Error: &lfs.ObjectError{ Code: http.StatusUnprocessableEntity, Message: "invalid object", }, }) } else { upload := &lfs.Link{ Href: fmt.Sprintf("%s/%s", baseHref, o.Oid), Header: map[string]string{ // NOTE: git-lfs v2.5.0 sets the Content-Type based on the uploaded file. // This ensures that the client always uses the designated value for the header. "Content-Type": "application/octet-stream", }, } verify := &lfs.Link{ Href: fmt.Sprintf("%s/verify", baseHref), } if auth := r.Header.Get("Authorization"); auth != "" { upload.Header["Authorization"] = auth verify.Header = map[string]string{ "Authorization": auth, } } objects = append(objects, &lfs.ObjectResponse{ Pointer: o, Actions: map[string]*lfs.Link{ lfs.ActionUpload: upload, // Verify uploaded objects // https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md#verification lfs.ActionVerify: verify, }, }) } } default: renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{ Message: "unsupported operation", }) return } batchResponse.Objects = objects renderJSON(w, http.StatusOK, batchResponse) } // serviceLfsBasic implements Git LFS basic transfer API // https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md func serviceLfsBasic(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: serviceLfsBasicDownload(w, r) case http.MethodPut: serviceLfsBasicUpload(w, r) } } // GET: /.git/info/lfs/objects/basic/ func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) { ctx := r.Context() oid := mux.Vars(r)["oid"] repo := proto.RepositoryFromContext(ctx) cfg := config.FromContext(ctx) logger := log.FromContext(ctx).WithPrefix("http.lfs-basic") datastore := store.FromContext(ctx) dbx := db.FromContext(ctx) repoID := strconv.FormatInt(repo.ID(), 10) strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid) if err != nil && !errors.Is(err, db.ErrRecordNotFound) { logger.Error("error getting object from database", "oid", oid, "repo", repo.Name(), "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } pointer := lfs.Pointer{Oid: oid} f, err := strg.Open(path.Join("objects", pointer.RelativePath())) if err != nil { logger.Error("error opening object", "oid", oid, "err", err) renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "object not found", }) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", strconv.FormatInt(obj.Size, 10)) defer f.Close() //nolint: errcheck if _, err := io.Copy(w, f); err != nil { logger.Error("error copying object to response", "oid", oid, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } } // PUT: /.git/info/lfs/objects/basic/ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) { if !isBinary(r) { renderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{ Message: "invalid content type", }) return } ctx := r.Context() oid := mux.Vars(r)["oid"] cfg := config.FromContext(ctx) be := backend.FromContext(ctx) dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) logger := log.FromContext(ctx).WithPrefix("http.lfs-basic") repo := proto.RepositoryFromContext(ctx) repoID := strconv.FormatInt(repo.ID(), 10) strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) name := mux.Vars(r)["repo"] defer r.Body.Close() //nolint: errcheck repo, err := be.Repository(ctx, name) if err != nil { renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "repository not found", }) return } // NOTE: Git LFS client will retry uploading the same object if there was a // partial error, so we need to skip existing objects. if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil { // Object exists, skip request io.Copy(io.Discard, r.Body) //nolint: errcheck renderStatus(http.StatusOK)(w, nil) return } else if !errors.Is(err, db.ErrRecordNotFound) { logger.Error("error getting object", "oid", oid, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } pointer := lfs.Pointer{Oid: oid} if _, err := strg.Put(path.Join("objects", pointer.RelativePath()), r.Body); err != nil { logger.Error("error writing object", "oid", oid, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64) if err != nil { logger.Error("error parsing content length", "err", err) renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ Message: "invalid content length", }) return } if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), oid, size); err != nil { logger.Error("error creating object", "oid", oid, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } renderStatus(http.StatusOK)(w, nil) } // POST: /.git/info/lfs/objects/basic/verify func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) { if !isLfs(r) { renderNotAcceptable(w) return } var pointer lfs.Pointer ctx := r.Context() logger := log.FromContext(ctx).WithPrefix("http.lfs-basic") repo := proto.RepositoryFromContext(ctx) if repo == nil { logger.Error("error getting repository from context") renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "repository not found", }) return } defer r.Body.Close() //nolint: errcheck if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil { logger.Error("error decoding json", "err", err) renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ Message: "invalid request: " + err.Error(), }) return } cfg := config.FromContext(ctx) dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) repoID := strconv.FormatInt(repo.ID(), 10) strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) if stat, err := strg.Stat(path.Join("objects", pointer.RelativePath())); err == nil { // Verify object is in the database. obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid) if err != nil { if errors.Is(err, db.ErrRecordNotFound) { logger.Error("object not found", "oid", pointer.Oid) renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "object not found", }) return } logger.Error("error getting object", "oid", pointer.Oid, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } if obj.Size != pointer.Size { renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ Message: "object size mismatch", }) return } if pointer.IsValid() && stat.Size() == pointer.Size { renderStatus(http.StatusOK)(w, nil) return } } else if errors.Is(err, fs.ErrNotExist) { logger.Error("file not found", "oid", pointer.Oid) renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "object not found", }) return } else { logger.Error("error getting object", "oid", pointer.Oid, "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } } func serviceLfsLocks(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: serviceLfsLocksGet(w, r) case http.MethodPost: serviceLfsLocksCreate(w, r) default: renderMethodNotAllowed(w, r) } } // POST: /.git/info/lfs/objects/locks func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) { if !isLfs(r) { renderNotAcceptable(w) return } ctx := r.Context() logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") var req lfs.LockCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { logger.Error("error decoding json", "err", err) renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ Message: "invalid request: " + err.Error(), }) return } repo := proto.RepositoryFromContext(ctx) if repo == nil { logger.Error("error getting repository from context") renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "repository not found", }) return } user := proto.UserFromContext(ctx) if user == nil { logger.Error("error getting user from context") renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "user not found", }) return } dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) if err := datastore.CreateLFSLockForUser(ctx, dbx, repo.ID(), user.ID(), req.Path, req.Ref.Name); err != nil { err = db.WrapError(err) if errors.Is(err, db.ErrDuplicateKey) { errResp := lfs.LockResponse{ ErrorResponse: lfs.ErrorResponse{ Message: "lock already exists", }, } lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path) if err == nil { errResp.Lock = lfs.Lock{ ID: strconv.FormatInt(lock.ID, 10), Path: lock.Path, LockedAt: lock.CreatedAt, } lockOwner := lfs.Owner{ Name: user.Username(), } if lock.UserID != user.ID() { owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) if err != nil { logger.Error("error getting lock owner", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } lockOwner.Name = owner.Username } errResp.Lock.Owner = lockOwner } renderJSON(w, http.StatusConflict, errResp) return } logger.Error("error creating lock", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path) if err != nil { logger.Error("error getting lock", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } renderJSON(w, http.StatusCreated, lfs.LockResponse{ Lock: lfs.Lock{ ID: strconv.FormatInt(lock.ID, 10), Path: lock.Path, LockedAt: lock.CreatedAt, Owner: lfs.Owner{ Name: user.Username(), }, }, }) } // GET: /.git/info/lfs/objects/locks func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept") if !strings.HasPrefix(accept, lfs.MediaType) { renderNotAcceptable(w) return } parseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) { path = values.Get("path") idStr := values.Get("id") if idStr != "" { id, _ = strconv.ParseInt(idStr, 10, 64) } cursorStr := values.Get("cursor") if cursorStr != "" { cursor, _ = strconv.Atoi(cursorStr) } limitStr := values.Get("limit") if limitStr != "" { limit, _ = strconv.Atoi(limitStr) } refspec = values.Get("refspec") return } ctx := r.Context() // TODO: respect refspec path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query()) if limit > 100 { limit = 100 } else if limit <= 0 { limit = lfs.DefaultLocksLimit } // cursor is the page number if cursor <= 0 { cursor = 1 } logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) repo := proto.RepositoryFromContext(ctx) if repo == nil { logger.Error("error getting repository from context") renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "repository not found", }) return } if id > 0 { lock, err := datastore.GetLFSLockByID(ctx, dbx, id) if err != nil { if errors.Is(err, db.ErrRecordNotFound) { renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "lock not found", }) return } logger.Error("error getting lock", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) if err != nil { logger.Error("error getting lock owner", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } renderJSON(w, http.StatusOK, lfs.LockListResponse{ Locks: []lfs.Lock{ { ID: strconv.FormatInt(lock.ID, 10), Path: lock.Path, LockedAt: lock.CreatedAt, Owner: lfs.Owner{ Name: owner.Username, }, }, }, }) return } else if path != "" { lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path) if err != nil { if errors.Is(err, db.ErrRecordNotFound) { renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "lock not found", }) return } logger.Error("error getting lock", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) if err != nil { logger.Error("error getting lock owner", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } renderJSON(w, http.StatusOK, lfs.LockListResponse{ Locks: []lfs.Lock{ { ID: strconv.FormatInt(lock.ID, 10), Path: lock.Path, LockedAt: lock.CreatedAt, Owner: lfs.Owner{ Name: owner.Username, }, }, }, }) return } locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit) if err != nil { logger.Error("error getting locks", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } lockList := make([]lfs.Lock, len(locks)) users := map[int64]models.User{} for i, lock := range locks { owner, ok := users[lock.UserID] if !ok { owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID) if err != nil { logger.Error("error getting lock owner", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } users[lock.UserID] = owner } lockList[i] = lfs.Lock{ ID: strconv.FormatInt(lock.ID, 10), Path: lock.Path, LockedAt: lock.CreatedAt, Owner: lfs.Owner{ Name: owner.Username, }, } } resp := lfs.LockListResponse{ Locks: lockList, } if len(locks) == limit { resp.NextCursor = strconv.Itoa(cursor + 1) } renderJSON(w, http.StatusOK, resp) } // POST: /.git/info/lfs/objects/locks/verify func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) { if !isLfs(r) { renderNotAcceptable(w) return } ctx := r.Context() logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") repo := proto.RepositoryFromContext(ctx) if repo == nil { logger.Error("error getting repository from context") renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "repository not found", }) return } var req lfs.LockVerifyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { logger.Error("error decoding request", "err", err) renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ Message: "invalid request: " + err.Error(), }) return } // TODO: refspec cursor, _ := strconv.Atoi(req.Cursor) if cursor <= 0 { cursor = 1 } limit := req.Limit if limit > 100 { limit = 100 } else if limit <= 0 { limit = lfs.DefaultLocksLimit } dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) user := proto.UserFromContext(ctx) ours := make([]lfs.Lock, 0) theirs := make([]lfs.Lock, 0) var resp lfs.LockVerifyResponse locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit) if err != nil { logger.Error("error getting locks", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } users := map[int64]models.User{} for _, lock := range locks { owner, ok := users[lock.UserID] if !ok { owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID) if err != nil { logger.Error("error getting lock owner", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } users[lock.UserID] = owner } l := lfs.Lock{ ID: strconv.FormatInt(lock.ID, 10), Path: lock.Path, LockedAt: lock.CreatedAt, Owner: lfs.Owner{ Name: owner.Username, }, } if user != nil && user.ID() == lock.UserID { ours = append(ours, l) } else { theirs = append(theirs, l) } } resp.Ours = ours resp.Theirs = theirs if len(locks) == limit { resp.NextCursor = strconv.Itoa(cursor + 1) } renderJSON(w, http.StatusOK, resp) } // POST: /.git/info/lfs/objects/locks/:lockID/unlock func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) { if !isLfs(r) { renderNotAcceptable(w) return } ctx := r.Context() logger := log.FromContext(ctx).WithPrefix("http.lfs-locks") lockIDStr := mux.Vars(r)["lock_id"] if lockIDStr == "" { logger.Error("error getting lock id") renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ Message: "invalid request", }) return } lockID, err := strconv.ParseInt(lockIDStr, 10, 64) if err != nil { logger.Error("error parsing lock id", "err", err) renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ Message: "invalid request", }) return } var req lfs.LockDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { logger.Error("error decoding request", "err", err) renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{ Message: "invalid request: " + err.Error(), }) return } dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) repo := proto.RepositoryFromContext(ctx) if repo == nil { logger.Error("error getting repository from context") renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "repository not found", }) return } // The lock being deleted lock, err := datastore.GetLFSLockByID(ctx, dbx, lockID) if err != nil { logger.Error("error getting lock", "err", err) renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{ Message: "lock not found", }) return } owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID) if err != nil { logger.Error("error getting lock owner", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } l := lfs.Lock{ ID: strconv.FormatInt(lock.ID, 10), Path: lock.Path, LockedAt: lock.CreatedAt, Owner: lfs.Owner{ Name: owner.Username, }, } // Retrieve user context first for authorization checks user := proto.UserFromContext(ctx) if user == nil { logger.Error("error getting user from context") renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{ Message: "unauthorized", }) return } // Force delete another user's lock (requires admin privileges) if req.Force { if !user.IsAdmin() { logger.Error("non-admin user attempted force delete", "user", user.Username()) renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ Message: "admin access required for force delete", }) return } if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil { logger.Error("error deleting lock", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } renderJSON(w, http.StatusOK, l) return } // Delete our own lock - verify ownership if owner.ID != user.ID() { logger.Error("error deleting another user's lock") renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{ Message: "lock belongs to another user", }) return } if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil { logger.Error("error deleting lock", "err", err) renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{ Message: "internal server error", }) return } renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l}) } // renderJSON renders a JSON response with the given status code and value. It // also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json). func renderJSON(w http.ResponseWriter, statusCode int, v interface{}) { hdrLfs(w) w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(v); err != nil { log.Error("error encoding json", "err", err) } } func renderNotAcceptable(w http.ResponseWriter) { renderStatus(http.StatusNotAcceptable)(w, nil) } func isLfs(r *http.Request) bool { contentType := r.Header.Get("Content-Type") accept := r.Header.Get("Accept") return strings.HasPrefix(contentType, lfs.MediaType) && strings.HasPrefix(accept, lfs.MediaType) } func isBinary(r *http.Request) bool { contentType := r.Header.Get("Content-Type") return strings.HasPrefix(contentType, "application/octet-stream") } func hdrLfs(w http.ResponseWriter) { w.Header().Set("Content-Type", lfs.MediaType) w.Header().Set("Accept", lfs.MediaType) } ================================================ FILE: pkg/web/goget.go ================================================ package web import ( "net/http" "net/url" "path" "text/template" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var goGetCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "soft_serve", Subsystem: "http", Name: "go_get_total", Help: "The total number of go get requests", }, []string{"repo"}) var repoIndexHTMLTpl = template.Must(template.New("index").Parse(` Redirecting to docs at godoc.org/{{ .ImportRoot }}/{{ .Repo }}... `)) // GoGetHandler handles go get requests. func GoGetHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cfg := config.FromContext(ctx) be := backend.FromContext(ctx) logger := log.FromContext(ctx) repo := mux.Vars(r)["repo"] // Handle go get requests. // // Always return a 200 status code, even if the repo path doesn't exist. // It will try to find the repo by walking up the path until it finds one. // If it can't find one, it will return a 404. // // https://golang.org/cmd/go/#hdr-Remote_import_paths // https://go.dev/ref/mod#vcs-branch if r.URL.Query().Get("go-get") == "1" { repo := repo importRoot, err := url.Parse(cfg.HTTP.PublicURL) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // find the repo for { if _, err := be.Repository(ctx, repo); err == nil { break } if repo == "" || repo == "." || repo == "/" { renderNotFound(w, r) return } repo = path.Dir(repo) } if err := repoIndexHTMLTpl.Execute(w, struct { Repo string Config *config.Config ImportRoot string }{ Repo: utils.SanitizeRepo(repo), Config: cfg, ImportRoot: importRoot.Host, }); err != nil { logger.Error("failed to render go get template", "err", err) renderInternalServerError(w, r) return } goGetCounter.WithLabelValues(repo).Inc() return } renderNotFound(w, r) } ================================================ FILE: pkg/web/health.go ================================================ package web import ( "context" "net/http" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/gorilla/mux" ) // HealthController registers the health check routes for the web server. func HealthController(_ context.Context, r *mux.Router) { r.HandleFunc("/livez", getLiveness) r.HandleFunc("/readyz", getReadiness) } func getLiveness(w http.ResponseWriter, _ *http.Request) { renderStatus(http.StatusOK)(w, nil) } func getReadiness(w http.ResponseWriter, r *http.Request) { ctx := r.Context() logger := log.FromContext(ctx) db := db.FromContext(ctx) if err := db.PingContext(ctx); err != nil { logger.Error("error getting db readiness", "err", err) renderStatus(http.StatusServiceUnavailable)(w, nil) return } renderStatus(http.StatusOK)(w, nil) } ================================================ FILE: pkg/web/http.go ================================================ package web import ( "context" "crypto/tls" "net/http" "time" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/config" ) // HTTPServer is an http server. type HTTPServer struct { ctx context.Context cfg *config.Config Server *http.Server } // NewHTTPServer creates a new HTTP server. func NewHTTPServer(ctx context.Context) (*HTTPServer, error) { cfg := config.FromContext(ctx) logger := log.FromContext(ctx) s := &HTTPServer{ ctx: ctx, cfg: cfg, Server: &http.Server{ Addr: cfg.HTTP.ListenAddr, Handler: NewRouter(ctx), ReadHeaderTimeout: time.Second * 10, IdleTimeout: time.Second * 10, MaxHeaderBytes: http.DefaultMaxHeaderBytes, ErrorLog: logger.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel}), }, } return s, nil } // SetTLSConfig sets the TLS configuration for the HTTP server. func (s *HTTPServer) SetTLSConfig(tlsConfig *tls.Config) { s.Server.TLSConfig = tlsConfig } // Close closes the HTTP server. func (s *HTTPServer) Close() error { return s.Server.Close() } // ListenAndServe starts the HTTP server. func (s *HTTPServer) ListenAndServe() error { if s.Server.TLSConfig != nil { return s.Server.ListenAndServeTLS("", "") } return s.Server.ListenAndServe() } // Shutdown gracefully shuts down the HTTP server. func (s *HTTPServer) Shutdown(ctx context.Context) error { return s.Server.Shutdown(ctx) } ================================================ FILE: pkg/web/logging.go ================================================ package web import ( "bufio" "fmt" "net" "net/http" "time" "charm.land/log/v2" "github.com/dustin/go-humanize" ) // logWriter is a wrapper around http.ResponseWriter that allows us to capture // the HTTP status code and bytes written to the response. type logWriter struct { http.ResponseWriter code, bytes int } var ( _ http.ResponseWriter = (*logWriter)(nil) _ http.Flusher = (*logWriter)(nil) _ http.Hijacker = (*logWriter)(nil) _ http.CloseNotifier = (*logWriter)(nil) ) // Write implements http.ResponseWriter. func (r *logWriter) Write(p []byte) (int, error) { written, err := r.ResponseWriter.Write(p) r.bytes += written return written, err } // Note this is generally only called when sending an HTTP error, so it's // important to set the `code` value to 200 as a default. func (r *logWriter) WriteHeader(code int) { r.code = code r.ResponseWriter.WriteHeader(code) } // Unwrap returns the underlying http.ResponseWriter. func (r *logWriter) Unwrap() http.ResponseWriter { return r.ResponseWriter } // Flush implements http.Flusher. func (r *logWriter) Flush() { if f, ok := r.ResponseWriter.(http.Flusher); ok { f.Flush() } } // CloseNotify implements http.CloseNotifier. func (r *logWriter) CloseNotify() <-chan bool { if cn, ok := r.ResponseWriter.(http.CloseNotifier); ok { return cn.CloseNotify() } return nil } // Hijack implements http.Hijacker. func (r *logWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { if h, ok := r.ResponseWriter.(http.Hijacker); ok { return h.Hijack() } return nil, nil, fmt.Errorf("http.Hijacker not implemented") } // NewLoggingMiddleware returns a new logging middleware. func NewLoggingMiddleware(next http.Handler, logger *log.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() writer := &logWriter{code: http.StatusOK, ResponseWriter: w} logger.Debug("request", "method", r.Method, "path", r.URL, "addr", r.RemoteAddr) next.ServeHTTP(writer, r) elapsed := time.Since(start) logger.Debug("response", "status", fmt.Sprintf("%d %s", writer.code, http.StatusText(writer.code)), "bytes", humanize.Bytes(uint64(writer.bytes)), //nolint:gosec "time", elapsed) }) } ================================================ FILE: pkg/web/server.go ================================================ package web import ( "context" "net/http" "charm.land/log/v2" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/gorilla/handlers" "github.com/gorilla/mux" ) // NewRouter returns a new HTTP router. func NewRouter(ctx context.Context) http.Handler { logger := log.FromContext(ctx).WithPrefix("http") router := mux.NewRouter() // Health routes HealthController(ctx, router) // Git routes GitController(ctx, router) router.PathPrefix("/").HandlerFunc(renderNotFound) // Context handler // Adds context to the request h := NewLoggingMiddleware(router, logger) h = NewContextHandler(ctx)(h) h = handlers.CompressHandler(h) h = handlers.RecoveryHandler()(h) cfg := config.FromContext(ctx) h = handlers.CORS(handlers.AllowedHeaders(cfg.HTTP.CORS.AllowedHeaders), handlers.AllowedOrigins(cfg.HTTP.CORS.AllowedOrigins), handlers.AllowedMethods(cfg.HTTP.CORS.AllowedMethods), )(h) return h } ================================================ FILE: pkg/web/util.go ================================================ package web import ( "fmt" "io" "net/http" ) func renderStatus(code int) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(code) io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) //nolint: errcheck } } ================================================ FILE: pkg/webhook/branch_tag.go ================================================ package webhook import ( "context" "fmt" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/store" ) // BranchTagEvent is a branch or tag event. type BranchTagEvent struct { Common // Ref is the branch or tag name. Ref string `json:"ref" url:"ref"` // Before is the previous commit SHA. Before string `json:"before" url:"before"` // After is the current commit SHA. After string `json:"after" url:"after"` // Created is whether the branch or tag was created. Created bool `json:"created" url:"created"` // Deleted is whether the branch or tag was deleted. Deleted bool `json:"deleted" url:"deleted"` } // NewBranchTagEvent sends a branch or tag event. func NewBranchTagEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (BranchTagEvent, error) { var event Event if git.IsZeroHash(before) { event = EventBranchTagCreate } else if git.IsZeroHash(after) { event = EventBranchTagDelete } else { return BranchTagEvent{}, fmt.Errorf("invalid branch or tag event: before=%q after=%q", before, after) } payload := BranchTagEvent{ Ref: ref, Before: before, After: after, Created: git.IsZeroHash(before), Deleted: git.IsZeroHash(after), Common: Common{ EventType: event, Repository: Repository{ ID: repo.ID(), Name: repo.Name(), Description: repo.Description(), ProjectName: repo.ProjectName(), Private: repo.IsPrivate(), CreatedAt: repo.CreatedAt(), UpdatedAt: repo.UpdatedAt(), }, Sender: User{ ID: user.ID(), Username: user.Username(), }, }, } cfg := config.FromContext(ctx) payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name()) payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name()) payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name()) // Find repo owner. dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID()) if err != nil { return BranchTagEvent{}, db.WrapError(err) } payload.Repository.Owner.ID = owner.ID payload.Repository.Owner.Username = owner.Username payload.Repository.DefaultBranch, _ = getDefaultBranch(repo) return payload, nil } ================================================ FILE: pkg/webhook/collaborator.go ================================================ package webhook import ( "context" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/store" ) // CollaboratorEvent is a collaborator event. type CollaboratorEvent struct { Common // Action is the collaborator event action. Action CollaboratorEventAction `json:"action" url:"action"` // AccessLevel is the collaborator access level. AccessLevel access.AccessLevel `json:"access_level" url:"access_level"` // Collaborator is the collaborator. Collaborator User `json:"collaborator" url:"collaborator"` } // CollaboratorEventAction is a collaborator event action. type CollaboratorEventAction string const ( // CollaboratorEventAdded is a collaborator added event. CollaboratorEventAdded CollaboratorEventAction = "added" // CollaboratorEventRemoved is a collaborator removed event. CollaboratorEventRemoved CollaboratorEventAction = "removed" ) // NewCollaboratorEvent sends a collaborator event. func NewCollaboratorEvent(ctx context.Context, user proto.User, repo proto.Repository, collabUsername string, action CollaboratorEventAction) (CollaboratorEvent, error) { event := EventCollaborator payload := CollaboratorEvent{ Action: action, Common: Common{ EventType: event, Repository: Repository{ ID: repo.ID(), Name: repo.Name(), Description: repo.Description(), ProjectName: repo.ProjectName(), Private: repo.IsPrivate(), CreatedAt: repo.CreatedAt(), UpdatedAt: repo.UpdatedAt(), }, Sender: User{ ID: user.ID(), Username: user.Username(), }, }, } // Find repo owner. dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID()) if err != nil { return CollaboratorEvent{}, db.WrapError(err) } payload.Repository.Owner.ID = owner.ID payload.Repository.Owner.Username = owner.Username payload.Repository.DefaultBranch, _ = getDefaultBranch(repo) collab, err := datastore.GetCollabByUsernameAndRepo(ctx, dbx, collabUsername, repo.Name()) if err != nil { return CollaboratorEvent{}, err } payload.AccessLevel = collab.AccessLevel payload.Collaborator.ID = collab.UserID payload.Collaborator.Username = collabUsername return payload, nil } ================================================ FILE: pkg/webhook/common.go ================================================ package webhook import "time" // EventPayload is a webhook event payload. type EventPayload interface { // Event returns the event type. Event() Event // RepositoryID returns the repository ID. RepositoryID() int64 } // Common is a common payload. type Common struct { // EventType is the event type. EventType Event `json:"event" url:"event"` // Repository is the repository payload. Repository Repository `json:"repository" url:"repository"` // Sender is the sender payload. Sender User `json:"sender" url:"sender"` } // Event returns the event type. // Implements EventPayload. func (c Common) Event() Event { return c.EventType } // RepositoryID returns the repository ID. // Implements EventPayload. func (c Common) RepositoryID() int64 { return c.Repository.ID } // User represents a user in an event. type User struct { // ID is the owner ID. ID int64 `json:"id" url:"id"` // Username is the owner username. Username string `json:"username" url:"username"` } // Repository represents an event repository. type Repository struct { // ID is the repository ID. ID int64 `json:"id" url:"id"` // Name is the repository name. Name string `json:"name" url:"name"` // ProjectName is the repository project name. ProjectName string `json:"project_name" url:"project_name"` // Description is the repository description. Description string `json:"description" url:"description"` // DefaultBranch is the repository default branch. DefaultBranch string `json:"default_branch" url:"default_branch"` // Private is whether the repository is private. Private bool `json:"private" url:"private"` // Owner is the repository owner. Owner User `json:"owner" url:"owner"` // HTTPURL is the repository HTTP URL. HTTPURL string `json:"http_url" url:"http_url"` // SSHURL is the repository SSH URL. SSHURL string `json:"ssh_url" url:"ssh_url"` // GitURL is the repository Git URL. GitURL string `json:"git_url" url:"git_url"` // CreatedAt is the repository creation time. CreatedAt time.Time `json:"created_at" url:"created_at"` // UpdatedAt is the repository last update time. UpdatedAt time.Time `json:"updated_at" url:"updated_at"` } // Author is a commit author. type Author struct { // Name is the author name. Name string `json:"name" url:"name"` // Email is the author email. Email string `json:"email" url:"email"` // Date is the author date. Date time.Time `json:"date" url:"date"` } // Commit represents a Git commit. type Commit struct { // ID is the commit ID. ID string `json:"id" url:"id"` // Message is the commit message. Message string `json:"message" url:"message"` // Title is the commit title. Title string `json:"title" url:"title"` // Author is the commit author. Author Author `json:"author" url:"author"` // Committer is the commit committer. Committer Author `json:"committer" url:"committer"` // Timestamp is the commit timestamp. Timestamp time.Time `json:"timestamp" url:"timestamp"` } ================================================ FILE: pkg/webhook/content_type.go ================================================ package webhook import ( "encoding" "errors" "strings" ) // ContentType is the type of content that will be sent in a webhook request. type ContentType int8 const ( // ContentTypeJSON is the JSON content type. ContentTypeJSON ContentType = iota // ContentTypeForm is the form content type. ContentTypeForm ) var contentTypeStrings = map[ContentType]string{ ContentTypeJSON: "application/json", ContentTypeForm: "application/x-www-form-urlencoded", } // String returns the string representation of the content type. func (c ContentType) String() string { return contentTypeStrings[c] } var stringContentType = map[string]ContentType{ "application/json": ContentTypeJSON, "application/x-www-form-urlencoded": ContentTypeForm, } // ErrInvalidContentType is returned when the content type is invalid. var ErrInvalidContentType = errors.New("invalid content type") // ParseContentType parses a content type string and returns the content type. func ParseContentType(s string) (ContentType, error) { for k, v := range stringContentType { if strings.HasPrefix(s, k) { return v, nil } } return -1, ErrInvalidContentType } var ( _ encoding.TextMarshaler = ContentType(0) _ encoding.TextUnmarshaler = (*ContentType)(nil) ) // UnmarshalText implements encoding.TextUnmarshaler. func (c *ContentType) UnmarshalText(text []byte) error { ct, err := ParseContentType(string(text)) if err != nil { return err } *c = ct return nil } // MarshalText implements encoding.TextMarshaler. func (c ContentType) MarshalText() (text []byte, err error) { ct := c.String() if ct == "" { return nil, ErrInvalidContentType } return []byte(ct), nil } ================================================ FILE: pkg/webhook/content_type_test.go ================================================ package webhook import "testing" func TestParseContentType(t *testing.T) { tests := []struct { name string s string want ContentType err error }{ { name: "JSON", s: "application/json", want: ContentTypeJSON, }, { name: "Form", s: "application/x-www-form-urlencoded", want: ContentTypeForm, }, { name: "Invalid", s: "application/invalid", err: ErrInvalidContentType, want: -1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseContentType(tt.s) if err != tt.err { t.Errorf("ParseContentType() error = %v, wantErr %v", err, tt.err) return } if got != tt.want { t.Errorf("ParseContentType() got = %v, want %v", got, tt.want) } }) } } func TestUnmarshalText(t *testing.T) { tests := []struct { name string text []byte want ContentType wantErr bool }{ { name: "JSON", text: []byte("application/json"), want: ContentTypeJSON, }, { name: "Form", text: []byte("application/x-www-form-urlencoded"), want: ContentTypeForm, }, { name: "Invalid", text: []byte("application/invalid"), wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := new(ContentType) if err := c.UnmarshalText(tt.text); (err != nil) != tt.wantErr { t.Errorf("ContentType.UnmarshalText() error = %v, wantErr %v", err, tt.wantErr) } if *c != tt.want { t.Errorf("ContentType.UnmarshalText() got = %v, want %v", *c, tt.want) } }) } } func TestMarshalText(t *testing.T) { tests := []struct { name string c ContentType want []byte wantErr bool }{ { name: "JSON", c: ContentTypeJSON, want: []byte("application/json"), }, { name: "Form", c: ContentTypeForm, want: []byte("application/x-www-form-urlencoded"), }, { name: "Invalid", c: ContentType(-1), wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { b, err := tt.c.MarshalText() if (err != nil) != tt.wantErr { t.Errorf("ContentType.MarshalText() error = %v, wantErr %v", err, tt.wantErr) return } if string(b) != string(tt.want) { t.Errorf("ContentType.MarshalText() got = %v, want %v", string(b), string(tt.want)) } }) } } ================================================ FILE: pkg/webhook/event.go ================================================ package webhook import ( "encoding" "errors" ) // Event is a webhook event. type Event int const ( // EventBranchTagCreate is a branch or tag create event. EventBranchTagCreate Event = 1 // EventBranchTagDelete is a branch or tag delete event. EventBranchTagDelete Event = 2 // EventCollaborator is a collaborator change event. EventCollaborator Event = 3 // EventPush is a push event. EventPush Event = 4 // EventRepository is a repository create, delete, rename event. EventRepository Event = 5 // EventRepositoryVisibilityChange is a repository visibility change event. EventRepositoryVisibilityChange Event = 6 ) // Events return all events. func Events() []Event { return []Event{ EventBranchTagCreate, EventBranchTagDelete, EventCollaborator, EventPush, EventRepository, EventRepositoryVisibilityChange, } } var eventStrings = map[Event]string{ EventBranchTagCreate: "branch_tag_create", EventBranchTagDelete: "branch_tag_delete", EventCollaborator: "collaborator", EventPush: "push", EventRepository: "repository", EventRepositoryVisibilityChange: "repository_visibility_change", } // String returns the string representation of the event. func (e Event) String() string { return eventStrings[e] } var stringEvent = map[string]Event{ "branch_tag_create": EventBranchTagCreate, "branch_tag_delete": EventBranchTagDelete, "collaborator": EventCollaborator, "push": EventPush, "repository": EventRepository, "repository_visibility_change": EventRepositoryVisibilityChange, } // ErrInvalidEvent is returned when the event is invalid. var ErrInvalidEvent = errors.New("invalid event") // ParseEvent parses an event string and returns the event. func ParseEvent(s string) (Event, error) { e, ok := stringEvent[s] if !ok { return -1, ErrInvalidEvent } return e, nil } var ( _ encoding.TextMarshaler = Event(0) _ encoding.TextUnmarshaler = (*Event)(nil) ) // UnmarshalText implements encoding.TextUnmarshaler. func (e *Event) UnmarshalText(text []byte) error { ev, err := ParseEvent(string(text)) if err != nil { return err } *e = ev return nil } // MarshalText implements encoding.TextMarshaler. func (e Event) MarshalText() (text []byte, err error) { ev := e.String() if ev == "" { return nil, ErrInvalidEvent } return []byte(ev), nil } ================================================ FILE: pkg/webhook/push.go ================================================ package webhook import ( "context" "fmt" gitm "github.com/aymanbagabas/git-module" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/store" ) // PushEvent is a push event. type PushEvent struct { Common // Ref is the branch or tag name. Ref string `json:"ref" url:"ref"` // Before is the previous commit SHA. Before string `json:"before" url:"before"` // After is the current commit SHA. After string `json:"after" url:"after"` // Commits is the list of commits. Commits []Commit `json:"commits" url:"commits"` } // NewPushEvent sends a push event. func NewPushEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (PushEvent, error) { event := EventPush payload := PushEvent{ Ref: ref, Before: before, After: after, Common: Common{ EventType: event, Repository: Repository{ ID: repo.ID(), Name: repo.Name(), Description: repo.Description(), ProjectName: repo.ProjectName(), Private: repo.IsPrivate(), CreatedAt: repo.CreatedAt(), UpdatedAt: repo.UpdatedAt(), }, Sender: User{ ID: user.ID(), Username: user.Username(), }, }, } cfg := config.FromContext(ctx) payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name()) payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name()) payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name()) // Find repo owner. dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID()) if err != nil { return PushEvent{}, db.WrapError(err) } payload.Repository.Owner.ID = owner.ID payload.Repository.Owner.Username = owner.Username // Find commits. r, err := repo.Open() if err != nil { return PushEvent{}, err } payload.Repository.DefaultBranch, _ = getDefaultBranch(repo) rev := after if !git.IsZeroHash(before) { rev = fmt.Sprintf("%s..%s", before, after) } commits, err := r.Log(rev, gitm.LogOptions{ // XXX: limit to 20 commits for now // TODO: implement a commits api MaxCount: 20, }) if err != nil { return PushEvent{}, err } payload.Commits = make([]Commit, len(commits)) for i, c := range commits { payload.Commits[i] = Commit{ ID: c.ID.String(), Message: c.Message, Title: c.Summary(), Author: Author{ Name: c.Author.Name, Email: c.Author.Email, Date: c.Author.When, }, Committer: Author{ Name: c.Committer.Name, Email: c.Committer.Email, Date: c.Committer.When, }, Timestamp: c.Committer.When, } } return payload, nil } ================================================ FILE: pkg/webhook/repository.go ================================================ package webhook import ( "context" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/store" ) // RepositoryEvent is a repository payload. type RepositoryEvent struct { Common // Action is the repository event action. Action RepositoryEventAction `json:"action" url:"action"` } // RepositoryEventAction is a repository event action. type RepositoryEventAction string const ( // RepositoryEventActionDelete is a repository deleted event. RepositoryEventActionDelete RepositoryEventAction = "delete" // RepositoryEventActionRename is a repository renamed event. RepositoryEventActionRename RepositoryEventAction = "rename" // RepositoryEventActionVisibilityChange is a repository visibility changed event. RepositoryEventActionVisibilityChange RepositoryEventAction = "visibility_change" // RepositoryEventActionDefaultBranchChange is a repository default branch changed event. RepositoryEventActionDefaultBranchChange RepositoryEventAction = "default_branch_change" ) // NewRepositoryEvent sends a repository event. func NewRepositoryEvent(ctx context.Context, user proto.User, repo proto.Repository, action RepositoryEventAction) (RepositoryEvent, error) { var event Event switch action { case RepositoryEventActionVisibilityChange: event = EventRepositoryVisibilityChange default: event = EventRepository } payload := RepositoryEvent{ Action: action, Common: Common{ EventType: event, Repository: Repository{ ID: repo.ID(), Name: repo.Name(), Description: repo.Description(), ProjectName: repo.ProjectName(), Private: repo.IsPrivate(), CreatedAt: repo.CreatedAt(), UpdatedAt: repo.UpdatedAt(), }, Sender: User{ ID: user.ID(), Username: user.Username(), }, }, } cfg := config.FromContext(ctx) payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name()) payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name()) payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name()) // Find repo owner. dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID()) if err != nil { return RepositoryEvent{}, db.WrapError(err) } payload.Repository.Owner.ID = owner.ID payload.Repository.Owner.Username = owner.Username payload.Repository.DefaultBranch, _ = getDefaultBranch(repo) return payload, nil } ================================================ FILE: pkg/webhook/ssrf_test.go ================================================ package webhook import ( "context" "errors" "net/http" "testing" "time" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/ssrf" ) // TestSSRFProtection is an integration test verifying the webhook send path // blocks private IPs end-to-end (models.Webhook -> secureHTTPClient -> ssrf). func TestSSRFProtection(t *testing.T) { tests := []struct { name string webhookURL string shouldBlock bool }{ {"block loopback", "http://127.0.0.1:8080/webhook", true}, {"block metadata", "http://169.254.169.254/latest/meta-data/", true}, {"allow public IP", "http://8.8.8.8/webhook", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := models.Webhook{ URL: tt.webhookURL, ContentType: int(ContentTypeJSON), } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "POST", w.URL, nil) if err != nil { t.Fatalf("failed to create request: %v", err) } resp, err := secureHTTPClient.Do(req) if resp != nil { resp.Body.Close() } if tt.shouldBlock { if err == nil { t.Errorf("%s: expected error but got none", tt.name) } } else { if err != nil && errors.Is(err, ssrf.ErrPrivateIP) { t.Errorf("%s: should not block public IPs, got: %v", tt.name, err) } } }) } } ================================================ FILE: pkg/webhook/validator.go ================================================ package webhook import ( "github.com/charmbracelet/soft-serve/pkg/ssrf" ) // Error aliases for backward compatibility. var ( ErrInvalidScheme = ssrf.ErrInvalidScheme ErrPrivateIP = ssrf.ErrPrivateIP ErrInvalidURL = ssrf.ErrInvalidURL ) // ValidateWebhookURL validates that a webhook URL is safe to use. func ValidateWebhookURL(rawURL string) error { return ssrf.ValidateURL(rawURL) //nolint:wrapcheck } // ValidateIPBeforeDial validates an IP address before establishing a connection. var ValidateIPBeforeDial = ssrf.ValidateIPBeforeDial ================================================ FILE: pkg/webhook/validator_test.go ================================================ package webhook import ( "errors" "testing" "github.com/charmbracelet/soft-serve/pkg/ssrf" ) // TestValidateWebhookURL verifies the wrapper delegates correctly and // error aliases work across the package boundary. IP range coverage // is in pkg/ssrf/ssrf_test.go -- here we just confirm the plumbing. func TestValidateWebhookURL(t *testing.T) { tests := []struct { name string url string wantErr bool errType error }{ {"valid", "https://1.1.1.1/webhook", false, nil}, {"bad scheme", "ftp://example.com", true, ErrInvalidScheme}, {"private IP", "http://127.0.0.1/webhook", true, ErrPrivateIP}, {"empty", "", true, ErrInvalidURL}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidateWebhookURL(tt.url) if (err != nil) != tt.wantErr { t.Errorf("ValidateWebhookURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr) return } if tt.wantErr && tt.errType != nil { if !errors.Is(err, tt.errType) { t.Errorf("ValidateWebhookURL(%q) error = %v, want %v", tt.url, err, tt.errType) } } }) } } func TestErrorAliases(t *testing.T) { if ErrPrivateIP != ssrf.ErrPrivateIP { t.Error("ErrPrivateIP should alias ssrf.ErrPrivateIP") } if ErrInvalidScheme != ssrf.ErrInvalidScheme { t.Error("ErrInvalidScheme should alias ssrf.ErrInvalidScheme") } if ErrInvalidURL != ssrf.ErrInvalidURL { t.Error("ErrInvalidURL should alias ssrf.ErrInvalidURL") } } ================================================ FILE: pkg/webhook/webhook.go ================================================ package webhook import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ssrf" "github.com/charmbracelet/soft-serve/pkg/store" "github.com/charmbracelet/soft-serve/pkg/utils" "github.com/charmbracelet/soft-serve/pkg/version" "github.com/google/go-querystring/query" "github.com/google/uuid" ) // Hook is a repository webhook. type Hook struct { models.Webhook ContentType ContentType Events []Event } // Delivery is a webhook delivery. type Delivery struct { models.WebhookDelivery Event Event } // secureHTTPClient is an HTTP client with SSRF protection. var secureHTTPClient = ssrf.NewSecureClient() // do sends a webhook. // Caller must close the returned body. func do(ctx context.Context, url string, method string, headers http.Header, body io.Reader) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return nil, err } req.Header = headers res, err := secureHTTPClient.Do(req) if err != nil { return nil, err } return res, nil } // SendWebhook sends a webhook event. func SendWebhook(ctx context.Context, w models.Webhook, event Event, payload interface{}) error { var buf bytes.Buffer dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) contentType := ContentType(w.ContentType) //nolint:gosec switch contentType { case ContentTypeJSON: if err := json.NewEncoder(&buf).Encode(payload); err != nil { return err } case ContentTypeForm: v, err := query.Values(payload) if err != nil { return err } buf.WriteString(v.Encode()) //nolint: errcheck default: return ErrInvalidContentType } headers := http.Header{} headers.Add("Content-Type", contentType.String()) headers.Add("User-Agent", "SoftServe/"+version.Version) headers.Add("X-SoftServe-Event", event.String()) id, err := uuid.NewUUID() if err != nil { return err } headers.Add("X-SoftServe-Delivery", id.String()) reqBody := buf.String() if w.Secret != "" { sig := hmac.New(sha256.New, []byte(w.Secret)) sig.Write([]byte(reqBody)) //nolint: errcheck headers.Add("X-SoftServe-Signature", "sha256="+hex.EncodeToString(sig.Sum(nil))) } res, reqErr := do(ctx, w.URL, http.MethodPost, headers, &buf) var reqHeaders string for k, v := range headers { reqHeaders += k + ": " + v[0] + "\n" } resStatus := 0 resHeaders := "" resBody := "" if res != nil { resStatus = res.StatusCode for k, v := range res.Header { resHeaders += k + ": " + v[0] + "\n" } if res.Body != nil { defer res.Body.Close() //nolint: errcheck b, err := io.ReadAll(res.Body) if err != nil { return err } resBody = string(b) } } return db.WrapError(datastore.CreateWebhookDelivery(ctx, dbx, id, w.ID, int(event), w.URL, http.MethodPost, reqErr, reqHeaders, reqBody, resStatus, resHeaders, resBody)) } // SendEvent sends a webhook event. func SendEvent(ctx context.Context, payload EventPayload) error { dbx := db.FromContext(ctx) datastore := store.FromContext(ctx) webhooks, err := datastore.GetWebhooksByRepoIDWhereEvent(ctx, dbx, payload.RepositoryID(), []int{int(payload.Event())}) if err != nil { return db.WrapError(err) } for _, w := range webhooks { if err := SendWebhook(ctx, w, payload.Event(), payload); err != nil { return err } } return nil } func repoURL(publicURL string, repo string) string { return fmt.Sprintf("%s/%s.git", publicURL, utils.SanitizeRepo(repo)) } func getDefaultBranch(repo proto.Repository) (string, error) { branch, err := proto.RepositoryDefaultBranch(repo) // XXX: we check for ErrReferenceNotExist here because we don't want to // return an error if the repo is an empty repo. // This means that the repo doesn't have a default branch yet and this is // the first push to it. if err != nil && !errors.Is(err, git.ErrReferenceNotExist) { return "", err } return branch, nil } ================================================ FILE: systemd.md ================================================ # Running Soft Serve as a Systemd Service Most Linux OSes use Systemd as an init system and service management. You can use Systemd to manage Soft Serve as a service on your host machine. Our Soft Serve deb/rpm packages come with Systemd service files pre-packaged. You can install `soft-serve` from our Apt/Yum repositories. Follow the [installation instructions](https://github.com/charmbracelet/soft-serve#installation) for more information. ## Writing a Systemd Service File > **Note** you can skip this section if you are using our deb/rpm packages or > installed Soft Serve from our Apt/Yum repositories. Start by writing a Systemd service file to define how your Soft Serve server should start. First, we need to specify where the data should live for our server. Here I will be choosing `/var/local/lib/soft-serve` to store the server's data. Soft Serve will look for this path in the `SOFT_SERVE_DATA_PATH` environment variable. Make sure this directory exists before proceeding. ```sh sudo mkdir -p /var/local/lib/soft-serve ``` We will also create a `/etc/soft-serve.conf` file for any extra server settings that we want to override. ```conf # Config defined here will override the config in /var/local/lib/soft-serve/config.yaml # Keys defined in `SOFT_SERVE_INITIAL_ADMIN_KEYS` will be merged with # the `initial_admin_keys` from /var/local/lib/soft-serve/config.yaml. # #SOFT_SERVE_GIT_LISTEN_ADDR=:9418 #SOFT_SERVE_HTTP_LISTEN_ADDR=:23232 #SOFT_SERVE_SSH_LISTEN_ADDR=:23231 #SOFT_SERVE_SSH_KEY_PATH=ssh/soft_serve_host_ed25519 #SOFT_SERVE_INITIAL_ADMIN_KEYS='ssh-ed25519 AAAAC3NzaC1lZDI1...' ``` > **Note** Soft Serve stores its server configuration and settings in > `config.yaml` under its _data path_ directory specified using > `SOFT_SERVE_DATA_PATH` environment variable. Now, let's write a new `/etc/systemd/system/soft-serve.service` Systemd service file: ```conf [Unit] Description=Soft Serve git server 🍦 Documentation=https://github.com/charmbracelet/soft-serve Requires=network-online.target After=network-online.target [Service] Type=simple Restart=always RestartSec=1 ExecStart=/usr/bin/soft serve Environment=SOFT_SERVE_DATA_PATH=/var/local/lib/soft-serve EnvironmentFile=-/etc/soft-serve.conf WorkingDirectory=/var/local/lib/soft-serve [Install] WantedBy=multi-user.target ``` Great, we now have a Systemd service file for Soft Serve. The settings defined here may vary depending on your specific setup. This assumes that you want to run Soft Serve as `root`. For more information on Systemd service files, refer to [systemd.service](https://www.freedesktop.org/software/systemd/man/systemd.service.html) ## Start Soft Serve on boot Now that we have our Soft Serve Systemd service file in-place, let's go ahead and enable and start Soft Serve to run on-boot. ```sh # Reload systemd daemon sudo systemctl daemon-reload # Enable Soft Serve to start on-boot sudo systemctl enable soft-serve.service # Start Soft Serve now!! sudo systemctl start soft-serve.service ``` You can monitor the server logs using `journalctl -u soft-serve.service`. Use `-f` to _tail_ and follow the logs as they get written. *** Part of [Charm](https://charm.sh). The Charm logo Charm热爱开源 • Charm loves open source ================================================ FILE: testscript/script_test.go ================================================ package testscript import ( "bytes" "context" "encoding/json" "flag" "fmt" "io" "math/rand" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "testing" "time" "github.com/charmbracelet/keygen" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/test" "github.com/rogpeppe/go-internal/testscript" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" ) var ( update = flag.Bool("update", false, "update script files") binPath string ) func PrepareBuildCommand(binPath string) *exec.Cmd { _, disableRaceSet := os.LookupEnv("SOFT_SERVE_DISABLE_RACE_CHECKS") if disableRaceSet { // don't add the -race flag return exec.Command("go", "build", "-cover", "-o", binPath, filepath.Join("..", "cmd", "soft")) //nolint:noctx } return exec.Command("go", "build", "-race", "-cover", "-o", binPath, filepath.Join("..", "cmd", "soft")) //nolint:noctx } func TestMain(m *testing.M) { tmp, err := os.MkdirTemp("", "soft-serve*") if err != nil { fmt.Fprintf(os.Stderr, "failed to create temporary directory: %s", err) os.Exit(1) } defer os.RemoveAll(tmp) binPath = filepath.Join(tmp, "soft") if runtime.GOOS == "windows" { binPath += ".exe" } // Build the soft binary with -cover flag. cmd := PrepareBuildCommand(binPath) if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "failed to build soft-serve binary: %s", err) os.Exit(1) } // Run tests os.Exit(m.Run()) } func TestScript(t *testing.T) { flag.Parse() mkkey := func(name string) (string, *keygen.SSHKeyPair) { path := filepath.Join(t.TempDir(), name) pair, err := keygen.New(path, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) if err != nil { t.Fatal(err) } return path, pair } admin1Key, admin1 := mkkey("admin1") _, admin2 := mkkey("admin2") user1Key, user1 := mkkey("user1") attackerKey, attacker := mkkey("attacker") attackerSigner := &maliciousSigner{ publicKey: admin1.PublicKey(), } testscript.Run(t, testscript.Params{ Dir: "./testdata/", UpdateScripts: *update, RequireExplicitExec: true, Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ "soft": cmdSoft("admin", admin1.Signer()), "usoft": cmdSoft("user1", user1.Signer()), "attacksoft": cmdSoft("attacker", attackerSigner, attacker.Signer()), "git": cmdGit(admin1Key), "ugit": cmdGit(user1Key), "agit": cmdGit(attackerKey), "curl": cmdCurl, "mkfile": cmdMkfile, "envfile": cmdEnvfile, "readfile": cmdReadfile, "dos2unix": cmdDos2Unix, "new-webhook": cmdNewWebhook, "ensureserverrunning": cmdEnsureServerRunning, "ensureservernotrunning": cmdEnsureServerNotRunning, "stopserver": cmdStopserver, "ui": cmdUI(admin1.Signer()), "uui": cmdUI(user1.Signer()), }, Setup: func(e *testscript.Env) error { // Add binPath to PATH e.Setenv("PATH", fmt.Sprintf("%s%c%s", filepath.Dir(binPath), os.PathListSeparator, e.Getenv("PATH"))) data := t.TempDir() sshPort := test.RandomPort() sshListen := fmt.Sprintf("localhost:%d", sshPort) gitPort := test.RandomPort() gitListen := fmt.Sprintf("localhost:%d", gitPort) httpPort := test.RandomPort() httpListen := fmt.Sprintf("localhost:%d", httpPort) statsPort := test.RandomPort() statsListen := fmt.Sprintf("localhost:%d", statsPort) serverName := "Test Soft Serve" e.Setenv("DATA_PATH", data) e.Setenv("SSH_PORT", fmt.Sprintf("%d", sshPort)) e.Setenv("HTTP_PORT", fmt.Sprintf("%d", httpPort)) e.Setenv("STATS_PORT", fmt.Sprintf("%d", statsPort)) e.Setenv("GIT_PORT", fmt.Sprintf("%d", gitPort)) e.Setenv("ADMIN1_AUTHORIZED_KEY", admin1.AuthorizedKey()) e.Setenv("ADMIN2_AUTHORIZED_KEY", admin2.AuthorizedKey()) e.Setenv("USER1_AUTHORIZED_KEY", user1.AuthorizedKey()) e.Setenv("ATTACKER_AUTHORIZED_KEY", attacker.AuthorizedKey()) e.Setenv("SSH_KNOWN_HOSTS_FILE", filepath.Join(t.TempDir(), "known_hosts")) e.Setenv("SSH_KNOWN_CONFIG_FILE", filepath.Join(t.TempDir(), "config")) // This is used to set up test specific configuration and http endpoints e.Setenv("SOFT_SERVE_TESTRUN", "1") // This will disable the default lipgloss renderer colors e.Setenv("SOFT_SERVE_NO_COLOR", "1") // Soft Serve debug environment variables for _, env := range []string{ "SOFT_SERVE_DEBUG", "SOFT_SERVE_VERBOSE", } { if v, ok := os.LookupEnv(env); ok { e.Setenv(env, v) } } // TODO: test different configs cfg := config.DefaultConfig() cfg.DataPath = data cfg.Name = serverName cfg.InitialAdminKeys = []string{admin1.AuthorizedKey()} cfg.SSH.ListenAddr = sshListen cfg.SSH.PublicURL = "ssh://" + sshListen cfg.Git.ListenAddr = gitListen cfg.HTTP.ListenAddr = httpListen cfg.HTTP.PublicURL = "http://" + httpListen cfg.Stats.ListenAddr = statsListen cfg.LFS.Enabled = true // Parse os SOFT_SERVE environment variables if err := cfg.ParseEnv(); err != nil { return err } // Override the database data source if we're using postgres // so we can create a temporary database for the tests. if cfg.DB.Driver == "postgres" { cleanup, err := setupPostgres(e.T(), cfg) if err != nil { return err } if cleanup != nil { e.Defer(cleanup) } } for _, env := range cfg.Environ() { parts := strings.SplitN(env, "=", 2) if len(parts) != 2 { e.T().Fatal("invalid environment variable", env) } e.Setenv(parts[0], parts[1]) } return nil }, }) } func cmdSoft(user string, keys ...ssh.Signer) func(ts *testscript.TestScript, neg bool, args []string) { return func(ts *testscript.TestScript, neg bool, args []string) { cli, err := ssh.Dial( "tcp", net.JoinHostPort("localhost", ts.Getenv("SSH_PORT")), &ssh.ClientConfig{ User: user, Auth: []ssh.AuthMethod{ssh.PublicKeys(keys...)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), }, ) ts.Check(err) defer cli.Close() sess, err := cli.NewSession() ts.Check(err) defer sess.Close() sess.Stdout = ts.Stdout() sess.Stderr = ts.Stderr() check(ts, sess.Run(strings.Join(args, " ")), neg) } } func cmdUI(key ssh.Signer) func(ts *testscript.TestScript, neg bool, args []string) { return func(ts *testscript.TestScript, neg bool, args []string) { if len(args) < 1 { ts.Fatalf("usage: ui ") return } cli, err := ssh.Dial( "tcp", net.JoinHostPort("localhost", ts.Getenv("SSH_PORT")), &ssh.ClientConfig{ User: "git", Auth: []ssh.AuthMethod{ssh.PublicKeys(key)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), }, ) check(ts, err, neg) defer cli.Close() sess, err := cli.NewSession() check(ts, err, neg) defer sess.Close() // XXX: this is a hack to make the UI tests work // cmp command always complains about an extra newline // in the output defer ts.Stdout().Write([]byte("\n")) sess.Stdout = ts.Stdout() sess.Stderr = ts.Stderr() stdin, err := sess.StdinPipe() check(ts, err, neg) err = sess.RequestPty("dumb", 40, 80, ssh.TerminalModes{}) check(ts, err, neg) check(ts, sess.Start(""), neg) in, err := strconv.Unquote(args[0]) check(ts, err, neg) reader := strings.NewReader(in) go func() { defer stdin.Close() for { r, _, err := reader.ReadRune() if err == io.EOF { break } check(ts, err, neg) _, _ = io.WriteString(stdin, string(r)) // Wait for the UI to process the input time.Sleep(100 * time.Millisecond) } }() check(ts, sess.Wait(), neg) } } func cmdDos2Unix(ts *testscript.TestScript, neg bool, args []string) { if neg { ts.Fatalf("unsupported: ! dos2unix") } if len(args) < 1 { ts.Fatalf("usage: dos2unix paths...") } for _, arg := range args { filename := ts.MkAbs(arg) data, err := os.ReadFile(filename) if err != nil { ts.Fatalf("%s: %v", filename, err) } // Replace all '\r\n' with '\n'. data = bytes.ReplaceAll(data, []byte{'\r', '\n'}, []byte{'\n'}) if err := os.WriteFile(filename, data, 0o644); err != nil { ts.Fatalf("%s: %v", filename, err) } } } var sshConfig = ` Host * UserKnownHostsFile %q StrictHostKeyChecking no IdentityAgent none IdentitiesOnly yes ServerAliveInterval 60 ` func cmdGit(key string) func(ts *testscript.TestScript, neg bool, args []string) { return func(ts *testscript.TestScript, neg bool, args []string) { ts.Check(os.WriteFile( ts.Getenv("SSH_KNOWN_CONFIG_FILE"), []byte(fmt.Sprintf(sshConfig, ts.Getenv("SSH_KNOWN_HOSTS_FILE"))), 0o600, )) sshArgs := []string{ "-F", filepath.ToSlash(ts.Getenv("SSH_KNOWN_CONFIG_FILE")), "-i", filepath.ToSlash(key), } ts.Setenv( "GIT_SSH_COMMAND", strings.Join(append([]string{"ssh"}, sshArgs...), " "), ) // Disable git prompting for credentials. ts.Setenv("GIT_TERMINAL_PROMPT", "0") args = append([]string{ "-c", "user.email=john@example.com", "-c", "user.name=John Doe", }, args...) check(ts, ts.Exec("git", args...), neg) } } func cmdMkfile(ts *testscript.TestScript, neg bool, args []string) { if len(args) < 2 { ts.Fatalf("usage: mkfile path content") } check(ts, os.WriteFile( ts.MkAbs(args[0]), []byte(strings.Join(args[1:], " ")), 0o644, ), neg) } func check(ts *testscript.TestScript, err error, neg bool) { if neg && err == nil { ts.Fatalf("expected error, got nil") } if !neg { ts.Check(err) } } func cmdReadfile(ts *testscript.TestScript, neg bool, args []string) { ts.Stdout().Write([]byte(ts.ReadFile(args[0]))) } func cmdEnvfile(ts *testscript.TestScript, neg bool, args []string) { if len(args) < 1 { ts.Fatalf("usage: envfile key=file...") } for _, arg := range args { parts := strings.SplitN(arg, "=", 2) if len(parts) != 2 { ts.Fatalf("usage: envfile key=file...") } key := parts[0] file := parts[1] ts.Setenv(key, strings.TrimSpace(ts.ReadFile(file))) } } func cmdNewWebhook(ts *testscript.TestScript, neg bool, args []string) { type webhookSite struct { UUID string `json:"uuid"` } if len(args) != 1 { ts.Fatalf("usage: new-webhook ") } const whSite = "https://webhook.site" req, err := http.NewRequest(http.MethodPost, whSite+"/token", nil) //nolint:noctx check(ts, err, neg) resp, err := http.DefaultClient.Do(req) check(ts, err, neg) defer resp.Body.Close() var site webhookSite check(ts, json.NewDecoder(resp.Body).Decode(&site), neg) ts.Setenv(args[0], whSite+"/"+site.UUID) } func cmdCurl(ts *testscript.TestScript, neg bool, args []string) { var verbose bool var headers []string var data string method := http.MethodGet cmd := &cobra.Command{ Use: "curl", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { url, err := url.Parse(args[0]) if err != nil { return err } req, err := http.NewRequest(method, url.String(), nil) //nolint:noctx if err != nil { return err } if data != "" { req.Body = io.NopCloser(strings.NewReader(data)) } if verbose { fmt.Fprintf(cmd.ErrOrStderr(), "< %s %s\n", req.Method, url.String()) } for _, header := range headers { parts := strings.SplitN(header, ":", 2) if len(parts) != 2 { return fmt.Errorf("invalid header: %s", header) } req.Header.Add(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) } if userInfo := url.User; userInfo != nil { password, _ := userInfo.Password() req.SetBasicAuth(userInfo.Username(), password) } if verbose { for key, values := range req.Header { for _, value := range values { fmt.Fprintf(cmd.ErrOrStderr(), "< %s: %s\n", key, value) } } } resp, err := http.DefaultClient.Do(req) if err != nil { return err } if verbose { fmt.Fprintf(ts.Stderr(), "> %s\n", resp.Status) for key, values := range resp.Header { for _, value := range values { fmt.Fprintf(cmd.ErrOrStderr(), "> %s: %s\n", key, value) } } } defer resp.Body.Close() buf, err := io.ReadAll(resp.Body) if err != nil { return err } cmd.Print(string(buf)) return nil }, } cmd.SetArgs(args) cmd.SetOut(ts.Stdout()) cmd.SetErr(ts.Stderr()) cmd.Flags().BoolVarP(&verbose, "verbose", "v", verbose, "verbose") cmd.Flags().StringArrayVarP(&headers, "header", "H", nil, "HTTP header") cmd.Flags().StringVarP(&method, "request", "X", method, "HTTP method") cmd.Flags().StringVarP(&data, "data", "d", data, "HTTP data") check(ts, cmd.Execute(), neg) } func cmdEnsureServerRunning(ts *testscript.TestScript, neg bool, args []string) { if len(args) < 1 { ts.Fatalf("Must supply a TCP port of one of the services to connect to. " + "These are set as env vars as they are randomized. " + "Example usage: \"cmdensureserverrunning SSH_PORT\"\n" + "Valid values for the env var: SSH_PORT|HTTP_PORT|GIT_PORT|STATS_PORT") } port := ts.Getenv(args[0]) // verify that the server is up addr := net.JoinHostPort("localhost", port) for { conn, _ := net.DialTimeout( //nolint:noctx "tcp", addr, time.Second, ) if conn != nil { ts.Logf("Server is running on port: %s", port) conn.Close() break } } } func cmdEnsureServerNotRunning(ts *testscript.TestScript, neg bool, args []string) { if len(args) < 1 { ts.Fatalf("Must supply a TCP port of one of the services to connect to. " + "These are set as env vars as they are randomized. " + "Example usage: \"cmdensureservernotrunning SSH_PORT\"\n" + "Valid values for the env var: SSH_PORT|HTTP_PORT|GIT_PORT|STATS_PORT") } port := ts.Getenv(args[0]) // verify that the server is not up addr := net.JoinHostPort("localhost", port) conn, _ := net.DialTimeout( //nolint:noctx "tcp", addr, time.Second, ) if conn != nil { ts.Fatalf("server is running on port %s while it should not be running", port) conn.Close() } } func cmdStopserver(ts *testscript.TestScript, neg bool, args []string) { // stop the server resp, err := http.DefaultClient.Head(fmt.Sprintf("%s/__stop", ts.Getenv("SOFT_SERVE_HTTP_PUBLIC_URL"))) //nolint:noctx check(ts, err, neg) resp.Body.Close() time.Sleep(time.Second * 2) // Allow some time for the server to stop } func setupPostgres(t testscript.T, cfg *config.Config) (func(), error) { // Indicates postgres // Create a disposable database rnd := rand.New(rand.NewSource(time.Now().UnixNano())) dbName := fmt.Sprintf("softserve_test_%d", rnd.Int63()) dbDsn := cfg.DB.DataSource if dbDsn == "" { cfg.DB.DataSource = "postgres://postgres@localhost:5432/postgres?sslmode=disable" } dbUrl, err := url.Parse(cfg.DB.DataSource) if err != nil { return nil, err } scheme := dbUrl.Scheme if scheme == "" { scheme = "postgres" } host := dbUrl.Hostname() if host == "" { host = "localhost" } connInfo := fmt.Sprintf("host=%s sslmode=disable", host) username := dbUrl.User.Username() if username != "" { connInfo += fmt.Sprintf(" user=%s", username) password, ok := dbUrl.User.Password() if ok { username = fmt.Sprintf("%s:%s", username, password) connInfo += fmt.Sprintf(" password=%s", password) } username = fmt.Sprintf("%s@", username) } else { connInfo += " user=postgres" username = "postgres@" } port := dbUrl.Port() if port != "" { connInfo += fmt.Sprintf(" port=%s", port) port = fmt.Sprintf(":%s", port) } cfg.DB.DataSource = fmt.Sprintf("%s://%s%s%s/%s?sslmode=disable", scheme, username, host, port, dbName, ) // Create the database dbx, err := db.Open(context.TODO(), cfg.DB.Driver, connInfo) if err != nil { return nil, err } if _, err := dbx.ExecContext(context.TODO(), "CREATE DATABASE "+dbName); err != nil { return nil, err } return func() { dbx, err := db.Open(context.TODO(), cfg.DB.Driver, connInfo) if err != nil { t.Fatal("failed to open database", dbName, err) } if _, err := dbx.ExecContext(context.TODO(), "DROP DATABASE "+dbName); err != nil { t.Fatal("failed to drop database", dbName, err) } }, nil } type maliciousSigner struct { publicKey ssh.PublicKey } var _ ssh.Signer = (*maliciousSigner)(nil) // PublicKey implements ssh.Signer. func (m *maliciousSigner) PublicKey() ssh.PublicKey { return m.publicKey } // Sign implements ssh.Signer. func (m *maliciousSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) { // The attacker doesn't know how to sign the data without a private key. return &ssh.Signature{}, nil } ================================================ FILE: testscript/testdata/anon-access.txtar ================================================ # vi: set ft=conf # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # set settings soft settings allow-keyless true soft settings anon-access no-access # create a repo soft repo create repo1 git clone ssh://localhost:$SSH_PORT/repo1 repo1 mkfile ./repo1/README.md '# Hello\n\nwelcome' git -C repo1 add -A git -C repo1 commit -m 'first' git -C repo1 push origin HEAD # access repo from anon ! ugit clone ssh://localhost:$SSH_PORT/repo1 urepo1 stderr 'Error: you are not authorized to do this' # list repo as anon usoft repo list stdout '' # create repo as anon ! usoft repo create urepo2 stderr 'Error: unauthorized' # stop the server [windows] stopserver ================================================ FILE: testscript/testdata/auth-bypass-regression.txtar ================================================ # vi: set ft=conf # Regression test for authentication bypass vulnerability # # VULNERABILITY DESCRIPTION: # A critical authentication bypass allows an attacker to impersonate any user # (including Admin) by offering the user's public key but failing to sign with # it, then successfully authenticating with their own key. # # ATTACK SCENARIO: # 1. Attacker obtains Admin's public key (publicly available) # 2. Attacker configures SSH client to offer TWO keys in sequence: # - First: Admin's public key (attacker has this but not the private key) # - Second: Attacker's own valid key pair # 3. During SSH handshake: # - Server sees admin's public key offered # - PublicKeyHandler() is called, looks up admin user, stores in context # - Server requests signature with admin's key # - Attacker can't sign (doesn't have admin's private key), this key fails # - Server tries next key (attacker's key) # - PublicKeyHandler() called again with attacker's key # - Server requests signature with attacker's key # - Attacker signs successfully with their own private key # 4. Admin user is still in context from step 3, even though authentication # succeeded with attacker's key! # 5. Attacker gains full Admin privileges # # THIS TEST VERIFIES: # - Using "attacksoft" command which offers both admin and attacker keys # - Attacker should NOT be able to perform admin user operations # - Attacker should NOT gain admin user privileges [windows] dos2unix notauthorizederr.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # Create a private repo as admin that only admin can access soft repo create admin-only-repo -p # TEST 1: Simulate the attack using attacksoft command ! attacksoft repo create attacker-created-repo # TEST 2: Verify attacker cannot access admin's private repo ! attacksoft git-upload-pack admin-only-repo cmp stderr notauthorizederr.txt # TEST 3: Verify admin can still create repos (sanity check) soft repo create admin-created-repo # TEST 4: Verify attacker cannot delete admin's repo ! attacksoft repo delete admin-only-repo # TEST 5: Verify attacker cannot change settings ! attacksoft settings anon-access read-write # stop the server [windows] stopserver # [windows] ! stderr . # FIXME: Windows returns error here, investigate and fix -- notauthorizederr.txt -- Error: you are not authorized to do this ================================================ FILE: testscript/testdata/config-servers-git_disabled.txtar ================================================ # vi: set ft=conf # disable git listening env SOFT_SERVE_SSH_ENABLED=true env SOFT_SERVE_GIT_ENABLED=false env SOFT_SERVE_HTTP_ENABLED=true env SOFT_SERVE_STATS_ENABLED=true # start soft serve exec soft serve --sync-hooks & # wait for the ssh + other servers to come up ensureserverrunning SSH_PORT ensureserverrunning HTTP_PORT ensureserverrunning STATS_PORT # ensure that the disabled server is not running ensureservernotrunning GIT_PORT ================================================ FILE: testscript/testdata/config-servers-http_disabled.txtar ================================================ # vi: set ft=conf # disable http listening env SOFT_SERVE_SSH_ENABLED=true env SOFT_SERVE_GIT_ENABLED=true env SOFT_SERVE_HTTP_ENABLED=false env SOFT_SERVE_STATS_ENABLED=true # start soft serve exec soft serve --sync-hooks & # wait for the ssh + other servers to come up ensureserverrunning SSH_PORT ensureserverrunning GIT_PORT ensureserverrunning STATS_PORT # ensure that the disabled server is not running ensureservernotrunning HTTP_PORT ================================================ FILE: testscript/testdata/config-servers-ssh_disabled.txtar ================================================ # vi: set ft=conf # disable ssh listening env SOFT_SERVE_SSH_ENABLED=false env SOFT_SERVE_GIT_ENABLED=true env SOFT_SERVE_HTTP_ENABLED=true env SOFT_SERVE_STATS_ENABLED=true # start soft serve exec soft serve --sync-hooks & # wait for the git + other servers to come up ensureserverrunning GIT_PORT ensureserverrunning HTTP_PORT ensureserverrunning STATS_PORT # ensure that the disabled server is not running ensureservernotrunning SSH_PORT ================================================ FILE: testscript/testdata/config-servers-stats_disabled.txtar ================================================ # vi: set ft=conf # disable stats listening env SOFT_SERVE_SSH_ENABLED=true env SOFT_SERVE_GIT_ENABLED=true env SOFT_SERVE_HTTP_ENABLED=true env SOFT_SERVE_STATS_ENABLED=false # start soft serve exec soft serve --sync-hooks & # wait for the ssh + other servers to come up ensureserverrunning SSH_PORT ensureserverrunning GIT_PORT ensureserverrunning HTTP_PORT # ensure that the disabled server is not running ensureservernotrunning STATS_PORT ================================================ FILE: testscript/testdata/help.txtar ================================================ # vi: set ft=conf [windows] dos2unix help.txt # start soft serve exec soft serve --sync-hooks & # wait for SSH server to start ensureserverrunning SSH_PORT soft --help cmpenv stdout help.txt # stop the server [windows] stopserver [windows] ! stderr . -- help.txt -- Soft Serve is a self-hostable Git server for the command line. Usage: ssh -p $SSH_PORT localhost [command] Available Commands: help Help about any command info Show your info jwt Generate a JSON Web Token pubkey Manage your public keys repo Manage repositories set-username Set your username settings Manage server settings token Manage access tokens user Manage users Flags: -h, --help help for this command Use "ssh -p $SSH_PORT localhost [command] --help" for more information about a command. ================================================ FILE: testscript/testdata/http-cors.txtar ================================================ # vi: set ft=conf # FIXME: don't skip windows [windows] skip 'curl makes github actions hang' # convert crlf to lf on windows [windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create user soft user create user1 --key "$USER1_AUTHORIZED_KEY" # create access token soft token create --expires-in '1h' 'repo2' cp stdout tokenfile envfile TOKEN=tokenfile soft token create --expires-in '1ns' 'repo2' cp stdout etokenfile envfile ETOKEN=etokenfile usoft token create 'repo2' cp stdout utokenfile envfile UTOKEN=utokenfile # push & create repo with some files, commits, tags... mkdir ./repo2 git -c init.defaultBranch=master -C repo2 init mkfile ./repo2/README.md '# Project\nfoo' mkfile ./repo2/foo.png 'foo' mkfile ./repo2/bar.png 'bar' git -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2 git -C repo2 lfs install --local git -C repo2 lfs track '*.png' git -C repo2 add -A git -C repo2 commit -m 'first' git -C repo2 tag v0.1.0 git -C repo2 push origin HEAD git -C repo2 push origin HEAD --tags -- test 1 -- # default public url is always allowed curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2/git-upload-pack -H 'Origin: http://localhost:23232' -H 'Access-Control-Request-Method: POST' stderr '.*200 OK.*' # stop the server stopserver -- test 2 -- # by default the server does not allow example.com, so the response does not have the "Access-Control-Allow-Origin" header and cors will fail. # restart soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2/git-upload-pack -H 'Origin: https://example.com' -H 'Access-Control-Request-Method: POST' ! stderr '.*Access-Control-Allow-Origin.*' # stop the server stopserver -- test 3 -- # allow cross-origin OPTIONS requests for example.com env SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS="https://example.com" env SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS="GET,OPTIONS" env SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS="Origin,Access-Control-Request-Method" # restart soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2.git/info/refs -H 'Origin: https://example.com' -H 'Access-Control-Request-Method: GET' stderr '.*200 OK.*' # stop the server [windows] stopserver [windows] ! stderr . ================================================ FILE: testscript/testdata/http.txtar ================================================ # vi: set ft=conf # FIXME: don't skip windows [windows] skip 'curl makes github actions hang' # convert crlf to lf on windows [windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create user soft user create user1 --key "$USER1_AUTHORIZED_KEY" # create access token soft token create --expires-in '1h' 'repo2' stdout 'ss_*' cp stdout tokenfile envfile TOKEN=tokenfile soft token create --expires-in '1ns' 'repo2' stdout 'ss_*' cp stdout etokenfile envfile ETOKEN=etokenfile usoft token create 'repo2' stdout 'ss_*' cp stdout utokenfile envfile UTOKEN=utokenfile # push & create repo with some files, commits, tags... mkdir ./repo2 git -c init.defaultBranch=master -C repo2 init mkfile ./repo2/README.md '# Project\nfoo' mkfile ./repo2/foo.png 'foo' mkfile ./repo2/bar.png 'bar' git -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2 git -C repo2 lfs install --local git -C repo2 lfs track '*.png' git -C repo2 add -A git -C repo2 commit -m 'first' git -C repo2 tag v0.1.0 git -C repo2 push origin HEAD git -C repo2 push origin HEAD --tags # dumb http git curl -XGET http://localhost:$HTTP_PORT/repo2.git/info/refs stdout '[0-9a-z]{40} refs/heads/master\n[0-9a-z]{40} refs/tags/v0.1.0' # http errors curl -XGET http://localhost:$HTTP_PORT/repo2111foobar.git/foo/bar stdout '404.*' curl -XGET http://localhost:$HTTP_PORT/repo2111/foobar.git/foo/bar stdout '404.*' curl -XGET http://localhost:$HTTP_PORT/repo2.git/foo/bar stdout '404.*' curl -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/foo stdout '404.*' curl -XGET http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch stdout '.*Method Not Allowed.*' curl -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch stdout '.*Not Acceptable.*' curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch stdout '.*validation error.*' curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch stdout '.*no objects found.*' curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"download","transfers":["foo"]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch stdout '.*unsupported transfer.*' curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"bar","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch stdout '.*unsupported operation.*' curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"download","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch cmp stdout http1.txt curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"upload","objects":[{}]}' http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch stdout '.*write access required.*' curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"upload","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch cmp stdout http1.txt # go-get allow (public repo) curl http://localhost:$HTTP_PORT/repo2.git?go-get=1 cmpenv stdout goget.txt curl http://localhost:$HTTP_PORT/repo2.git/subpackage?go-get=1 cmpenv stdout goget.txt curl http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1 cmpenv stdout goget.txt # go-get not found (invalid method) curl -XPOST http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1 stdout '404.*' # go-get not found (invalid repo) curl -XPOST http://localhost:$HTTP_PORT/repo299/subpackage?go-get=1 stdout '404.*' # set private soft repo private repo2 true # allow access private curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch cmp stdout http2.txt curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$ETOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch cmp stdout http3.txt # deny access private curl http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch stdout '.*credentials needed.*' curl http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch stdout '.*credentials needed.*' curl http://0$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch cmp stdout http3.txt # deny dumb http git curl -XGET http://localhost:$HTTP_PORT/repo2.git/info/refs stdout '404.*' # deny access ask for credentials # this means the server responded with a 401 and prompted for credentials # but we disable git terminal prompting to we get a fatal instead of a 401 "Unauthorized" ! git clone http://localhost:$HTTP_PORT/repo2 repo2_clone cmpenv stderr gitclone.txt ! git clone http://someuser:somepassword@localhost:$HTTP_PORT/repo2 repo2_clone stderr '.*403.*' # go-get not found (private repo) curl http://localhost:$HTTP_PORT/repo2.git?go-get=1 stdout '404.*' # go-get forbidden (private repo & expired token) curl http://$ETOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1 stdout '403.*' # go-get not found (private repo & different user) curl http://$UTOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1 stdout '404.*' # go-get with creds curl http://$TOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1 cmpenv stdout goget.txt # stop the server [windows] stopserver [windows] ! stderr . -- http1.txt -- {"transfer":"basic","objects":[{"oid":"","size":0,"error":{"code":422,"message":"invalid object"}}],"hash_algo":"sha256"} -- http2.txt -- {"message":"validation error in request: EOF"} -- http3.txt -- {"message":"bad credentials"} -- goget.txt -- Redirecting to docs at godoc.org/localhost:$HTTP_PORT/repo2... -- gitclone.txt -- Cloning into 'repo2_clone'... fatal: could not read Username for 'http://localhost:$HTTP_PORT': terminal prompts disabled ================================================ FILE: testscript/testdata/jwt.txtar ================================================ # vi: set ft=conf # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create user soft user create user1 --key "$USER1_AUTHORIZED_KEY" # generate jwt token soft jwt stdout '.*\..*\..*' soft jwt repo stdout '.*\..*\..*' usoft jwt stdout '.*\..*\..*' usoft jwt repo stdout '.*\..*\..*' # stop the server [windows] stopserver [windows] ! stderr . ================================================ FILE: testscript/testdata/mirror.txtar ================================================ # vi: set ft=conf # convert crlf to lf on windows [windows] dos2unix info1.txt info2.txt tree.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # import a repo soft repo import --mirror charmbracelet/wizard-tutorial https://github.com/charmbracelet/wizard-tutorial.git # check empty description file readfile $DATA_PATH/repos/charmbracelet/wizard-tutorial.git/description '' # check repo info soft repo info charmbracelet/wizard-tutorial cmp stdout info1.txt # check repo list soft repo list stdout charmbracelet/wizard-tutorial # is-mirror? soft repo is-mirror charmbracelet/wizard-tutorial stdout true # set project name soft repo project-name charmbracelet/wizard-tutorial wizard-tutorial soft repo list stdout wizard-tutorial # check description soft repo description charmbracelet/wizard-tutorial ! stdout . # set description soft repo description charmbracelet/wizard-tutorial "testing repo" soft repo description charmbracelet/wizard-tutorial stdout 'testing repo' readfile $DATA_PATH/repos/charmbracelet/wizard-tutorial.git/description 'testing repo' # rename soft repo rename charmbracelet/wizard-tutorial charmbracelet/test soft repo list stdout charmbracelet/test # TODO: shouldn't this still show the project-name? # check its not private soft repo private charmbracelet/test stdout false exists $DATA_PATH/repos/charmbracelet/test.git/git-daemon-export-ok # make it private soft repo private charmbracelet/test true soft repo private charmbracelet/test stdout true ! exists $DATA_PATH/repos/charmbracelet/test.git/git-daemon-export-ok # check its not hidden soft repo hidden charmbracelet/test stdout false # make it hidden soft repo hidden charmbracelet/test true soft repo hidden charmbracelet/test stdout true # print tree soft repo tree charmbracelet/test cmp stdout tree.txt # check repo info again soft repo info charmbracelet/test cmp stdout info2.txt # get a file soft repo blob charmbracelet/test README.md stdout '.*Wizard.*' # stop the server [windows] stopserver [windows] ! stderr . -- info1.txt -- Project Name: Repository: charmbracelet/wizard-tutorial Description: Private: false Hidden: false Mirror: true Owner: admin Default Branch: main Branches: - main -- info2.txt -- Project Name: wizard-tutorial Repository: charmbracelet/test Description: testing repo Private: true Hidden: true Mirror: true Owner: admin Default Branch: main Branches: - main -- tree.txt -- -rw-r--r-- 10 B .gitignore -rw-r--r-- 1.3 kB README.md -rw-r--r-- 970 B go.mod -rw-r--r-- 5.3 kB go.sum -rw-r--r-- 2.2 kB input.go -rw-r--r-- 2.9 kB main.go ================================================ FILE: testscript/testdata/repo-blob.txtar ================================================ # vi: set ft=conf # convert crlf to lf on windows [windows] dos2unix blob1.txt blob2.txt blob3.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a repo soft repo create repo1 # clone repo git clone ssh://localhost:$SSH_PORT/repo1 repo1 # create some files, commits, tags... mkfile ./repo1/README.md '# Hello\n\nwelcome' mkfile ./repo1/main.go 'package main\nconst foo = 2\n' mkfile ./repo1/.hidden '' mkdir ./repo1/folder mkdir ./repo1/.folder mkfile ./repo1/folder/lib.c '//#include ' git -C repo1 add -A git -C repo1 commit -m 'first' git -C repo1 push origin HEAD # print root blob soft repo blob repo1 README.md cmp stdout blob1.txt # print file blob with revision with line numbers and colors soft repo blob repo1 master main.go -l -c cmp stdout blob2.txt # print file blob with revision within folder with lineno soft repo blob repo1 master folder/lib.c -l cmp stdout blob3.txt # print blob of folder that does not exist ! soft repo blob repo1 folder/nope.txt ! stdout . stderr 'revision does not exist' # print blob of bad revision ! soft repo blob repo1 badrev README.md ! stdout . stderr 'revision does not exist' # stop the server [windows] stopserver -- blob1.txt -- # Hello\n\nwelcome -- blob2.txt -- 1 │ package main\nconst foo = 2\n -- blob3.txt -- 1 │ //#include ================================================ FILE: testscript/testdata/repo-collab.txtar ================================================ # vi: set ft=conf # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # setup soft repo import test https://github.com/charmbracelet/wizard-tutorial.git soft user create foo --key "$USER1_AUTHORIZED_KEY" # list collabs soft repo collab list test ! stdout . # add collab soft repo collab add test foo soft repo collab list test stdout 'foo' # remove collab soft repo collab remove test foo soft repo collab list test ! stdout . # create empty repo soft repo create empty '-d "empty repo"' # add collab soft repo collab add empty foo # add collab again # test issue #464 https://github.com/charmbracelet/soft-serve/issues/464 ! soft repo collab add empty foo stderr '.*already exists.*' # a placeholder to reset stderr soft help # stop the server [windows] stopserver [windows] ! stderr . ================================================ FILE: testscript/testdata/repo-commit.txtar ================================================ # vi: set ft=conf # convert crlf to lf on windows [windows] dos2unix commit1.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a repo soft repo import basic1 https://github.com/git-fixtures/basic # print commit soft repo commit basic1 b8e471f58bcbca63b07bda20e428190409c2db47 cmp stdout commit1.txt # stop the server [windows] stopserver [windows] ! stderr . -- commit1.txt -- commit b8e471f58bcbca63b07bda20e428190409c2db47 Author: Daniel Ripolles Date: Tue Mar 31 11:44:52 UTC 2015 Creating changelog CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000000000000000000000000000000000000..d3ff53e0564a9f87d8e84b6e28e5060e517008aa --- /dev/null +++ b/CHANGELOG @@ -0,0 +1 @@ +Initial changelog ================================================ FILE: testscript/testdata/repo-create.txtar ================================================ # vi: set ft=conf # convert crlf to lf on windows [windows] dos2unix readme.md branch_list.1.txt info.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a repo soft repo create repo1 -d 'description' -H -p -n 'repo11' stderr 'Created repository repo1.*' stdout ssh://localhost:$SSH_PORT/repo1.git soft repo hidden repo1 stdout true soft repo private repo1 stdout true ! exists $DATA_PATH/repos/repo1.git/git-daemon-export-ok soft repo description repo1 stdout 'description' readfile $DATA_PATH/repos/repo1.git/description 'description' soft repo project-name repo1 stdout 'repo1' # clone repo git clone ssh://localhost:$SSH_PORT/repo1 repo1 # create some files, commits, tags... mkfile ./repo1/README.md '# Project\nfoo' git -C repo1 add -A git -C repo1 commit -m 'first' git -C repo1 tag v0.1.0 git -C repo1 push origin HEAD git -C repo1 push origin HEAD --tags # create lfs files, use ssh git-lfs-transfer git -C repo1 lfs install --local git -C repo1 lfs track '*.png' git -C repo1 lfs track '*.mp4' mkfile ./repo1/foo.png 'foo' mkfile ./repo1/bar.png 'bar' git -C repo1 add -A git -C repo1 commit -m 'lfs' git -C repo1 push origin HEAD # info soft repo info repo1 cmp stdout info.txt # list tags soft repo tag list repo1 stdout 'v0.1.0' # delete tag soft repo tag delete repo1 v0.1.0 soft repo tag list repo1 ! stdout . # print tree soft repo tree repo1 cp stdout tree.txt grep '.gitattributes' tree.txt grep 'README.md' tree.txt grep 'foo.png' tree.txt grep 'bar.png' tree.txt # cat blob soft repo blob repo1 README.md cmp stdout readme.md # cat blob that doesn't exist ! soft repo blob repo1 README.txt ! stdout . stderr '.*revision does not exist.*' # check main branch soft repo branch default repo1 stdout master # create a new branch git -C repo1 checkout -b branch1 git -C repo1 push origin branch1 soft repo branch list repo1 cmp stdout branch_list.1.txt # change default branch soft repo branch default repo1 branch1 soft repo branch default repo1 stdout branch1 # cannot delete main branch ! soft repo branch delete repo1 branch1 # delete other branch soft repo branch delete repo1 master soft repo branch list repo1 stdout branch1 # create a new user soft user create bar --key "$USER1_AUTHORIZED_KEY" # user create a repo usoft repo create repo2 -d 'description' -H -p -n 'repo2' stderr 'Created repository repo2.*' stdout ssh://localhost:$SSH_PORT/repo2.git usoft repo hidden repo2 stdout true usoft repo private repo2 stdout true ! exists $DATA_PATH/repos/repo2.git/git-daemon-export-ok usoft repo description repo2 stdout 'description' readfile $DATA_PATH/repos/repo2.git/description 'description' usoft repo project-name repo2 stdout 'repo2' # user delete a repo usoft repo delete repo2 ! exists $DATA_PATH/repos/repo2.git # stop the server [windows] stopserver [windows] ! stderr . -- readme.md -- # Project\nfoo -- branch_list.1.txt -- branch1 master -- info.txt -- Project Name: repo11 Repository: repo1 Description: description Private: true Hidden: true Mirror: false Owner: admin Default Branch: master Branches: - master Tags: - v0.1.0 ================================================ FILE: testscript/testdata/repo-delete.txtar ================================================ # vi: set ft=conf # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT soft repo create repo1 soft repo create repo-to-delete soft repo delete repo-to-delete ! soft repo delete nope stderr '.*not found.*' # missing argument should fail ! soft repo branch delete repo1 stderr 'Error.*accepts 2 arg.*' soft repo list stdout 'repo1' # stop the server [windows] stopserver [windows] ! stderr . ================================================ FILE: testscript/testdata/repo-import-local-path.txtar ================================================ # vi: set ft=conf [windows] skip 'uses a raw server filesystem path as the import remote' # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a private repo and a second user soft repo create secret -p soft user create user1 --key "$USER1_AUTHORIZED_KEY" # seed the private repo with content git clone ssh://localhost:$SSH_PORT/secret secret mkfile ./secret/SECRET.txt 'top secret' git -C secret add -A git -C secret commit -m 'first' git -C secret push origin HEAD # user1 cannot read the private repo directly ! usoft repo info secret stderr 'repository not found' # user1 also must not be able to import the server-local repo path ! usoft repo import stolen "$DATA_PATH/repos/secret.git" --lfs-endpoint http://example.com stderr 'remote must be a network URL' # the failed import must not create a readable repo ! usoft repo info stolen stderr 'repository not found' [windows] stopserver [windows] ! stderr . ================================================ FILE: testscript/testdata/repo-import.txtar ================================================ # vi: set ft=conf # convert crlf to lf on windows [windows] dos2unix repo3.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # import private soft repo import --private repo1 https://github.com/charmbracelet/wizard-tutorial.git soft repo private repo1 stdout 'true' # import hidden soft repo import --hidden repo2 https://github.com/charmbracelet/wizard-tutorial.git soft repo hidden repo2 stdout 'true' # import with name and description soft repo import --name 'repo33' --description 'descriptive' repo3 https://github.com/charmbracelet/wizard-tutorial.git soft repo info repo3 cmp stdout repo3.txt # stop the server [windows] stopserver [windows] ! stderr . -- repo3.txt -- Project Name: repo33 Repository: repo3 Description: descriptive Private: false Hidden: false Mirror: false Owner: admin Default Branch: main Branches: - main ================================================ FILE: testscript/testdata/repo-perms.txtar ================================================ # vi: set ft=conf # convert crlf to lf on windows [windows] dos2unix info.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a repo & user1 with admin soft repo create repo1 -p soft user create user1 -k "$USER1_AUTHORIZED_KEY" # setup repo git clone ssh://localhost:$SSH_PORT/repo1 repo1 mkfile ./repo1/README.md '# Project\nfoo' git -C repo1 add -A git -C repo1 commit -m 'first' git -C repo1 tag v1.0.0 git -C repo1 push origin HEAD git -C repo1 push origin HEAD --tags # admin can access it soft repo tree repo1 soft repo blob repo1 README.md soft repo description repo1 'desc' soft repo project-name repo1 'proj' soft repo private repo1 soft repo info repo1 cmp stdout info.txt # verify no collab soft repo collab list repo1 ! stdout . # regular user can't access it ! usoft repo info repo1 stderr 'repository not found' ! usoft repo tree repo1 stderr 'repository not found' ! usoft repo tag list repo1 stderr 'repository not found' ! usoft repo tag delete repo1 v1.0.0 stderr 'repository not found' ! usoft repo blob repo1 README.md stderr 'repository not found' ! usoft repo description repo1 stderr 'repository not found' ! usoft repo description repo1 'new desc' stderr 'repository not found' ! usoft repo project-name repo1 stderr 'repository not found' ! usoft repo private repo1 true stderr 'repository not found' ! usoft repo private repo1 stderr 'repository not found' ! usoft repo rename repo1 repo11 stderr 'repository not found' ! usoft repo branch default repo1 stderr 'repository not found' ! usoft repo branch default repo1 main stderr 'repository not found' ! usoft repo delete repo1 stderr 'repository not found' # add user1 as collab ! soft repo collab add repo1 user1 foobar stderr 'invalid access level' soft repo collab add repo1 user1 read-write soft repo collab list repo1 stdout user1 usoft repo collab list repo1 stdout user1 # verify user1 has access now usoft repo info repo1 cmp stdout info.txt # delete usoft repo delete repo1 usoft repo list ! stdout . # stop the server [windows] stopserver [windows] ! stderr . -- info.txt -- Project Name: proj Repository: repo1 Description: desc Private: true Hidden: false Mirror: false Owner: admin Default Branch: master Branches: - master Tags: - v1.0.0 ================================================ FILE: testscript/testdata/repo-push.txtar ================================================ # vi: set ft=conf # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a repo soft repo create repo-empty -d 'description' -H -p -n 'repo-empty' # clone repo git clone ssh://localhost:$SSH_PORT/repo-empty repo-empty # push repo without any commits ! git -C repo-empty push origin HEAD # push repo with a commit mkfile ./repo-empty/README.md '# Hello\n\nwelcome' git -C repo-empty add README.md git -C repo-empty commit -m 'first' git -C repo-empty push origin HEAD # stop the server [windows] stopserver ================================================ FILE: testscript/testdata/repo-tree.txtar ================================================ # vi: set ft=conf # convert crlf to lf on windows [windows] dos2unix tree1.txt tree2.txt tree3.txt tree4.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a repo soft repo create repo1 # clone repo git clone ssh://localhost:$SSH_PORT/repo1 repo1 # create some files, commits, tags... mkfile ./repo1/README.md '# Hello' mkfile ./repo1/b.md 'hi' mkfile ./repo1/.hidden '' mkdir ./repo1/folder mkdir ./repo1/.folder mkfile ./repo1/folder/aa.md 'hello' git -C repo1 add -A git -C repo1 commit -m 'first' git -C repo1 push origin HEAD # print root tree soft repo tree repo1 cmp stdout tree1.txt # print folder tree soft repo tree repo1 folder cmp stdout tree2.txt # print file tree with revision soft repo tree repo1 master b.md cmp stdout tree3.txt # print tree of folder that does not exist ! soft repo tree repo1 folder2 ! stdout . stderr 'file not found' # print tree of bad revision ! soft repo tree repo1 badrev folder ! stdout . stderr 'revision does not exist' # test unicode file name issue #457 soft repo create repo4 git clone ssh://localhost:$SSH_PORT/repo4 repo4 mkfile ./repo4/🍕.md '🍕' git -C repo4 add -A git -C repo4 commit -m 'unicode' git -C repo4 push origin HEAD # print root tree soft repo tree repo4 cmp stdout tree4.txt # stop the server [windows] stopserver -- tree1.txt -- drwxrwxrwx - folder -rw-r--r-- - .hidden -rw-r--r-- 7 B README.md -rw-r--r-- 2 B b.md -- tree2.txt -- -rw-r--r-- 5 B aa.md -- tree3.txt -- -rw-r--r-- 2 B b.md -- tree4.txt -- -rw-r--r-- 4 B 🍕.md ================================================ FILE: testscript/testdata/repo-webhook-ssrf.txtar ================================================ # vi: set ft=conf # Test SSRF protection in webhook creation # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a repo soft repo create test-repo stderr 'Created repository test-repo.*' # Try to create webhook with localhost - should fail ! soft repo webhook create test-repo http://localhost:8080/webhook -e push # Try to create webhook with 127.0.0.1 - should fail ! soft repo webhook create test-repo http://127.0.0.1:8080/webhook -e push # Try to create webhook with AWS metadata service - should fail ! soft repo webhook create test-repo http://169.254.169.254/latest/meta-data/ -e push # Try to create webhook with private network - should fail ! soft repo webhook create test-repo http://192.168.1.1/webhook -e push # Try to create webhook with private 10.x network - should fail ! soft repo webhook create test-repo http://10.0.0.1/webhook -e push # Create webhook with valid public IP - should succeed new-webhook WH_PUBLIC soft repo webhook create test-repo $WH_PUBLIC -e push # List webhooks - should show only the valid one soft repo webhook list test-repo stdout 'webhook.site' # Try to update webhook to localhost - should fail ! soft repo webhook update test-repo 1 --url http://localhost:9090/hook # stop the server [windows] stopserver ================================================ FILE: testscript/testdata/repo-webhooks.txtar ================================================ # vi: set ft=conf # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a repo soft repo create repo-123 stderr 'Created repository repo-123.*' stdout ssh://localhost:$SSH_PORT/repo-123.git # create webhook new-webhook WH_REPO_123 soft repo webhook create repo-123 $WH_REPO_123 -e branch_tag_create -e branch_tag_delete -e collaborator -e push -e repository -e repository_visibility_change # list webhooks soft repo webhook list repo-123 stdout '1.*webhook.site/.*' # clone repo and commit files git clone ssh://localhost:$SSH_PORT/repo-123 repo-123 mkfile ./repo-123/README.md 'foobar' git -C repo-123 add -A git -C repo-123 commit -m 'first' git -C repo-123 push origin HEAD # list webhook deliveries soft repo webhook deliver list repo-123 1 stdout '✅.*push.*' # stop the server [windows] stopserver [windows] ! stderr . ================================================ FILE: testscript/testdata/set-username.txtar ================================================ # vi: set ft=conf # convert crlf to lf on windows [windows] dos2unix info1.txt info2.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # get original username soft info cmpenv stdout info1.txt # set another username soft set-username test soft info cmpenv stdout info2.txt # stop the server [windows] stopserver [windows] ! stderr . -- info1.txt -- Username: admin Admin: true Public keys: $ADMIN1_AUTHORIZED_KEY -- info2.txt -- Username: test Admin: true Public keys: $ADMIN1_AUTHORIZED_KEY ================================================ FILE: testscript/testdata/settings.txtar ================================================ # vi: set ft=conf # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # check default allow-keyless soft settings allow-keyless true soft settings allow-keyless stdout 'true.*' # change allow-keyless and check soft settings allow-keyless false soft settings allow-keyless stdout 'false.*' # check default anon-access soft settings anon-access stdout 'read-only.*' # change anon-access to all available options, and check them soft settings anon-access no-access soft settings anon-access stdout 'no-access.*' soft settings anon-access read-only soft settings anon-access stdout 'read-only.*' soft settings anon-access read-write soft settings anon-access stdout 'read-write.*' soft settings anon-access admin-access soft settings anon-access stdout 'admin-access.*' # try to set a bad access ! soft settings anon-access nope ! stdout . stderr . # stop the server [windows] stopserver ================================================ FILE: testscript/testdata/soft-browse.txtar ================================================ # vi: set ft=conf [windows] skip # clone repo #git clone https://github.com/charmbracelet/wizard-tutorial.git wizard-tutorial # run soft browse # disable this temporarily #ttyin input.txt #exec soft browse ./wizard-tutorial # cd and run soft # disable this temporarily #cd wizard-tutorial #ttyin ../input.txt #exec soft -- input.txt -- jjkkdduu jjkkdduu jjkkdduu jjkkdduu jjkkdduu qqq ================================================ FILE: testscript/testdata/soft-manpages.txtar ================================================ # vi: set ft=conf # test `soft man` output exec soft man stdout . ================================================ FILE: testscript/testdata/ssh-lfs.txtar ================================================ # vi: set ft=conf [windows] dos2unix err1.txt err2.txt err3.txt errauth.txt skip 'breaks with git-lfs 3.5.1' # enable ssh lfs transfer env SOFT_SERVE_LFS_SSH_ENABLED=true # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a user soft user create foo --key "$USER1_AUTHORIZED_KEY" # create a repo soft repo create repo1 soft repo create repo1p -p usoft repo create repo2 usoft repo create repo2p -p # SSH Git LFS Transfer command ! soft git-lfs-transfer cmp stderr err1.txt ! soft git-lfs-transfer repo1 cmp stderr err2.txt soft git-lfs-transfer repo1 download stdout '000eversion=1\n000clocking\n0000' soft git-lfs-transfer repo1 upload stdout '000eversion=1\n000clocking\n0000' usoft git-lfs-transfer repo1 download stdout '000eversion=1\n000clocking\n0000' ! usoft git-lfs-transfer repo1 upload cmp stderr errauth.txt # Unauthorized user ! usoft git-lfs-transfer cmp stderr err1.txt ! usoft git-lfs-transfer repo1p cmp stderr err2.txt ! usoft git-lfs-transfer repo1p download cmp stderr errauth.txt ! usoft git-lfs-transfer repo1p upload cmp stderr errauth.txt # push & create repo with some files, commits, tags... mkdir ./repo1 git -c init.defaultBranch=master -C repo1 init mkfile ./repo1/README.md '# Project\nfoo' mkfile ./repo1/foo.png 'foo' mkfile ./repo1/bar.png 'bar' git -C repo1 remote add origin ssh://localhost:$SSH_PORT/repo1 git -C repo1 lfs install --local git -C repo1 lfs track '*.png' git -C repo1 add -A git -C repo1 commit -m 'first' git -C repo1 tag v0.1.0 git -C repo1 push origin HEAD git -C repo1 push origin HEAD --tags # clone repo with ssh lfs-transfer git clone ssh://localhost:$SSH_PORT/repo1 repo1c exists repo1c/README.md exists repo1c/foo.png exists repo1c/bar.png # stop the server [windows] stopserver -- err1.txt -- Error: accepts 2 arg(s), received 0 -- err2.txt -- Error: accepts 2 arg(s), received 1 -- err3.txt -- Error: invalid request -- errauth.txt -- Error: you are not authorized to do this ================================================ FILE: testscript/testdata/ssh.txtar ================================================ # vi: set ft=conf [windows] dos2unix argserr1.txt argserr2.txt argserr3.txt invalidrepoerr.txt notauthorizederr.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create a user soft user create foo --key "$USER1_AUTHORIZED_KEY" # create a repo soft repo create repo1 soft repo create repo1p -p usoft repo create repo2 usoft repo create repo2p -p # SSH Git commands as admin ! soft git-upload-pack cmp stderr argserr1.txt ! soft git-upload-pack foobar cmp stderr invalidrepoerr.txt ! soft git-upload-archive cmp stderr argserr1.txt ! soft git-upload-archive foobar cmp stderr invalidrepoerr.txt ! soft git-receive-pack cmp stderr argserr1.txt ! soft git-receive-pack foobar stdout '.*0000 capabilities.*git.*' # git pack response stderr '.*something went wrong.*' ! soft git-lfs-authenticate cmp stderr argserr2.txt ! soft git-lfs-authenticate foobar cmp stderr argserr3.txt ! soft git-lfs-authenticate foobar download cmp stderr invalidrepoerr.txt ! soft git-lfs-authenticate foobar upload cmp stderr invalidrepoerr.txt soft git-lfs-authenticate repo1 download stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' soft git-lfs-authenticate repo1 upload stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' soft git-lfs-authenticate repo1p download stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' soft git-lfs-authenticate repo1p upload stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' soft git-lfs-authenticate repo2 download stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' soft git-lfs-authenticate repo2 upload stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' soft git-lfs-authenticate repo2p download stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' soft git-lfs-authenticate repo2p upload stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' # SSH Git commands as user ! usoft git-upload-pack cmp stderr argserr1.txt ! usoft git-upload-pack foobar cmp stderr invalidrepoerr.txt ! usoft git-upload-archive cmp stderr argserr1.txt ! usoft git-upload-archive foobar cmp stderr invalidrepoerr.txt ! usoft git-receive-pack cmp stderr argserr1.txt ! usoft git-receive-pack foobar stdout '.*0000 capabilities.*git.*' # git pack response stderr '.*something went wrong.*' ! usoft git-lfs-authenticate cmp stderr argserr2.txt ! usoft git-lfs-authenticate foobar download cmp stderr invalidrepoerr.txt ! usoft git-lfs-authenticate foobar upload cmp stderr invalidrepoerr.txt usoft git-lfs-authenticate repo1 download stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' ! usoft git-lfs-authenticate repo1 upload cmp stderr notauthorizederr.txt ! usoft git-lfs-authenticate repo1p download cmp stderr notauthorizederr.txt ! usoft git-lfs-authenticate repo1p upload cmp stderr notauthorizederr.txt usoft git-lfs-authenticate repo2 download stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' usoft git-lfs-authenticate repo2 upload stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' usoft git-lfs-authenticate repo2p download stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' usoft git-lfs-authenticate repo2p upload stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*' # stop the server [windows] stopserver [windows] ! stderr . -- argserr1.txt -- Error: accepts 1 arg(s), received 0 -- argserr2.txt -- Error: accepts 2 arg(s), received 0 -- argserr3.txt -- Error: accepts 2 arg(s), received 1 -- invalidrepoerr.txt -- Error: invalid repo -- notauthorizederr.txt -- Error: you are not authorized to do this ================================================ FILE: testscript/testdata/token.txtar ================================================ # vi: set ft=conf # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # create user soft user create user1 --key "$USER1_AUTHORIZED_KEY" # generate jwt token usoft token create 'test1' stdout 'ss_.*' stderr 'Access token created' usoft token create --expires-in 1y 'test2' stdout 'ss_.*' stderr 'Access token created' usoft token create --expires-in 1ns 'test3' stdout 'ss_.*' stderr 'Access token created' # list tokens usoft token list cp stdout tokens.txt grep '1.*test1.*-' tokens.txt grep '2.*test2.*1 year from now' tokens.txt grep '3.*est3.*expired' tokens.txt # delete token usoft token delete 1 stderr 'Access token deleted' ! usoft token delete 1 stderr 'token not found' # stop the server [windows] stopserver ================================================ FILE: testscript/testdata/ui-home.txtar ================================================ # vi: set ft=conf # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # test repositories tab ui '" q"' cp stdout home.txt grep 'Test Soft Serve' home.txt grep '• Repositories' home.txt grep 'No items' home.txt # test about tab ui '"\t q"' cp stdout about.txt grep 'Create a `.soft-serve` repository and add a `README.md` file' about.txt # add a new repo soft repo create .soft-serve -n 'Config' -d '"Test Soft Serve"' soft repo description .soft-serve stdout 'Test Soft Serve' soft repo project-name .soft-serve stdout 'Config' # clone repo git clone ssh://localhost:$SSH_PORT/.soft-serve config # create readme file mkfile ./config/README.md '# Hello World\nTest Soft Serve' git -C config add -A git -C config commit -m 'Initial commit' git -C config push origin HEAD # test repositories tab ui '" q"' cp stdout home2.txt grep 'Config' home2.txt grep 'Test Soft Serve' home2.txt grep 'git clone ssh://localhost:.*/.soft-serve' home2.txt # test about tab ui '"\t q"' cp stdout about2.txt grep '• About' about2.txt grep 'Hello World' about2.txt grep 'Test Soft Serve' about2.txt # stop the server [windows] stopserver [windows] ! stderr . ================================================ FILE: testscript/testdata/user_management.txtar ================================================ # vi: set ft=conf # convert crlf to lf on windows [windows] dos2unix info.txt admin_key_list1.txt admin_key_list2.txt list1.txt list2.txt foo_info1.txt foo_info2.txt foo_info3.txt foo_info4.txt foo_info5.txt # start soft serve exec soft serve & # wait for SSH server to start ensureserverrunning SSH_PORT # add key to admin soft user add-pubkey admin "$ADMIN2_AUTHORIZED_KEY" soft user info admin soft info cmpenv stdout info.txt # list admin pubkeys soft pubkey list cmpenv stdout admin_key_list1.txt # remove key soft pubkey remove $ADMIN2_AUTHORIZED_KEY soft pubkey list cmpenv stdout admin_key_list2.txt # add key back key soft pubkey add $ADMIN2_AUTHORIZED_KEY soft pubkey list cmpenv stdout admin_key_list1.txt # list users soft user list cmpenv stdout list1.txt # create a new user soft user create foo --key "$USER1_AUTHORIZED_KEY" soft user list cmpenv stdout list2.txt # get new user info soft user info foo cmpenv stdout foo_info1.txt # make user admin soft user set-admin foo true soft user info foo cmpenv stdout foo_info2.txt # remove admin soft user set-admin foo false soft user info foo cmpenv stdout foo_info3.txt # remove key from user soft user remove-pubkey foo "$USER1_AUTHORIZED_KEY" soft user info foo cmpenv stdout foo_info4.txt # rename user soft user set-username foo foo2 soft user info foo2 cmpenv stdout foo_info5.txt # remove user soft user delete foo2 ! stdout . soft user list cmpenv stdout list1.txt # stop the server [windows] stopserver [windows] ! stderr . -- info.txt -- Username: admin Admin: true Public keys: $ADMIN1_AUTHORIZED_KEY $ADMIN2_AUTHORIZED_KEY -- list1.txt -- admin -- list2.txt -- admin foo -- foo_info1.txt -- Username: foo Admin: false Public keys: $USER1_AUTHORIZED_KEY -- foo_info2.txt -- Username: foo Admin: true Public keys: $USER1_AUTHORIZED_KEY -- foo_info3.txt -- Username: foo Admin: false Public keys: $USER1_AUTHORIZED_KEY -- foo_info4.txt -- Username: foo Admin: false Public keys: -- foo_info5.txt -- Username: foo2 Admin: false Public keys: -- admin_key_list1.txt -- $ADMIN1_AUTHORIZED_KEY $ADMIN2_AUTHORIZED_KEY -- admin_key_list2.txt -- $ADMIN1_AUTHORIZED_KEY