├── .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 │ ├── 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 │ ├── session.go │ ├── session_test.go │ ├── ssh.go │ └── ui.go ├── sshutils │ ├── utils.go │ └── utils_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 │ ├── 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 │ └── webhook.go ├── systemd.md └── testscript ├── script_test.go └── testdata ├── anon-access.txtar ├── config-servers-git_disabled.txtar ├── config-servers-http_disabled.txtar ├── config-servers-ssh_disabled.txtar ├── config-servers-stats_disabled.txtar ├── help.txtar ├── http.txtar ├── jwt.txtar ├── mirror.txtar ├── repo-blob.txtar ├── repo-collab.txtar ├── repo-commit.txtar ├── repo-create.txtar ├── repo-delete.txtar ├── repo-import.txtar ├── repo-perms.txtar ├── repo-push.txtar ├── repo-tree.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 /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset=utf-8 5 | end_of_line=lf 6 | insert_final_newline=true 7 | trim_trailing_whitespace=true 8 | indent_size=2 9 | indent_style=space 10 | 11 | [*.go] 12 | indent_size=4 13 | indent_style=tab 14 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @aymanbagabas 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - OS: [e.g. Linux] 28 | - Terminal [e.g. kitty, iterm2, gnome-terminal] 29 | - Version [e.g. v0.4.0] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "05:00" 10 | timezone: "America/New_York" 11 | labels: 12 | - "dependencies" 13 | commit-message: 14 | prefix: "chore" 15 | include: "scope" 16 | 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | day: "monday" 22 | time: "05:00" 23 | timezone: "America/New_York" 24 | labels: 25 | - "dependencies" 26 | commit-message: 27 | prefix: "chore" 28 | include: "scope" 29 | 30 | - package-ecosystem: "docker" 31 | directory: "/" 32 | schedule: 33 | interval: "weekly" 34 | day: "monday" 35 | time: "05:00" 36 | timezone: "America/New_York" 37 | labels: 38 | - "dependencies" 39 | commit-message: 40 | prefix: "chore" 41 | include: "scope" 42 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | uses: charmbracelet/meta/.github/workflows/build.yml@main 12 | 13 | snapshot: 14 | uses: charmbracelet/meta/.github/workflows/snapshot.yml@main 15 | secrets: 16 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 17 | 18 | test_postgres: 19 | services: 20 | postgres: 21 | image: postgres 22 | ports: 23 | - 5432:5432 24 | env: 25 | POSTGRES_PASSWORD: postgres 26 | options: >- 27 | --health-cmd pg_isready 28 | --health-interval 10s 29 | --health-timeout 5s 30 | --health-retries 5 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout code 34 | uses: actions/checkout@v4 35 | - name: Install Go 36 | uses: actions/setup-go@v5 37 | with: 38 | go-version: ^1 39 | cache: true 40 | - name: Download Go modules 41 | run: go mod download 42 | - name: Test 43 | run: go test ./... 44 | env: 45 | SOFT_SERVE_DB_DRIVER: postgres 46 | SOFT_SERVE_DB_DATA_SOURCE: postgres://postgres:postgres@localhost/postgres?sslmode=disable 47 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | 9 | jobs: 10 | coverage: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] # TODO: add macos & windows 14 | services: 15 | postgres: 16 | image: postgres 17 | ports: 18 | - 5432:5432 19 | env: 20 | POSTGRES_PASSWORD: postgres 21 | options: >- 22 | --health-cmd pg_isready 23 | --health-interval 10s 24 | --health-timeout 5s 25 | --health-retries 5 26 | runs-on: ${{ matrix.os }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Set up Go 31 | uses: actions/setup-go@v5 32 | with: 33 | go-version: ^1 34 | 35 | - name: Test 36 | run: | 37 | # We collect coverage data from two sources, 38 | # 1) unit tests 2) integration tests 39 | # 40 | # https://go.dev/testing/coverage/ 41 | # https://dustinspecker.com/posts/go-combined-unit-integration-code-coverage/ 42 | # https://github.com/golang/go/issues/51430#issuecomment-1344711300 43 | mkdir -p coverage/unit 44 | mkdir -p coverage/int 45 | mkdir -p coverage/int2 46 | 47 | # Collect unit tests coverage 48 | go test -failfast -race -timeout 5m -skip=^TestScript -cover ./... -args -test.gocoverdir=$PWD/coverage/unit 49 | 50 | # Collect integration tests coverage 51 | GOCOVERDIR=$PWD/coverage/int go test -failfast -race -timeout 5m -run=^TestScript ./... 52 | SOFT_SERVE_DB_DRIVER=postgres \ 53 | SOFT_SERVE_DB_DATA_SOURCE=postgres://postgres:postgres@localhost/postgres?sslmode=disable \ 54 | GOCOVERDIR=$PWD/coverage/int2 go test -failfast -race -timeout 5m -run=^TestScript ./... 55 | 56 | # Convert coverage data to legacy textfmt format to upload 57 | go tool covdata textfmt -i=coverage/unit,coverage/int,coverage/int2 -o=coverage.txt 58 | - uses: codecov/codecov-action@v5 59 | with: 60 | file: ./coverage.txt 61 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-sync.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-sync 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # every Sunday at midnight 5 | workflow_dispatch: # allows manual triggering 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-sync: 13 | uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main 14 | with: 15 | repo_name: ${{ github.event.repository.name }} 16 | secrets: 17 | gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | tags: 7 | - v*.*.* 8 | 9 | concurrency: 10 | group: goreleaser 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | goreleaser: 15 | uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main 16 | secrets: 17 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 18 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 19 | gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 20 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 21 | fury_token: ${{ secrets.FURY_TOKEN }} 22 | nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} 23 | nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} 24 | macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} 25 | macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }} 26 | macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} 27 | macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }} 28 | macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }} 29 | -------------------------------------------------------------------------------- /.github/workflows/lint-sync.yml: -------------------------------------------------------------------------------- 1 | name: lint-sync 2 | on: 3 | schedule: 4 | # every Sunday at midnight 5 | - cron: "0 0 * * 0" 6 | workflow_dispatch: # allows manual triggering 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | lint: 14 | uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | uses: charmbracelet/meta/.github/workflows/lint.yml@main 9 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | nightly: 10 | uses: charmbracelet/meta/.github/workflows/nightly.yml@main 11 | secrets: 12 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 13 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 14 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 15 | macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} 16 | macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }} 17 | macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} 18 | macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }} 19 | macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cmd/soft/soft 2 | ./soft 3 | .ssh 4 | .repos 5 | dist 6 | data/ 7 | completions/ 8 | manpages/ 9 | soft_serve_ed25519* 10 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | tests: false 4 | linters: 5 | enable: 6 | - bodyclose 7 | - exhaustive 8 | - goconst 9 | - godot 10 | - godox 11 | - gomoddirectives 12 | - goprintffuncname 13 | - gosec 14 | - misspell 15 | - nakedret 16 | - nestif 17 | - nilerr 18 | - noctx 19 | - nolintlint 20 | - prealloc 21 | - revive 22 | - rowserrcheck 23 | - sqlclosecheck 24 | - tparallel 25 | - unconvert 26 | - unparam 27 | - whitespace 28 | - wrapcheck 29 | exclusions: 30 | generated: lax 31 | presets: 32 | - common-false-positives 33 | issues: 34 | max-issues-per-linter: 0 35 | max-same-issues: 0 36 | formatters: 37 | enable: 38 | - gofumpt 39 | - goimports 40 | exclusions: 41 | generated: lax 42 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | includes: 2 | - from_url: 3 | url: charmbracelet/meta/main/goreleaser-soft-serve.yaml 4 | 5 | variables: 6 | main: "./cmd/soft" 7 | binary_name: soft 8 | description: "A tasty, self-hostable Git server for the command line🍦" 9 | github_url: "https://github.com/charmbracelet/soft-serve" 10 | maintainer: "Ayman Bagabas " 11 | brew_commit_author_name: "Ayman Bagabas" 12 | brew_commit_author_email: "ayman@charm.sh" 13 | 14 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 15 | 16 | -------------------------------------------------------------------------------- /.nfpm/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if ! command -V systemctl >/dev/null 2>&1; then 5 | echo "Not running SystemD, ignoring" 6 | exit 0 7 | fi 8 | 9 | systemd-sysusers 10 | systemd-tmpfiles --create 11 | 12 | systemctl daemon-reload 13 | systemctl unmask soft-serve.service 14 | systemctl preset soft-serve.service 15 | -------------------------------------------------------------------------------- /.nfpm/postremove.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if ! command -V systemctl >/dev/null 2>&1; then 5 | echo "Not running SystemD, ignoring" 6 | exit 0 7 | fi 8 | 9 | systemctl daemon-reload 10 | systemctl reset-failed 11 | 12 | echo "WARN: the soft-serve user/group and /var/lib/soft-serve directory were not removed" 13 | -------------------------------------------------------------------------------- /.nfpm/soft-serve.conf: -------------------------------------------------------------------------------- 1 | # Config defined here will override the config in /var/lib/soft-serve/config.yaml 2 | # Keys defined in `SOFT_SERVE_INITIAL_ADMIN_KEYS` will be merged with 3 | # the `initial_admin_keys` from /var/lib/soft-serve/config.yaml. 4 | # 5 | #SOFT_SERVE_GIT_LISTEN_ADDR=:9418 6 | #SOFT_SERVE_HTTP_LISTEN_ADDR=:23232 7 | #SOFT_SERVE_SSH_LISTEN_ADDR=:23231 8 | #SOFT_SERVE_SSH_KEY_PATH=ssh/soft_serve_host_ed25519 9 | #SOFT_SERVE_INITIAL_ADMIN_KEYS='ssh-ed25519 AAAAC3NzaC1lZDI1...' 10 | -------------------------------------------------------------------------------- /.nfpm/soft-serve.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Soft Serve git server 🍦 3 | Documentation=https://github.com/charmbracelet/soft-serve 4 | Requires=network-online.target 5 | After=network-online.target 6 | 7 | [Service] 8 | Type=simple 9 | User=soft-serve 10 | Group=soft-serve 11 | Restart=always 12 | RestartSec=1 13 | ExecStart=/usr/bin/soft serve 14 | Environment=SOFT_SERVE_DATA_PATH=/var/lib/soft-serve 15 | EnvironmentFile=-/etc/soft-serve.conf 16 | WorkingDirectory=/var/lib/soft-serve 17 | 18 | # Hardening 19 | ReadWritePaths=/var/lib/soft-serve 20 | UMask=0027 21 | NoNewPrivileges=true 22 | LimitNOFILE=1048576 23 | ProtectSystem=strict 24 | ProtectHome=true 25 | PrivateUsers=yes 26 | PrivateTmp=true 27 | PrivateDevices=true 28 | ProtectHostname=true 29 | ProtectClock=true 30 | ProtectKernelTunables=true 31 | ProtectKernelModules=true 32 | ProtectKernelLogs=true 33 | ProtectControlGroups=true 34 | RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 35 | RestrictNamespaces=true 36 | LockPersonality=true 37 | MemoryDenyWriteExecute=true 38 | RestrictRealtime=true 39 | RestrictSUIDSGID=true 40 | RemoveIPC=true 41 | CapabilityBoundingSet= 42 | AmbientCapabilities= 43 | SystemCallFilter=@system-service 44 | SystemCallFilter=~@privileged @resources 45 | SystemCallArchitectures=native 46 | 47 | [Install] 48 | WantedBy=multi-user.target 49 | -------------------------------------------------------------------------------- /.nfpm/sysusers.conf: -------------------------------------------------------------------------------- 1 | u soft-serve - "Soft Serve daemon user" /var/lib/soft-serve 2 | -------------------------------------------------------------------------------- /.nfpm/tmpfiles.conf: -------------------------------------------------------------------------------- 1 | d /var/lib/soft-serve 0750 soft-serve soft-serve 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | # Create directories 4 | WORKDIR /soft-serve 5 | # Expose data volume 6 | VOLUME /soft-serve 7 | 8 | # Environment variables 9 | ENV SOFT_SERVE_DATA_PATH "/soft-serve" 10 | ENV SOFT_SERVE_INITIAL_ADMIN_KEYS "" 11 | # workaround to prevent slowness in docker when running with a tty 12 | ENV CI "1" 13 | 14 | # Expose ports 15 | # SSH 16 | EXPOSE 23231/tcp 17 | # HTTP 18 | EXPOSE 23232/tcp 19 | # Stats 20 | EXPOSE 23233/tcp 21 | # Git 22 | EXPOSE 9418/tcp 23 | 24 | # Set the default command 25 | ENTRYPOINT [ "/usr/local/bin/soft", "serve" ] 26 | 27 | RUN apk update && apk add --update git bash openssh && rm -rf /var/cache/apk/* 28 | 29 | COPY soft /usr/local/bin/soft 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 Charmbracelet, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /browse.tape: -------------------------------------------------------------------------------- 1 | Set Width 1600 2 | Set Height 900 3 | Set FontSize 22 4 | 5 | Output soft-serve-browse.gif 6 | Output soft-serve-frames/ 7 | 8 | Type@300ms "soft" 9 | Enter 10 | Sleep 2s 11 | Type@1s "ddd" 12 | Sleep 2s 13 | Type@1s "uuu" 14 | Sleep 2s 15 | Tab@1s 16 | Sleep 1s 17 | Down@300ms 4 18 | Enter 19 | Sleep 1s 20 | Down@300ms 13 21 | Enter 22 | Sleep 1s 23 | Down@300ms 5 24 | Enter 25 | Down@300ms 20 26 | Sleep 2s 27 | Type@500ms "b" 28 | Sleep 2.5s 29 | Down@300ms 50 30 | Sleep 2.5s 31 | Tab@1s 32 | Down@500ms 4 33 | Up@500ms 2 34 | Enter 35 | Down@250ms 50 36 | Sleep 1s 37 | Tab@1s 38 | Down@500ms 8 39 | Enter 40 | Down@250ms 30 41 | Tab@2s 42 | Down@500ms 5 43 | Up@500ms 2 44 | Sleep 2.5s 45 | Tab@2s 46 | Down@500ms 8 47 | Sleep 2s 48 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "os" 9 | 10 | "github.com/charmbracelet/soft-serve/pkg/backend" 11 | "github.com/charmbracelet/soft-serve/pkg/config" 12 | "github.com/charmbracelet/soft-serve/pkg/db" 13 | "github.com/charmbracelet/soft-serve/pkg/hooks" 14 | "github.com/charmbracelet/soft-serve/pkg/store" 15 | "github.com/charmbracelet/soft-serve/pkg/store/database" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | // InitBackendContext initializes the backend context. 20 | func InitBackendContext(cmd *cobra.Command, _ []string) error { 21 | ctx := cmd.Context() 22 | cfg := config.FromContext(ctx) 23 | if _, err := os.Stat(cfg.DataPath); errors.Is(err, fs.ErrNotExist) { 24 | if err := os.MkdirAll(cfg.DataPath, os.ModePerm); err != nil { 25 | return fmt.Errorf("create data directory: %w", err) 26 | } 27 | } 28 | dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource) 29 | if err != nil { 30 | return fmt.Errorf("open database: %w", err) 31 | } 32 | 33 | ctx = db.WithContext(ctx, dbx) 34 | dbstore := database.New(ctx, dbx) 35 | ctx = store.WithContext(ctx, dbstore) 36 | be := backend.New(ctx, cfg, dbx, dbstore) 37 | ctx = backend.WithContext(ctx, be) 38 | 39 | cmd.SetContext(ctx) 40 | 41 | return nil 42 | } 43 | 44 | // CloseDBContext closes the database context. 45 | func CloseDBContext(cmd *cobra.Command, _ []string) error { 46 | ctx := cmd.Context() 47 | dbx := db.FromContext(ctx) 48 | if dbx != nil { 49 | if err := dbx.Close(); err != nil { 50 | return fmt.Errorf("close database: %w", err) 51 | } 52 | } 53 | 54 | return nil 55 | } 56 | 57 | // InitializeHooks initializes the hooks. 58 | func InitializeHooks(ctx context.Context, cfg *config.Config, be *backend.Backend) error { 59 | repos, err := be.Repositories(ctx) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | for _, repo := range repos { 65 | if err := hooks.GenerateHooks(ctx, cfg, repo.Name()); err != nil { 66 | return err 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /cmd/soft/admin/admin.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/soft-serve/cmd" 7 | "github.com/charmbracelet/soft-serve/pkg/backend" 8 | "github.com/charmbracelet/soft-serve/pkg/config" 9 | "github.com/charmbracelet/soft-serve/pkg/db" 10 | "github.com/charmbracelet/soft-serve/pkg/db/migrate" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var ( 15 | // Command is the admin command. 16 | Command = &cobra.Command{ 17 | Use: "admin", 18 | Short: "Administrate the server", 19 | } 20 | 21 | migrateCmd = &cobra.Command{ 22 | Use: "migrate", 23 | Short: "Migrate the database to the latest version", 24 | PersistentPreRunE: cmd.InitBackendContext, 25 | PersistentPostRunE: cmd.CloseDBContext, 26 | RunE: func(cmd *cobra.Command, _ []string) error { 27 | ctx := cmd.Context() 28 | db := db.FromContext(ctx) 29 | if err := migrate.Migrate(ctx, db); err != nil { 30 | return fmt.Errorf("migration: %w", err) 31 | } 32 | 33 | return nil 34 | }, 35 | } 36 | 37 | rollbackCmd = &cobra.Command{ 38 | Use: "rollback", 39 | Short: "Rollback the database to the previous version", 40 | PersistentPreRunE: cmd.InitBackendContext, 41 | PersistentPostRunE: cmd.CloseDBContext, 42 | RunE: func(cmd *cobra.Command, _ []string) error { 43 | ctx := cmd.Context() 44 | db := db.FromContext(ctx) 45 | if err := migrate.Rollback(ctx, db); err != nil { 46 | return fmt.Errorf("rollback: %w", err) 47 | } 48 | 49 | return nil 50 | }, 51 | } 52 | 53 | syncHooksCmd = &cobra.Command{ 54 | Use: "sync-hooks", 55 | Short: "Update repository hooks", 56 | PersistentPreRunE: cmd.InitBackendContext, 57 | PersistentPostRunE: cmd.CloseDBContext, 58 | RunE: func(c *cobra.Command, _ []string) error { 59 | ctx := c.Context() 60 | cfg := config.FromContext(ctx) 61 | be := backend.FromContext(ctx) 62 | if err := cmd.InitializeHooks(ctx, cfg, be); err != nil { 63 | return fmt.Errorf("initialize hooks: %w", err) 64 | } 65 | 66 | return nil 67 | }, 68 | } 69 | ) 70 | 71 | func init() { 72 | Command.AddCommand( 73 | syncHooksCmd, 74 | migrateCmd, 75 | rollbackCmd, 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 50% 6 | patch: 7 | default: 8 | target: 30% 9 | -------------------------------------------------------------------------------- /demo.tape: -------------------------------------------------------------------------------- 1 | Set Width 1600 2 | Set Height 900 3 | Set FontSize 22 4 | 5 | Output soft-serve.gif 6 | Output soft-serve-frames/ 7 | 8 | Type "ssh git.charm.sh" 9 | Sleep 1s 10 | Enter 11 | Sleep 2s 12 | Type@500ms "jjj" 13 | Sleep 1s 14 | Type@250ms "kkk" 15 | Enter 16 | Sleep 1s 17 | Down@300ms 10 18 | Sleep 1s 19 | Tab@1s 2 20 | Down@300ms 3 21 | Enter 22 | Down@250ms 30 23 | Sleep 1s 24 | Type "h" 25 | Sleep 1s 26 | Tab@1s 4 27 | Sleep 500ms 28 | Down@300ms 4 29 | Enter 30 | Down@300ms 2 31 | Enter 32 | Down 33 | Sleep 1s 34 | Enter 35 | Down@250ms 50 36 | Sleep 2.5s 37 | Escape 38 | Sleep 2s 39 | -------------------------------------------------------------------------------- /docker.md: -------------------------------------------------------------------------------- 1 | # Running Soft-Serve with Docker 2 | 3 | The official Soft Serve Docker images are available at [charmcli/soft-serve][docker]. Development and nightly builds are available at [ghcr.io/charmbracelet/soft-serve][ghcr] 4 | 5 | ```sh 6 | docker pull charmcli/soft-serve:latest 7 | ``` 8 | 9 | Here’s how you might run `soft-serve` as a container. Keep in mind that 10 | repositories are stored in the `/soft-serve` directory, so you’ll likely want 11 | to mount that directory as a volume in order keep your repositories backed up. 12 | 13 | ```sh 14 | docker run \ 15 | --name=soft-serve \ 16 | --volume /path/to/data:/soft-serve \ 17 | --publish 23231:23231 \ 18 | --publish 23232:23232 \ 19 | --publish 23233:23233 \ 20 | --publish 9418:9418 \ 21 | -e SOFT_SERVE_INITIAL_ADMIN_KEYS="YOUR_ADMIN_KEY_HERE" \ 22 | --restart unless-stopped \ 23 | charmcli/soft-serve:latest 24 | ``` 25 | 26 | Or by using docker-compose: 27 | 28 | ```yaml 29 | --- 30 | version: "3.1" 31 | services: 32 | soft-serve: 33 | image: charmcli/soft-serve:latest 34 | container_name: soft-serve 35 | volumes: 36 | - /path/to/data:/soft-serve 37 | ports: 38 | - 23231:23231 39 | - 23232:23232 40 | - 23233:23233 41 | - 9418:9418 42 | environment: 43 | SOFT_SERVE_INITIAL_ADMIN_KEYS: "YOUR_ADMIN_KEY_HERE" 44 | restart: unless-stopped 45 | ``` 46 | 47 | [docker]: https://hub.docker.com/r/charmcli/soft-serve 48 | [ghcr]: https://github.com/charmbracelet/soft-serve/pkgs/container/soft-serve 49 | 50 | 51 | > **Warning** 52 | > 53 | > Make sure to run the image without a TTY, i.e.: do not use the `--tty`/`-t` 54 | > flags. 55 | 56 | 57 | *** 58 | 59 | Part of [Charm](https://charm.sh). 60 | 61 | The Charm logo 62 | 63 | Charm热爱开源 • Charm loves open source 64 | -------------------------------------------------------------------------------- /git/attr.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "math/rand" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // Attribute represents a Git attribute. 13 | type Attribute struct { 14 | Name string 15 | Value string 16 | } 17 | 18 | // CheckAttributes checks the attributes of the given ref and path. 19 | func (r *Repository) CheckAttributes(ref *Reference, path string) ([]Attribute, error) { 20 | rnd := rand.NewSource(time.Now().UnixNano()) 21 | fn := "soft-serve-index-" + strconv.Itoa(rand.New(rnd).Int()) // nolint: gosec 22 | tmpindex := filepath.Join(os.TempDir(), fn) 23 | 24 | defer os.Remove(tmpindex) // nolint: errcheck 25 | 26 | readTree := NewCommand("read-tree", "--reset", "-i", ref.Name().String()). 27 | AddEnvs("GIT_INDEX_FILE=" + tmpindex) 28 | if _, err := readTree.RunInDir(r.Path); err != nil { 29 | return nil, err 30 | } 31 | 32 | checkAttr := NewCommand("check-attr", "--cached", "-a", "--", path). 33 | AddEnvs("GIT_INDEX_FILE=" + tmpindex) 34 | out, err := checkAttr.RunInDir(r.Path) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | return parseAttributes(path, out), nil 40 | } 41 | 42 | func parseAttributes(path string, buf []byte) []Attribute { 43 | attrs := make([]Attribute, 0) 44 | for _, line := range strings.Split(string(buf), "\n") { 45 | if line == "" { 46 | continue 47 | } 48 | 49 | line = strings.TrimPrefix(line, path+": ") 50 | parts := strings.SplitN(line, ": ", 2) 51 | if len(parts) != 2 { 52 | continue 53 | } 54 | 55 | attrs = append(attrs, Attribute{ 56 | Name: parts[0], 57 | Value: parts[1], 58 | }) 59 | } 60 | 61 | return attrs 62 | } 63 | -------------------------------------------------------------------------------- /git/attr_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matryer/is" 7 | ) 8 | 9 | func TestParseAttr(t *testing.T) { 10 | cases := []struct { 11 | in string 12 | file string 13 | want []Attribute 14 | }{ 15 | { 16 | in: "org/example/MyClass.java: diff: java\n", 17 | file: "org/example/MyClass.java", 18 | want: []Attribute{ 19 | { 20 | Name: "diff", 21 | Value: "java", 22 | }, 23 | }, 24 | }, 25 | { 26 | in: `org/example/MyClass.java: crlf: unset 27 | org/example/MyClass.java: diff: java 28 | org/example/MyClass.java: myAttr: set`, 29 | file: "org/example/MyClass.java", 30 | want: []Attribute{ 31 | { 32 | Name: "crlf", 33 | Value: "unset", 34 | }, 35 | { 36 | Name: "diff", 37 | Value: "java", 38 | }, 39 | { 40 | Name: "myAttr", 41 | Value: "set", 42 | }, 43 | }, 44 | }, 45 | { 46 | in: `org/example/MyClass.java: diff: java 47 | org/example/MyClass.java: myAttr: set`, 48 | file: "org/example/MyClass.java", 49 | want: []Attribute{ 50 | { 51 | Name: "diff", 52 | Value: "java", 53 | }, 54 | { 55 | Name: "myAttr", 56 | Value: "set", 57 | }, 58 | }, 59 | }, 60 | { 61 | in: `README: caveat: unspecified`, 62 | file: "README", 63 | want: []Attribute{ 64 | { 65 | Name: "caveat", 66 | Value: "unspecified", 67 | }, 68 | }, 69 | }, 70 | { 71 | in: "", 72 | file: "foo", 73 | want: []Attribute{}, 74 | }, 75 | { 76 | in: "\n", 77 | file: "foo", 78 | want: []Attribute{}, 79 | }, 80 | } 81 | 82 | is := is.New(t) 83 | for _, c := range cases { 84 | attrs := parseAttributes(c.file, []byte(c.in)) 85 | if len(attrs) != len(c.want) { 86 | t.Fatalf("parseAttributes(%q, %q) = %v, want %v", c.file, c.in, attrs, c.want) 87 | } 88 | 89 | is.Equal(attrs, c.want) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /git/command.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "github.com/aymanbagabas/git-module" 4 | 5 | // RunInDirOptions are options for RunInDir. 6 | type RunInDirOptions = git.RunInDirOptions 7 | 8 | // NewCommand creates a new git command. 9 | func NewCommand(args ...string) *git.Command { 10 | return git.NewCommand(args...) 11 | } 12 | -------------------------------------------------------------------------------- /git/commit.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/aymanbagabas/git-module" 7 | ) 8 | 9 | // ZeroID is the zero hash. 10 | const ZeroID = git.EmptyID 11 | 12 | // IsZeroHash returns whether the hash is a zero hash. 13 | func IsZeroHash(h string) bool { 14 | pattern := regexp.MustCompile(`^0{40,}$`) 15 | return pattern.MatchString(h) 16 | } 17 | 18 | // Commit is a wrapper around git.Commit with helper methods. 19 | type Commit = git.Commit 20 | 21 | // Commits is a list of commits. 22 | type Commits []*Commit 23 | 24 | // Len implements sort.Interface. 25 | func (cl Commits) Len() int { return len(cl) } 26 | 27 | // Swap implements sort.Interface. 28 | func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } 29 | 30 | // Less implements sort.Interface. 31 | func (cl Commits) Less(i, j int) bool { 32 | return cl[i].Author.When.After(cl[j].Author.When) 33 | } 34 | -------------------------------------------------------------------------------- /git/config.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | gcfg "github.com/go-git/go-git/v5/plumbing/format/config" 8 | ) 9 | 10 | // Config returns the repository Git configuration. 11 | func (r *Repository) Config() (*gcfg.Config, error) { 12 | cp := filepath.Join(r.Path, "config") 13 | f, err := os.Open(cp) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | defer f.Close() // nolint: errcheck 19 | d := gcfg.NewDecoder(f) 20 | cfg := gcfg.New() 21 | if err := d.Decode(cfg); err != nil { 22 | return nil, err 23 | } 24 | 25 | return cfg, nil 26 | } 27 | 28 | // SetConfig sets the repository Git configuration. 29 | func (r *Repository) SetConfig(cfg *gcfg.Config) error { 30 | cp := filepath.Join(r.Path, "config") 31 | f, err := os.Create(cp) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | defer f.Close() // nolint: errcheck 37 | e := gcfg.NewEncoder(f) 38 | return e.Encode(cfg) 39 | } 40 | -------------------------------------------------------------------------------- /git/errors.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/aymanbagabas/git-module" 7 | ) 8 | 9 | var ( 10 | // ErrFileNotFound is returned when a file is not found. 11 | ErrFileNotFound = errors.New("file not found") 12 | // ErrDirectoryNotFound is returned when a directory is not found. 13 | ErrDirectoryNotFound = errors.New("directory not found") 14 | // ErrReferenceNotExist is returned when a reference does not exist. 15 | ErrReferenceNotExist = git.ErrReferenceNotExist 16 | // ErrRevisionNotExist is returned when a revision is not found. 17 | ErrRevisionNotExist = git.ErrRevisionNotExist 18 | // ErrNotAGitRepository is returned when the given path is not a Git repository. 19 | ErrNotAGitRepository = errors.New("not a git repository") 20 | ) 21 | -------------------------------------------------------------------------------- /git/reference.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/aymanbagabas/git-module" 7 | ) 8 | 9 | const ( 10 | // HEAD represents the name of the HEAD reference. 11 | HEAD = "HEAD" 12 | // RefsHeads represents the prefix for branch references. 13 | RefsHeads = git.RefsHeads 14 | // RefsTags represents the prefix for tag references. 15 | RefsTags = git.RefsTags 16 | ) 17 | 18 | // Reference is a wrapper around git.Reference with helper methods. 19 | type Reference struct { 20 | *git.Reference 21 | path string // repo path 22 | } 23 | 24 | // ReferenceName is a Refspec wrapper. 25 | type ReferenceName string 26 | 27 | // String returns the reference name i.e. refs/heads/master. 28 | func (r ReferenceName) String() string { 29 | return string(r) 30 | } 31 | 32 | // Short returns the short name of the reference i.e. master. 33 | func (r ReferenceName) Short() string { 34 | return git.RefShortName(string(r)) 35 | } 36 | 37 | // Name returns the reference name i.e. refs/heads/master. 38 | func (r *Reference) Name() ReferenceName { 39 | return ReferenceName(r.Refspec) 40 | } 41 | 42 | // IsBranch returns true if the reference is a branch. 43 | func (r *Reference) IsBranch() bool { 44 | return strings.HasPrefix(r.Refspec, git.RefsHeads) 45 | } 46 | 47 | // IsTag returns true if the reference is a tag. 48 | func (r *Reference) IsTag() bool { 49 | return strings.HasPrefix(r.Refspec, git.RefsTags) 50 | } 51 | -------------------------------------------------------------------------------- /git/server.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aymanbagabas/git-module" 7 | ) 8 | 9 | // UpdateServerInfo updates the server info file for the given repo path. 10 | func UpdateServerInfo(ctx context.Context, path string) error { 11 | if !isGitDir(path) { 12 | return ErrNotAGitRepository 13 | } 14 | 15 | cmd := git.NewCommand("update-server-info").WithContext(ctx).WithTimeout(-1) 16 | _, err := cmd.RunInDir(path) 17 | return err 18 | } 19 | -------------------------------------------------------------------------------- /git/stash.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "github.com/aymanbagabas/git-module" 4 | 5 | // StashDiff returns the diff of the given stash index. 6 | func (r *Repository) StashDiff(index int) (*Diff, error) { 7 | diff, err := r.Repository.StashDiff(index, DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{ 8 | CommandOptions: git.CommandOptions{ 9 | Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"}, 10 | }, 11 | }) 12 | if err != nil { 13 | return nil, err 14 | } 15 | return toDiff(diff), nil 16 | } 17 | -------------------------------------------------------------------------------- /git/tag.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "github.com/aymanbagabas/git-module" 4 | 5 | // Tag is a git tag. 6 | type Tag = git.Tag 7 | -------------------------------------------------------------------------------- /git/types.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "github.com/aymanbagabas/git-module" 4 | 5 | // CommandOptions contain options for running a git command. 6 | type CommandOptions = git.CommandOptions 7 | 8 | // CloneOptions contain options for cloning a repository. 9 | type CloneOptions = git.CloneOptions 10 | -------------------------------------------------------------------------------- /git/utils.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/gobwas/glob" 8 | ) 9 | 10 | // LatestFile returns the contents of the first file at the specified path pattern in the repository and its file path. 11 | func LatestFile(repo *Repository, ref *Reference, pattern string) (string, string, error) { 12 | g := glob.MustCompile(pattern) 13 | dir := filepath.Dir(pattern) 14 | if ref == nil { 15 | head, err := repo.HEAD() 16 | if err != nil { 17 | return "", "", err 18 | } 19 | ref = head 20 | } 21 | t, err := repo.TreePath(ref, dir) 22 | if err != nil { 23 | return "", "", err 24 | } 25 | ents, err := t.Entries() 26 | if err != nil { 27 | return "", "", err 28 | } 29 | for _, e := range ents { 30 | te := e 31 | fp := filepath.Join(dir, te.Name()) 32 | if te.IsTree() { 33 | continue 34 | } 35 | if g.Match(fp) { 36 | if te.IsSymlink() { 37 | bts, err := te.Contents() 38 | if err != nil { 39 | return "", "", err 40 | } 41 | fp = string(bts) 42 | te, err = t.TreeEntry(fp) 43 | if err != nil { 44 | return "", "", err 45 | } 46 | } 47 | bts, err := te.Contents() 48 | if err != nil { 49 | return "", "", err 50 | } 51 | return string(bts), fp, nil 52 | } 53 | } 54 | return "", "", ErrFileNotFound 55 | } 56 | 57 | // Returns true if path is a directory containing an `objects` directory and a 58 | // `HEAD` file. 59 | func isGitDir(path string) bool { 60 | stat, err := os.Stat(filepath.Join(path, "objects")) 61 | if err != nil { 62 | return false 63 | } 64 | if !stat.IsDir() { 65 | return false 66 | } 67 | 68 | stat, err = os.Stat(filepath.Join(path, "HEAD")) 69 | if err != nil { 70 | return false 71 | } 72 | if stat.IsDir() { 73 | return false 74 | } 75 | 76 | return true 77 | } 78 | -------------------------------------------------------------------------------- /pkg/access/access.go: -------------------------------------------------------------------------------- 1 | package access 2 | 3 | import ( 4 | "encoding" 5 | "errors" 6 | ) 7 | 8 | // AccessLevel is the level of access allowed to a repo. 9 | type AccessLevel int // nolint: revive 10 | 11 | const ( 12 | // NoAccess does not allow access to the repo. 13 | NoAccess AccessLevel = iota 14 | 15 | // ReadOnlyAccess allows read-only access to the repo. 16 | ReadOnlyAccess 17 | 18 | // ReadWriteAccess allows read and write access to the repo. 19 | ReadWriteAccess 20 | 21 | // AdminAccess allows read, write, and admin access to the repo. 22 | AdminAccess 23 | ) 24 | 25 | // String returns the string representation of the access level. 26 | func (a AccessLevel) String() string { 27 | switch a { 28 | case NoAccess: 29 | return "no-access" 30 | case ReadOnlyAccess: 31 | return "read-only" 32 | case ReadWriteAccess: 33 | return "read-write" 34 | case AdminAccess: 35 | return "admin-access" 36 | default: 37 | return "unknown" 38 | } 39 | } 40 | 41 | // ParseAccessLevel parses an access level string. 42 | func ParseAccessLevel(s string) AccessLevel { 43 | switch s { 44 | case "no-access": 45 | return NoAccess 46 | case "read-only": 47 | return ReadOnlyAccess 48 | case "read-write": 49 | return ReadWriteAccess 50 | case "admin-access": 51 | return AdminAccess 52 | default: 53 | return AccessLevel(-1) 54 | } 55 | } 56 | 57 | var ( 58 | _ encoding.TextMarshaler = AccessLevel(0) 59 | _ encoding.TextUnmarshaler = (*AccessLevel)(nil) 60 | ) 61 | 62 | // ErrInvalidAccessLevel is returned when an invalid access level is provided. 63 | var ErrInvalidAccessLevel = errors.New("invalid access level") 64 | 65 | // UnmarshalText implements encoding.TextUnmarshaler. 66 | func (a *AccessLevel) UnmarshalText(text []byte) error { 67 | l := ParseAccessLevel(string(text)) 68 | if l < 0 { 69 | return ErrInvalidAccessLevel 70 | } 71 | 72 | *a = l 73 | 74 | return nil 75 | } 76 | 77 | // MarshalText implements encoding.TextMarshaler. 78 | func (a AccessLevel) MarshalText() (text []byte, err error) { 79 | return []byte(a.String()), nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/access/access_test.go: -------------------------------------------------------------------------------- 1 | package access 2 | 3 | import "testing" 4 | 5 | func TestParseAccessLevel(t *testing.T) { 6 | cases := []struct { 7 | in string 8 | out AccessLevel 9 | }{ 10 | {"", -1}, 11 | {"foo", -1}, 12 | {AdminAccess.String(), AdminAccess}, 13 | {ReadOnlyAccess.String(), ReadOnlyAccess}, 14 | {ReadWriteAccess.String(), ReadWriteAccess}, 15 | {NoAccess.String(), NoAccess}, 16 | } 17 | 18 | for _, c := range cases { 19 | out := ParseAccessLevel(c.in) 20 | if out != c.out { 21 | t.Errorf("ParseAccessLevel(%q) => %d, want %d", c.in, out, c.out) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/access/context.go: -------------------------------------------------------------------------------- 1 | package access 2 | 3 | import "context" 4 | 5 | // ContextKey is the context key for the access level. 6 | var ContextKey = &struct{ string }{"access"} 7 | 8 | // FromContext returns the access level from the context. 9 | func FromContext(ctx context.Context) AccessLevel { 10 | if ac, ok := ctx.Value(ContextKey).(AccessLevel); ok { 11 | return ac 12 | } 13 | 14 | return -1 15 | } 16 | 17 | // WithContext returns a new context with the access level. 18 | func WithContext(ctx context.Context, ac AccessLevel) context.Context { 19 | return context.WithValue(ctx, ContextKey, ac) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/access/context_test.go: -------------------------------------------------------------------------------- 1 | package access 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestGoodFromContext(t *testing.T) { 9 | ctx := WithContext(context.TODO(), AdminAccess) 10 | if ac := FromContext(ctx); ac != AdminAccess { 11 | t.Errorf("FromContext(ctx) => %d, want %d", ac, AdminAccess) 12 | } 13 | } 14 | 15 | func TestBadFromContext(t *testing.T) { 16 | ctx := context.TODO() 17 | if ac := FromContext(ctx); ac != -1 { 18 | t.Errorf("FromContext(ctx) => %d, want %d", ac, -1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/backend/access_token.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/charmbracelet/soft-serve/pkg/db" 9 | "github.com/charmbracelet/soft-serve/pkg/proto" 10 | ) 11 | 12 | // CreateAccessToken creates an access token for user. 13 | func (b *Backend) CreateAccessToken(ctx context.Context, user proto.User, name string, expiresAt time.Time) (string, error) { 14 | token := GenerateToken() 15 | tokenHash := HashToken(token) 16 | 17 | if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { 18 | _, err := b.store.CreateAccessToken(ctx, tx, name, user.ID(), tokenHash, expiresAt) 19 | if err != nil { 20 | return db.WrapError(err) 21 | } 22 | 23 | return nil 24 | }); err != nil { 25 | return "", err 26 | } 27 | 28 | return token, nil 29 | } 30 | 31 | // DeleteAccessToken deletes an access token for a user. 32 | func (b *Backend) DeleteAccessToken(ctx context.Context, user proto.User, id int64) error { 33 | err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { 34 | _, err := b.store.GetAccessToken(ctx, tx, id) 35 | if err != nil { 36 | return db.WrapError(err) 37 | } 38 | 39 | if err := b.store.DeleteAccessTokenForUser(ctx, tx, user.ID(), id); err != nil { 40 | return db.WrapError(err) 41 | } 42 | return nil 43 | }) 44 | if err != nil { 45 | if errors.Is(err, db.ErrRecordNotFound) { 46 | return proto.ErrTokenNotFound 47 | } 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | 54 | // ListAccessTokens lists access tokens for a user. 55 | func (b *Backend) ListAccessTokens(ctx context.Context, user proto.User) ([]proto.AccessToken, error) { 56 | accessTokens, err := b.store.GetAccessTokensByUserID(ctx, b.db, user.ID()) 57 | if err != nil { 58 | return nil, db.WrapError(err) 59 | } 60 | 61 | var tokens []proto.AccessToken 62 | for _, t := range accessTokens { 63 | token := proto.AccessToken{ 64 | ID: t.ID, 65 | Name: t.Name, 66 | TokenHash: t.Token, 67 | UserID: t.UserID, 68 | CreatedAt: t.CreatedAt, 69 | } 70 | if t.ExpiresAt.Valid { 71 | token.ExpiresAt = t.ExpiresAt.Time 72 | } 73 | 74 | tokens = append(tokens, token) 75 | } 76 | 77 | return tokens, nil 78 | } 79 | -------------------------------------------------------------------------------- /pkg/backend/auth.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | 8 | "github.com/charmbracelet/log" 9 | "golang.org/x/crypto/bcrypt" 10 | ) 11 | 12 | const saltySalt = "salty-soft-serve" 13 | 14 | // HashPassword hashes the password using bcrypt. 15 | func HashPassword(password string) (string, error) { 16 | crypt, err := bcrypt.GenerateFromPassword([]byte(password+saltySalt), bcrypt.DefaultCost) 17 | if err != nil { 18 | return "", err 19 | } 20 | 21 | return string(crypt), nil 22 | } 23 | 24 | // VerifyPassword verifies the password against the hash. 25 | func VerifyPassword(password, hash string) bool { 26 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+saltySalt)) 27 | return err == nil 28 | } 29 | 30 | // GenerateToken returns a random unique token. 31 | func GenerateToken() string { 32 | buf := make([]byte, 20) 33 | if _, err := rand.Read(buf); err != nil { 34 | log.Error("unable to generate access token") 35 | return "" 36 | } 37 | 38 | return "ss_" + hex.EncodeToString(buf) 39 | } 40 | 41 | // HashToken hashes the token using sha256. 42 | func HashToken(token string) string { 43 | sum := sha256.Sum256([]byte(token + saltySalt)) 44 | return hex.EncodeToString(sum[:]) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/backend/auth_test.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import "testing" 4 | 5 | func TestHashPassword(t *testing.T) { 6 | hash, err := HashPassword("password") 7 | if err != nil { 8 | t.Fatal(err) 9 | } 10 | if hash == "" { 11 | t.Fatal("hash is empty") 12 | } 13 | } 14 | 15 | func TestVerifyPassword(t *testing.T) { 16 | hash, err := HashPassword("password") 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | if !VerifyPassword("password", hash) { 21 | t.Fatal("password did not verify") 22 | } 23 | } 24 | 25 | func TestGenerateToken(t *testing.T) { 26 | token := GenerateToken() 27 | if token == "" { 28 | t.Fatal("token is empty") 29 | } 30 | } 31 | 32 | func TestHashToken(t *testing.T) { 33 | token := GenerateToken() 34 | hash := HashToken(token) 35 | if hash == "" { 36 | t.Fatal("hash is empty") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/backend/backend.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/log" 7 | "github.com/charmbracelet/soft-serve/pkg/config" 8 | "github.com/charmbracelet/soft-serve/pkg/db" 9 | "github.com/charmbracelet/soft-serve/pkg/store" 10 | "github.com/charmbracelet/soft-serve/pkg/task" 11 | ) 12 | 13 | // Backend is the Soft Serve backend that handles users, repositories, and 14 | // server settings management and operations. 15 | type Backend struct { 16 | ctx context.Context 17 | cfg *config.Config 18 | db *db.DB 19 | store store.Store 20 | logger *log.Logger 21 | cache *cache 22 | manager *task.Manager 23 | } 24 | 25 | // New returns a new Soft Serve backend. 26 | func New(ctx context.Context, cfg *config.Config, db *db.DB, st store.Store) *Backend { 27 | logger := log.FromContext(ctx).WithPrefix("backend") 28 | b := &Backend{ 29 | ctx: ctx, 30 | cfg: cfg, 31 | db: db, 32 | store: st, 33 | logger: logger, 34 | manager: task.NewManager(ctx), 35 | } 36 | 37 | // TODO: implement a proper caching interface 38 | cache := newCache(b, 1000) 39 | b.cache = cache 40 | 41 | return b 42 | } 43 | -------------------------------------------------------------------------------- /pkg/backend/cache.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import lru "github.com/hashicorp/golang-lru/v2" 4 | 5 | // TODO: implement a caching interface. 6 | type cache struct { 7 | b *Backend 8 | repos *lru.Cache[string, *repo] 9 | } 10 | 11 | func newCache(b *Backend, size int) *cache { 12 | if size <= 0 { 13 | size = 1 14 | } 15 | c := &cache{b: b} 16 | cache, _ := lru.New[string, *repo](size) 17 | c.repos = cache 18 | return c 19 | } 20 | 21 | func (c *cache) Get(repo string) (*repo, bool) { 22 | return c.repos.Get(repo) 23 | } 24 | 25 | func (c *cache) Set(repo string, r *repo) { 26 | c.repos.Add(repo, r) 27 | } 28 | 29 | func (c *cache) Delete(repo string) { 30 | c.repos.Remove(repo) 31 | } 32 | 33 | func (c *cache) Len() int { 34 | return c.repos.Len() 35 | } 36 | -------------------------------------------------------------------------------- /pkg/backend/context.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import "context" 4 | 5 | // ContextKey is the key for the backend in the context. 6 | var ContextKey = &struct{ string }{"backend"} 7 | 8 | // FromContext returns the backend from a context. 9 | func FromContext(ctx context.Context) *Backend { 10 | if b, ok := ctx.Value(ContextKey).(*Backend); ok { 11 | return b 12 | } 13 | 14 | return nil 15 | } 16 | 17 | // WithContext returns a new context with the backend attached. 18 | func WithContext(ctx context.Context, b *Backend) context.Context { 19 | return context.WithValue(ctx, ContextKey, b) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/backend/lfs.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "path" 8 | "path/filepath" 9 | "strconv" 10 | 11 | "github.com/charmbracelet/soft-serve/pkg/config" 12 | "github.com/charmbracelet/soft-serve/pkg/db" 13 | "github.com/charmbracelet/soft-serve/pkg/lfs" 14 | "github.com/charmbracelet/soft-serve/pkg/proto" 15 | "github.com/charmbracelet/soft-serve/pkg/storage" 16 | "github.com/charmbracelet/soft-serve/pkg/store" 17 | ) 18 | 19 | // StoreRepoMissingLFSObjects stores missing LFS objects for a repository. 20 | func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx *db.DB, store store.Store, lfsClient lfs.Client) error { 21 | cfg := config.FromContext(ctx) 22 | repoID := strconv.FormatInt(repo.ID(), 10) 23 | lfsRoot := filepath.Join(cfg.DataPath, "lfs", repoID) 24 | 25 | // TODO: support S3 storage 26 | strg := storage.NewLocalStorage(lfsRoot) 27 | pointerChan := make(chan lfs.PointerBlob) 28 | errChan := make(chan error, 1) 29 | r, err := repo.Open() 30 | if err != nil { 31 | return err 32 | } 33 | 34 | go lfs.SearchPointerBlobs(ctx, r, pointerChan, errChan) 35 | 36 | download := func(pointers []lfs.Pointer) error { 37 | return lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error { 38 | if objectError != nil { 39 | return objectError 40 | } 41 | 42 | defer content.Close() // nolint: errcheck 43 | return dbx.TransactionContext(ctx, func(tx *db.Tx) error { 44 | if err := store.CreateLFSObject(ctx, tx, repo.ID(), p.Oid, p.Size); err != nil { 45 | return db.WrapError(err) 46 | } 47 | 48 | _, err := strg.Put(path.Join("objects", p.RelativePath()), content) 49 | return err 50 | }) 51 | }) 52 | } 53 | 54 | var batch []lfs.Pointer 55 | for pointer := range pointerChan { 56 | obj, err := store.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid) 57 | if err != nil && !errors.Is(err, db.ErrRecordNotFound) { 58 | return db.WrapError(err) 59 | } 60 | 61 | exist, err := strg.Exists(path.Join("objects", pointer.RelativePath())) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | if exist && obj.ID == 0 { 67 | if err := store.CreateLFSObject(ctx, dbx, repo.ID(), pointer.Oid, pointer.Size); err != nil { 68 | return db.WrapError(err) 69 | } 70 | } else { 71 | batch = append(batch, pointer.Pointer) 72 | // Limit batch requests to 20 objects 73 | if len(batch) >= 20 { 74 | if err := download(batch); err != nil { 75 | return err 76 | } 77 | 78 | batch = nil 79 | } 80 | } 81 | } 82 | 83 | if err, ok := <-errChan; ok { 84 | return err 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/backend/settings.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/access" 7 | "github.com/charmbracelet/soft-serve/pkg/db" 8 | ) 9 | 10 | // AllowKeyless returns whether or not keyless access is allowed. 11 | // 12 | // It implements backend.Backend. 13 | func (b *Backend) AllowKeyless(ctx context.Context) bool { 14 | var allow bool 15 | if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { 16 | var err error 17 | allow, err = b.store.GetAllowKeylessAccess(ctx, tx) 18 | return err 19 | }); err != nil { 20 | return false 21 | } 22 | 23 | return allow 24 | } 25 | 26 | // SetAllowKeyless sets whether or not keyless access is allowed. 27 | // 28 | // It implements backend.Backend. 29 | func (b *Backend) SetAllowKeyless(ctx context.Context, allow bool) error { 30 | return b.db.TransactionContext(ctx, func(tx *db.Tx) error { 31 | return b.store.SetAllowKeylessAccess(ctx, tx, allow) 32 | }) 33 | } 34 | 35 | // AnonAccess returns the level of anonymous access. 36 | // 37 | // It implements backend.Backend. 38 | func (b *Backend) AnonAccess(ctx context.Context) access.AccessLevel { 39 | var level access.AccessLevel 40 | if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error { 41 | var err error 42 | level, err = b.store.GetAnonAccess(ctx, tx) 43 | return err 44 | }); err != nil { 45 | return access.NoAccess 46 | } 47 | 48 | return level 49 | } 50 | 51 | // SetAnonAccess sets the level of anonymous access. 52 | // 53 | // It implements backend.Backend. 54 | func (b *Backend) SetAnonAccess(ctx context.Context, level access.AccessLevel) error { 55 | return b.db.TransactionContext(ctx, func(tx *db.Tx) error { 56 | return b.store.SetAnonAccess(ctx, tx, level) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/backend/utils.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "github.com/charmbracelet/soft-serve/git" 5 | "github.com/charmbracelet/soft-serve/pkg/proto" 6 | ) 7 | 8 | // LatestFile returns the contents of the latest file at the specified path in 9 | // the repository and its file path. 10 | func LatestFile(r proto.Repository, ref *git.Reference, pattern string) (string, string, error) { 11 | repo, err := r.Open() 12 | if err != nil { 13 | return "", "", err 14 | } 15 | return git.LatestFile(repo, ref, pattern) 16 | } 17 | 18 | // Readme returns the repository's README. 19 | func Readme(r proto.Repository, ref *git.Reference) (readme string, path string, err error) { 20 | pattern := "[rR][eE][aA][dD][mM][eE]*" 21 | readme, path, err = LatestFile(r, ref, pattern) 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /pkg/config/context.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "context" 4 | 5 | // ContextKey is the context key for the config. 6 | var ContextKey = struct{ string }{"config"} 7 | 8 | // WithContext returns a new context with the configuration attached. 9 | func WithContext(ctx context.Context, cfg *Config) context.Context { 10 | return context.WithValue(ctx, ContextKey, cfg) 11 | } 12 | 13 | // FromContext returns the configuration from the context. 14 | func FromContext(ctx context.Context) *Config { 15 | if c, ok := ctx.Value(ContextKey).(*Config); ok { 16 | return c 17 | } 18 | 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /pkg/config/context_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestBadFromContext(t *testing.T) { 10 | ctx := context.TODO() 11 | if c := FromContext(ctx); c != nil { 12 | t.Errorf("FromContext(ctx) => %v, want %v", c, nil) 13 | } 14 | } 15 | 16 | func TestGoodFromContext(t *testing.T) { 17 | ctx := WithContext(context.TODO(), &Config{}) 18 | if c := FromContext(ctx); c == nil { 19 | t.Errorf("FromContext(ctx) => %v, want %v", c, &Config{}) 20 | } 21 | } 22 | 23 | func TestGoodFromContextWithDefaultConfig(t *testing.T) { 24 | cfg := DefaultConfig() 25 | ctx := WithContext(context.TODO(), cfg) 26 | if c := FromContext(ctx); c == nil || !reflect.DeepEqual(c, cfg) { 27 | t.Errorf("FromContext(ctx) => %v, want %v", c, cfg) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/config/file_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "testing" 4 | 5 | func TestNewConfigFile(t *testing.T) { 6 | for _, cfg := range []*Config{ 7 | nil, 8 | DefaultConfig(), 9 | &Config{}, 10 | } { 11 | if s := newConfigFile(cfg); s == "" { 12 | t.Errorf("newConfigFile(nil) => %q, want non-empty string", s) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pkg/config/ssh.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/charmbracelet/keygen" 7 | ) 8 | 9 | var ( 10 | // ErrNilConfig is returned when a nil config is passed to a function. 11 | ErrNilConfig = errors.New("nil config") 12 | 13 | // ErrEmptySSHKeyPath is returned when the SSH key path is empty. 14 | ErrEmptySSHKeyPath = errors.New("empty SSH key path") 15 | ) 16 | 17 | // KeyPair returns the server's SSH key pair. 18 | func KeyPair(cfg *Config) (*keygen.SSHKeyPair, error) { 19 | if cfg == nil { 20 | return nil, ErrNilConfig 21 | } 22 | 23 | if cfg.SSH.KeyPath == "" { 24 | return nil, ErrEmptySSHKeyPath 25 | } 26 | 27 | return keygen.New(cfg.SSH.KeyPath, keygen.WithKeyType(keygen.Ed25519)) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/config/ssh_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "testing" 4 | 5 | func TestBadSSHKeyPair(t *testing.T) { 6 | for _, cfg := range []*Config{ 7 | nil, 8 | {}, 9 | } { 10 | if _, err := KeyPair(cfg); err == nil { 11 | t.Errorf("cfg.SSH.KeyPair() => _, nil, want non-nil error") 12 | } 13 | } 14 | } 15 | 16 | func TestGoodSSHKeyPair(t *testing.T) { 17 | cfg := &Config{ 18 | SSH: SSHConfig{ 19 | KeyPath: "testdata/ssh_host_ed25519_key", 20 | }, 21 | } 22 | 23 | if _, err := KeyPair(cfg); err != nil { 24 | t.Errorf("cfg.SSH.KeyPair() => _, %v, want nil error", err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/config/testdata/config.yaml: -------------------------------------------------------------------------------- 1 | # Soft Serve Server configurations 2 | 3 | name: "Test server name" 4 | -------------------------------------------------------------------------------- /pkg/config/testdata/k1.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b 2 | -------------------------------------------------------------------------------- /pkg/cron/cron.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/charmbracelet/log" 8 | "github.com/robfig/cron/v3" 9 | ) 10 | 11 | // Scheduler is a cron-like job scheduler. 12 | type Scheduler struct { 13 | *cron.Cron 14 | } 15 | 16 | // cronLogger is a wrapper around the logger to make it compatible with the 17 | // cron logger. 18 | type cronLogger struct { 19 | logger *log.Logger 20 | } 21 | 22 | // Info logs routine messages about cron's operation. 23 | func (l cronLogger) Info(msg string, keysAndValues ...interface{}) { 24 | l.logger.Debug(msg, keysAndValues...) 25 | } 26 | 27 | // Error logs an error condition. 28 | func (l cronLogger) Error(err error, msg string, keysAndValues ...interface{}) { 29 | l.logger.Error(msg, append(keysAndValues, "err", err)...) 30 | } 31 | 32 | // NewScheduler returns a new Cron. 33 | func NewScheduler(ctx context.Context) *Scheduler { 34 | logger := cronLogger{log.FromContext(ctx).WithPrefix("cron")} 35 | return &Scheduler{ 36 | Cron: cron.New(cron.WithLogger(logger)), 37 | } 38 | } 39 | 40 | // Shutdonw gracefully shuts down the Scheduler. 41 | func (s *Scheduler) Shutdown() { 42 | ctx, cancel := context.WithTimeout(s.Cron.Stop(), 30*time.Second) 43 | defer func() { cancel() }() 44 | <-ctx.Done() 45 | } 46 | 47 | // Start starts the Scheduler. 48 | func (s *Scheduler) Start() { 49 | s.Cron.Start() 50 | } 51 | 52 | // AddFunc adds a job to the Scheduler. 53 | func (s *Scheduler) AddFunc(spec string, fn func()) (int, error) { 54 | id, err := s.Cron.AddFunc(spec, fn) 55 | return int(id), err 56 | } 57 | 58 | // Remove removes a job from the Scheduler. 59 | func (s *Scheduler) Remove(id int) { 60 | s.Cron.Remove(cron.EntryID(id)) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/cron/cron_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/charmbracelet/log" 10 | ) 11 | 12 | func TestCronLogger(t *testing.T) { 13 | var buf bytes.Buffer 14 | logger := log.New(&buf) 15 | logger.SetLevel(log.DebugLevel) 16 | clogger := cronLogger{logger} 17 | clogger.Info("foo") 18 | clogger.Error(fmt.Errorf("bar"), "test") 19 | if buf.String() != "DEBU foo\nERRO test err=bar\n" { 20 | t.Errorf("unexpected log output: %s", buf.String()) 21 | } 22 | } 23 | 24 | func TestSchedularAddRemove(t *testing.T) { 25 | s := NewScheduler(context.TODO()) 26 | id, err := s.AddFunc("* * * * *", func() {}) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | s.Remove(id) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/daemon/conn.go: -------------------------------------------------------------------------------- 1 | package daemon 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | // connections is a synchronizes access to to a net.Conn pool. 12 | type connections struct { 13 | m map[net.Conn]struct{} 14 | mu sync.Mutex 15 | } 16 | 17 | func (m *connections) Add(c net.Conn) { 18 | m.mu.Lock() 19 | defer m.mu.Unlock() 20 | m.m[c] = struct{}{} 21 | } 22 | 23 | func (m *connections) Close(c net.Conn) error { 24 | m.mu.Lock() 25 | defer m.mu.Unlock() 26 | err := c.Close() 27 | delete(m.m, c) 28 | return err 29 | } 30 | 31 | func (m *connections) Size() int { 32 | m.mu.Lock() 33 | defer m.mu.Unlock() 34 | return len(m.m) 35 | } 36 | 37 | func (m *connections) CloseAll() error { 38 | m.mu.Lock() 39 | defer m.mu.Unlock() 40 | var err error 41 | for c := range m.m { 42 | err = errors.Join(err, c.Close()) 43 | delete(m.m, c) 44 | } 45 | 46 | return err 47 | } 48 | 49 | // serverConn is a wrapper around a net.Conn that closes the connection when 50 | // the one of the timeouts is reached. 51 | type serverConn struct { 52 | net.Conn 53 | 54 | initTimeout time.Duration 55 | idleTimeout time.Duration 56 | maxDeadline time.Time 57 | closeCanceler context.CancelFunc 58 | } 59 | 60 | var _ net.Conn = (*serverConn)(nil) 61 | 62 | func (c *serverConn) Write(p []byte) (n int, err error) { 63 | c.updateDeadline() 64 | n, err = c.Conn.Write(p) 65 | if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { 66 | c.closeCanceler() 67 | } 68 | return 69 | } 70 | 71 | func (c *serverConn) Read(b []byte) (n int, err error) { 72 | c.updateDeadline() 73 | n, err = c.Conn.Read(b) 74 | if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { 75 | c.closeCanceler() 76 | } 77 | return 78 | } 79 | 80 | func (c *serverConn) Close() (err error) { 81 | err = c.Conn.Close() 82 | if c.closeCanceler != nil { 83 | c.closeCanceler() 84 | } 85 | return 86 | } 87 | 88 | func (c *serverConn) updateDeadline() { 89 | switch { 90 | case c.initTimeout > 0: 91 | initTimeout := time.Now().Add(c.initTimeout) 92 | c.initTimeout = 0 93 | if initTimeout.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() { 94 | c.Conn.SetDeadline(initTimeout) // nolint: errcheck 95 | return 96 | } 97 | case c.idleTimeout > 0: 98 | idleDeadline := time.Now().Add(c.idleTimeout) 99 | if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() { 100 | c.Conn.SetDeadline(idleDeadline) // nolint: errcheck 101 | return 102 | } 103 | } 104 | c.Conn.SetDeadline(c.maxDeadline) // nolint: errcheck 105 | } 106 | -------------------------------------------------------------------------------- /pkg/db/context.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "context" 4 | 5 | // ContextKey is the key used to store the database in the context. 6 | var ContextKey = struct{ string }{"db"} 7 | 8 | // FromContext returns the database from the context. 9 | func FromContext(ctx context.Context) *DB { 10 | if db, ok := ctx.Value(ContextKey).(*DB); ok { 11 | return db 12 | } 13 | return nil 14 | } 15 | 16 | // WithContext returns a new context with the database. 17 | func WithContext(ctx context.Context, db *DB) context.Context { 18 | return context.WithValue(ctx, ContextKey, db) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/db/context_test.go: -------------------------------------------------------------------------------- 1 | package db_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/charmbracelet/soft-serve/pkg/db" 8 | "github.com/charmbracelet/soft-serve/pkg/db/internal/test" 9 | ) 10 | 11 | func TestBadFromContext(t *testing.T) { 12 | ctx := context.TODO() 13 | if c := db.FromContext(ctx); c != nil { 14 | t.Errorf("FromContext(ctx) => %v, want %v", c, nil) 15 | } 16 | } 17 | 18 | func TestGoodFromContext(t *testing.T) { 19 | ctx := context.TODO() 20 | dbx, err := test.OpenSqlite(ctx, t) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | ctx = db.WithContext(ctx, dbx) 25 | if c := db.FromContext(ctx); c == nil { 26 | t.Errorf("FromContext(ctx) => %v, want %v", c, dbx) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | 9 | "github.com/charmbracelet/log" 10 | "github.com/charmbracelet/soft-serve/pkg/config" 11 | "github.com/jmoiron/sqlx" 12 | _ "github.com/lib/pq" // postgres driver 13 | _ "modernc.org/sqlite" // sqlite driver 14 | ) 15 | 16 | // DB is the interface for a Soft Serve database. 17 | type DB struct { 18 | *sqlx.DB 19 | logger *log.Logger 20 | } 21 | 22 | // Open opens a database connection. 23 | func Open(ctx context.Context, driverName string, dsn string) (*DB, error) { 24 | db, err := sqlx.ConnectContext(ctx, driverName, dsn) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | d := &DB{ 30 | DB: db, 31 | } 32 | 33 | if config.IsVerbose() { 34 | logger := log.FromContext(ctx).WithPrefix("db") 35 | d.logger = logger 36 | } 37 | 38 | return d, nil 39 | } 40 | 41 | // Close implements db.DB. 42 | func (d *DB) Close() error { 43 | return d.DB.Close() 44 | } 45 | 46 | // Tx is a database transaction. 47 | type Tx struct { 48 | *sqlx.Tx 49 | logger *log.Logger 50 | } 51 | 52 | // Transaction implements db.DB. 53 | func (d *DB) Transaction(fn func(tx *Tx) error) error { 54 | return d.TransactionContext(context.Background(), fn) 55 | } 56 | 57 | // TransactionContext implements db.DB. 58 | func (d *DB) TransactionContext(ctx context.Context, fn func(tx *Tx) error) error { 59 | txx, err := d.DB.BeginTxx(ctx, nil) 60 | if err != nil { 61 | return fmt.Errorf("failed to begin transaction: %w", err) 62 | } 63 | 64 | tx := &Tx{txx, d.logger} 65 | if err := fn(tx); err != nil { 66 | return rollback(tx, err) 67 | } 68 | 69 | if err := tx.Commit(); err != nil { 70 | if errors.Is(err, sql.ErrTxDone) { 71 | // this is ok because whoever did finish the tx should have also written the error already. 72 | return nil 73 | } 74 | return fmt.Errorf("failed to commit transaction: %w", err) 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func rollback(tx *Tx, err error) error { 81 | if rerr := tx.Rollback(); rerr != nil { 82 | if errors.Is(rerr, sql.ErrTxDone) { 83 | return err 84 | } 85 | return fmt.Errorf("failed to rollback: %s: %w", err.Error(), rerr) 86 | } 87 | 88 | return err 89 | } 90 | -------------------------------------------------------------------------------- /pkg/db/db_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestOpenUnknownDriver(t *testing.T) { 10 | _, err := Open(context.TODO(), "invalid", "") 11 | if err == nil { 12 | t.Error("Open(invalid) => nil, want error") 13 | } 14 | if !strings.Contains(err.Error(), "unknown driver") { 15 | t.Errorf("Open(invalid) => %v, want error containing 'unknown driver'", err) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkg/db/errors.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | 7 | "github.com/lib/pq" 8 | sqlite "modernc.org/sqlite" 9 | sqlitelib "modernc.org/sqlite/lib" 10 | ) 11 | 12 | var ( 13 | // ErrDuplicateKey is a constraint violation error. 14 | ErrDuplicateKey = errors.New("duplicate key value violates table constraint") 15 | 16 | // ErrRecordNotFound is returned when a record is not found. 17 | ErrRecordNotFound = sql.ErrNoRows 18 | ) 19 | 20 | // WrapError is a convenient function that unite various database driver 21 | // errors to consistent errors. 22 | func WrapError(err error) error { 23 | if err != nil { 24 | if errors.Is(err, sql.ErrNoRows) { 25 | return ErrRecordNotFound 26 | } 27 | 28 | // Handle sqlite constraint error. 29 | if liteErr, ok := err.(*sqlite.Error); ok { 30 | code := liteErr.Code() 31 | if code == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY || 32 | code == sqlitelib.SQLITE_CONSTRAINT_FOREIGNKEY || 33 | code == sqlitelib.SQLITE_CONSTRAINT_UNIQUE { 34 | return ErrDuplicateKey 35 | } 36 | } 37 | 38 | // Handle postgres constraint error. 39 | if pgErr, ok := err.(*pq.Error); ok { 40 | if pgErr.Code == "23505" || 41 | pgErr.Code == "23503" || 42 | pgErr.Code == "23514" { 43 | return ErrDuplicateKey 44 | } 45 | } 46 | } 47 | return err 48 | } 49 | -------------------------------------------------------------------------------- /pkg/db/errors_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "testing" 8 | ) 9 | 10 | func TestWrapErrorBadNoRows(t *testing.T) { 11 | for _, e := range []error{ 12 | fmt.Errorf("foo"), 13 | errors.New("bar"), 14 | } { 15 | if err := WrapError(e); err != e { 16 | t.Errorf("WrapError(%v) => %v, want %v", e, err, e) 17 | } 18 | } 19 | } 20 | 21 | func TestWrapErrorGoodNoRows(t *testing.T) { 22 | if err := WrapError(sql.ErrNoRows); err != ErrRecordNotFound { 23 | t.Errorf("WrapError(sql.ErrNoRows) => %v, want %v", err, ErrRecordNotFound) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/db/handler.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/jmoiron/sqlx" 8 | ) 9 | 10 | // Handler is a database handler. 11 | type Handler interface { 12 | Rebind(string) string 13 | 14 | Select(interface{}, string, ...interface{}) error 15 | Get(interface{}, string, ...interface{}) error 16 | Queryx(string, ...interface{}) (*sqlx.Rows, error) 17 | QueryRowx(string, ...interface{}) *sqlx.Row 18 | Exec(string, ...interface{}) (sql.Result, error) 19 | 20 | SelectContext(context.Context, interface{}, string, ...interface{}) error 21 | GetContext(context.Context, interface{}, string, ...interface{}) error 22 | QueryxContext(context.Context, string, ...interface{}) (*sqlx.Rows, error) 23 | QueryRowxContext(context.Context, string, ...interface{}) *sqlx.Row 24 | ExecContext(context.Context, string, ...interface{}) (sql.Result, error) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/db/internal/test/test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/charmbracelet/soft-serve/pkg/db" 9 | ) 10 | 11 | // OpenSqlite opens a new temp SQLite database for testing. 12 | // It removes the database file when the test is done using tb.Cleanup. 13 | // If ctx is nil, context.TODO() is used. 14 | func OpenSqlite(ctx context.Context, tb testing.TB) (*db.DB, error) { 15 | if ctx == nil { 16 | ctx = context.TODO() 17 | } 18 | dbpath := filepath.Join(tb.TempDir(), "test.db") 19 | dbx, err := db.Open(ctx, "sqlite", dbpath) 20 | if err != nil { 21 | return nil, err 22 | } 23 | tb.Cleanup(func() { 24 | if err := dbx.Close(); err != nil { 25 | tb.Error(err) 26 | } 27 | }) 28 | return dbx, nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/db/migrate/0001_create_tables_postgres.down.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/soft-serve/3b983e90275ca3167b44fce1ead1fdcbce4aff56/pkg/db/migrate/0001_create_tables_postgres.down.sql -------------------------------------------------------------------------------- /pkg/db/migrate/0001_create_tables_sqlite.down.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/soft-serve/3b983e90275ca3167b44fce1ead1fdcbce4aff56/pkg/db/migrate/0001_create_tables_sqlite.down.sql -------------------------------------------------------------------------------- /pkg/db/migrate/0002_webhooks.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/db" 7 | ) 8 | 9 | const ( 10 | webhooksName = "webhooks" 11 | webhooksVersion = 2 12 | ) 13 | 14 | var webhooks = Migration{ 15 | Name: webhooksName, 16 | Version: webhooksVersion, 17 | Migrate: func(ctx context.Context, tx *db.Tx) error { 18 | return migrateUp(ctx, tx, webhooksVersion, webhooksName) 19 | }, 20 | Rollback: func(ctx context.Context, tx *db.Tx) error { 21 | return migrateDown(ctx, tx, webhooksVersion, webhooksName) 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /pkg/db/migrate/0002_webhooks_postgres.down.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/soft-serve/3b983e90275ca3167b44fce1ead1fdcbce4aff56/pkg/db/migrate/0002_webhooks_postgres.down.sql -------------------------------------------------------------------------------- /pkg/db/migrate/0002_webhooks_postgres.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS webhooks ( 2 | id SERIAL PRIMARY KEY, 3 | repo_id INTEGER NOT NULL, 4 | url TEXT NOT NULL, 5 | secret TEXT NOT NULL, 6 | content_type INTEGER NOT NULL, 7 | active BOOLEAN NOT NULL, 8 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | updated_at TIMESTAMP NOT NULL, 10 | UNIQUE (repo_id, url), 11 | CONSTRAINT repo_id_fk 12 | FOREIGN KEY(repo_id) REFERENCES repos(id) 13 | ON DELETE CASCADE 14 | ON UPDATE CASCADE 15 | ); 16 | 17 | CREATE TABLE IF NOT EXISTS webhook_events ( 18 | id SERIAL PRIMARY KEY, 19 | webhook_id INTEGER NOT NULL, 20 | event INTEGER NOT NULL, 21 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | UNIQUE (webhook_id, event), 23 | CONSTRAINT webhook_id_fk 24 | FOREIGN KEY(webhook_id) REFERENCES webhooks(id) 25 | ON DELETE CASCADE 26 | ON UPDATE CASCADE 27 | ); 28 | 29 | CREATE TABLE IF NOT EXISTS webhook_deliveries ( 30 | id TEXT PRIMARY KEY, 31 | webhook_id INTEGER NOT NULL, 32 | event INTEGER NOT NULL, 33 | request_url TEXT NOT NULL, 34 | request_method TEXT NOT NULL, 35 | request_error TEXT, 36 | request_headers TEXT NOT NULL, 37 | request_body TEXT NOT NULL, 38 | response_status INTEGER NOT NULL, 39 | response_headers TEXT NOT NULL, 40 | response_body TEXT NOT NULL, 41 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 42 | CONSTRAINT webhook_id_fk 43 | FOREIGN KEY(webhook_id) REFERENCES webhooks(id) 44 | ON DELETE CASCADE 45 | ON UPDATE CASCADE 46 | ); 47 | -------------------------------------------------------------------------------- /pkg/db/migrate/0002_webhooks_sqlite.down.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/soft-serve/3b983e90275ca3167b44fce1ead1fdcbce4aff56/pkg/db/migrate/0002_webhooks_sqlite.down.sql -------------------------------------------------------------------------------- /pkg/db/migrate/0002_webhooks_sqlite.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS webhooks ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | repo_id INTEGER NOT NULL, 4 | url TEXT NOT NULL, 5 | secret TEXT NOT NULL, 6 | content_type INTEGER NOT NULL, 7 | active BOOLEAN NOT NULL, 8 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | updated_at DATETIME NOT NULL, 10 | UNIQUE (repo_id, url), 11 | CONSTRAINT repo_id_fk 12 | FOREIGN KEY(repo_id) REFERENCES repos(id) 13 | ON DELETE CASCADE 14 | ON UPDATE CASCADE 15 | ); 16 | 17 | CREATE TABLE IF NOT EXISTS webhook_events ( 18 | id INTEGER PRIMARY KEY AUTOINCREMENT, 19 | webhook_id INTEGER NOT NULL, 20 | event INTEGER NOT NULL, 21 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | UNIQUE (webhook_id, event), 23 | CONSTRAINT webhook_id_fk 24 | FOREIGN KEY(webhook_id) REFERENCES webhooks(id) 25 | ON DELETE CASCADE 26 | ON UPDATE CASCADE 27 | ); 28 | 29 | CREATE TABLE IF NOT EXISTS webhook_deliveries ( 30 | id TEXT PRIMARY KEY, 31 | webhook_id INTEGER NOT NULL, 32 | event INTEGER NOT NULL, 33 | request_url TEXT NOT NULL, 34 | request_method TEXT NOT NULL, 35 | request_error TEXT, 36 | request_headers TEXT NOT NULL, 37 | request_body TEXT NOT NULL, 38 | response_status INTEGER NOT NULL, 39 | response_headers TEXT NOT NULL, 40 | response_body TEXT NOT NULL, 41 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 42 | CONSTRAINT webhook_id_fk 43 | FOREIGN KEY(webhook_id) REFERENCES webhooks(id) 44 | ON DELETE CASCADE 45 | ON UPDATE CASCADE 46 | ); 47 | -------------------------------------------------------------------------------- /pkg/db/migrate/0003_migrate_lfs_objects.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | 9 | "github.com/charmbracelet/log" 10 | "github.com/charmbracelet/soft-serve/pkg/config" 11 | "github.com/charmbracelet/soft-serve/pkg/db" 12 | "github.com/charmbracelet/soft-serve/pkg/db/models" 13 | ) 14 | 15 | const ( 16 | migrateLfsObjectsName = "migrate_lfs_objects" 17 | migrateLfsObjectsVersion = 3 18 | ) 19 | 20 | // Correct LFS objects relative path. 21 | // From OID[:2]/OID[2:4]/OID[4:] to OID[:2]/OID[2:4]/OID 22 | // See: https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#intercepting-git 23 | var migrateLfsObjects = Migration{ 24 | Name: migrateLfsObjectsName, 25 | Version: migrateLfsObjectsVersion, 26 | Migrate: func(ctx context.Context, tx *db.Tx) error { 27 | cfg := config.FromContext(ctx) 28 | logger := log.FromContext(ctx).WithPrefix("migrate_lfs_objects") 29 | 30 | var repoIDs []int64 31 | if err := tx.Select(&repoIDs, "SELECT id FROM repos"); err != nil { 32 | return err 33 | } 34 | for _, r := range repoIDs { 35 | var objs []models.LFSObject 36 | if err := tx.Select(&objs, "SELECT * FROM lfs_objects WHERE repo_id = ?", r); err != nil { 37 | return err 38 | } 39 | objsp := filepath.Join(cfg.DataPath, "lfs", strconv.FormatInt(r, 10), "objects") 40 | for _, obj := range objs { 41 | oldpath := filepath.Join(objsp, badRelativePath(obj.Oid)) 42 | newpath := filepath.Join(objsp, goodRelativePath(obj.Oid)) 43 | if _, err := os.Stat(oldpath); err == nil { 44 | if err := os.Rename(oldpath, newpath); err != nil { 45 | logger.Error("rename lfs object", "oldpath", oldpath, "newpath", newpath, "err", err) 46 | continue 47 | } 48 | } 49 | } 50 | } 51 | return nil 52 | }, 53 | Rollback: func(context.Context, *db.Tx) error { 54 | return nil 55 | }, 56 | } 57 | 58 | func goodRelativePath(oid string) string { 59 | if len(oid) < 5 { 60 | return oid 61 | } 62 | return filepath.Join(oid[:2], oid[2:4], oid) 63 | } 64 | 65 | func badRelativePath(oid string) string { 66 | if len(oid) < 5 { 67 | return oid 68 | } 69 | return filepath.Join(oid[:2], oid[2:4], oid[4:]) 70 | } 71 | -------------------------------------------------------------------------------- /pkg/db/migrate/migrate_test.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/charmbracelet/soft-serve/pkg/config" 8 | "github.com/charmbracelet/soft-serve/pkg/db/internal/test" 9 | ) 10 | 11 | func TestMigrate(t *testing.T) { 12 | // XXX: we need a config.Config in the context for the migrations to run 13 | // properly. Some migrations depend on the config being present. 14 | ctx := config.WithContext(context.TODO(), config.DefaultConfig()) 15 | dbx, err := test.OpenSqlite(ctx, t) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | if err := Migrate(ctx, dbx); err != nil { 20 | t.Errorf("Migrate() => %v, want nil error", err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/db/migrate/migrations.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/charmbracelet/soft-serve/pkg/db" 11 | ) 12 | 13 | //go:embed *.sql 14 | var sqls embed.FS 15 | 16 | // Keep this in order of execution, oldest to newest. 17 | var migrations = []Migration{ 18 | createTables, 19 | webhooks, 20 | migrateLfsObjects, 21 | } 22 | 23 | func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error { 24 | direction := "up" 25 | if down { 26 | direction = "down" 27 | } 28 | 29 | driverName := tx.DriverName() 30 | if driverName == "sqlite3" { 31 | driverName = "sqlite" 32 | } 33 | 34 | fn := fmt.Sprintf("%04d_%s_%s.%s.sql", version, toSnakeCase(name), driverName, direction) 35 | sqlstr, err := sqls.ReadFile(fn) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | if _, err := tx.ExecContext(ctx, string(sqlstr)); err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func migrateUp(ctx context.Context, tx *db.Tx, version int, name string) error { 48 | return execMigration(ctx, tx, version, name, false) 49 | } 50 | 51 | func migrateDown(ctx context.Context, tx *db.Tx, version int, name string) error { 52 | return execMigration(ctx, tx, version, name, true) 53 | } 54 | 55 | var ( 56 | matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") 57 | matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") 58 | ) 59 | 60 | func toSnakeCase(str string) string { 61 | str = strings.ReplaceAll(str, "-", "_") 62 | str = strings.ReplaceAll(str, " ", "_") 63 | snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") 64 | snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") 65 | return strings.ToLower(snake) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/db/models/access_token.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | // AccessToken represents an access token. 9 | type AccessToken struct { 10 | ID int64 `db:"id"` 11 | Name string `db:"name"` 12 | UserID int64 `db:"user_id"` 13 | Token string `db:"token"` 14 | ExpiresAt sql.NullTime `db:"expires_at"` 15 | CreatedAt time.Time `db:"created_at"` 16 | UpdatedAt time.Time `db:"updated_at"` 17 | } 18 | -------------------------------------------------------------------------------- /pkg/db/models/collab.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/access" 7 | ) 8 | 9 | // Collab represents a repository collaborator. 10 | type Collab struct { 11 | ID int64 `db:"id"` 12 | RepoID int64 `db:"repo_id"` 13 | UserID int64 `db:"user_id"` 14 | AccessLevel access.AccessLevel `db:"access_level"` 15 | CreatedAt time.Time `db:"created_at"` 16 | UpdatedAt time.Time `db:"updated_at"` 17 | } 18 | -------------------------------------------------------------------------------- /pkg/db/models/lfs.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | // LFSObject is a Git LFS object. 6 | type LFSObject struct { 7 | ID int64 `db:"id"` 8 | Oid string `db:"oid"` 9 | Size int64 `db:"size"` 10 | RepoID int64 `db:"repo_id"` 11 | CreatedAt time.Time `db:"created_at"` 12 | UpdatedAt time.Time `db:"updated_at"` 13 | } 14 | 15 | // LFSLock is a Git LFS lock. 16 | type LFSLock struct { 17 | ID int64 `db:"id"` 18 | Path string `db:"path"` 19 | UserID int64 `db:"user_id"` 20 | RepoID int64 `db:"repo_id"` 21 | Refname string `db:"refname"` 22 | CreatedAt time.Time `db:"created_at"` 23 | UpdatedAt time.Time `db:"updated_at"` 24 | } 25 | -------------------------------------------------------------------------------- /pkg/db/models/public_key.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // PublicKey represents a public key. 4 | type PublicKey struct { 5 | ID int64 `db:"id"` 6 | UserID int64 `db:"user_id"` 7 | PublicKey string `db:"public_key"` 8 | CreatedAt string `db:"created_at"` 9 | UpdatedAt string `db:"updated_at"` 10 | } 11 | -------------------------------------------------------------------------------- /pkg/db/models/repo.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | // Repo is a database model for a repository. 9 | type Repo struct { 10 | ID int64 `db:"id"` 11 | Name string `db:"name"` 12 | ProjectName string `db:"project_name"` 13 | Description string `db:"description"` 14 | Private bool `db:"private"` 15 | Mirror bool `db:"mirror"` 16 | Hidden bool `db:"hidden"` 17 | UserID sql.NullInt64 `db:"user_id"` 18 | CreatedAt time.Time `db:"created_at"` 19 | UpdatedAt time.Time `db:"updated_at"` 20 | } 21 | -------------------------------------------------------------------------------- /pkg/db/models/settings.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Settings represents a settings record. 4 | type Settings struct { 5 | ID int64 `db:"id"` 6 | Key string `db:"key"` 7 | Value string `db:"value"` 8 | CreatedAt string `db:"created_at"` 9 | UpdatedAt string `db:"updated_at"` 10 | } 11 | -------------------------------------------------------------------------------- /pkg/db/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | // User represents a user. 9 | type User struct { 10 | ID int64 `db:"id"` 11 | Username string `db:"username"` 12 | Admin bool `db:"admin"` 13 | Password sql.NullString `db:"password"` 14 | CreatedAt time.Time `db:"created_at"` 15 | UpdatedAt time.Time `db:"updated_at"` 16 | } 17 | -------------------------------------------------------------------------------- /pkg/db/models/webhook.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // Webhook is a repository webhook. 11 | type Webhook struct { 12 | ID int64 `db:"id"` 13 | RepoID int64 `db:"repo_id"` 14 | URL string `db:"url"` 15 | Secret string `db:"secret"` 16 | ContentType int `db:"content_type"` 17 | Active bool `db:"active"` 18 | CreatedAt time.Time `db:"created_at"` 19 | UpdatedAt time.Time `db:"updated_at"` 20 | } 21 | 22 | // WebhookEvent is a webhook event. 23 | type WebhookEvent struct { 24 | ID int64 `db:"id"` 25 | WebhookID int64 `db:"webhook_id"` 26 | Event int `db:"event"` 27 | CreatedAt time.Time `db:"created_at"` 28 | } 29 | 30 | // WebhookDelivery is a webhook delivery. 31 | type WebhookDelivery struct { 32 | ID uuid.UUID `db:"id"` 33 | WebhookID int64 `db:"webhook_id"` 34 | Event int `db:"event"` 35 | RequestURL string `db:"request_url"` 36 | RequestMethod string `db:"request_method"` 37 | RequestError sql.NullString `db:"request_error"` 38 | RequestHeaders string `db:"request_headers"` 39 | RequestBody string `db:"request_body"` 40 | ResponseStatus int `db:"response_status"` 41 | ResponseHeaders string `db:"response_headers"` 42 | ResponseBody string `db:"response_body"` 43 | CreatedAt time.Time `db:"created_at"` 44 | } 45 | -------------------------------------------------------------------------------- /pkg/git/errors.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrNotAuthed represents unauthorized access. 7 | ErrNotAuthed = errors.New("you are not authorized to do this") 8 | 9 | // ErrSystemMalfunction represents a general system error returned to clients. 10 | ErrSystemMalfunction = errors.New("something went wrong") 11 | 12 | // ErrInvalidRepo represents an attempt to access a non-existent repo. 13 | ErrInvalidRepo = errors.New("invalid repo") 14 | 15 | // ErrInvalidRequest represents an invalid request. 16 | ErrInvalidRequest = errors.New("invalid request") 17 | 18 | // ErrMaxConnections represents a maximum connection limit being reached. 19 | ErrMaxConnections = errors.New("too many connections, try again later") 20 | 21 | // ErrTimeout is returned when the maximum read timeout is exceeded. 22 | ErrTimeout = errors.New("I/O timeout reached") 23 | ) 24 | -------------------------------------------------------------------------------- /pkg/git/git_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "testing" 9 | 10 | "github.com/charmbracelet/soft-serve/git" 11 | ) 12 | 13 | func TestPktline(t *testing.T) { 14 | cases := []struct { 15 | name string 16 | in []byte 17 | err error 18 | out []byte 19 | }{ 20 | { 21 | name: "empty", 22 | in: []byte{}, 23 | out: []byte("0005\n0000"), 24 | }, 25 | { 26 | name: "simple", 27 | in: []byte("hello"), 28 | out: []byte("000ahello\n0000"), 29 | }, 30 | { 31 | name: "newline", 32 | in: []byte("hello\n"), 33 | out: []byte("000bhello\n\n0000"), 34 | }, 35 | { 36 | name: "error", 37 | err: fmt.Errorf("foobar"), 38 | out: []byte("000fERR foobar\n0000"), 39 | }, 40 | } 41 | 42 | for _, c := range cases { 43 | t.Run(c.name, func(t *testing.T) { 44 | var out bytes.Buffer 45 | if c.err == nil { 46 | if err := WritePktline(&out, string(c.in)); err != nil { 47 | t.Fatal(err) 48 | } 49 | } else { 50 | if err := WritePktlineErr(&out, c.err); err != nil { 51 | t.Fatal(err) 52 | } 53 | } 54 | 55 | if !bytes.Equal(out.Bytes(), c.out) { 56 | t.Errorf("expected %q, got %q", c.out, out.Bytes()) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestEnsureWithinBad(t *testing.T) { 63 | tmp := t.TempDir() 64 | for _, f := range []string{ 65 | "..", 66 | "../../../", 67 | } { 68 | if err := EnsureWithin(tmp, f); err == nil { 69 | t.Errorf("EnsureWithin(%q, %q) => nil, want non-nil error", tmp, f) 70 | } 71 | } 72 | } 73 | 74 | func TestEnsureWithinGood(t *testing.T) { 75 | tmp := t.TempDir() 76 | for _, f := range []string{ 77 | tmp, 78 | tmp + "/foo", 79 | tmp + "/foo/bar", 80 | } { 81 | if err := EnsureWithin(tmp, f); err != nil { 82 | t.Errorf("EnsureWithin(%q, %q) => %v, want nil error", tmp, f, err) 83 | } 84 | } 85 | } 86 | 87 | func TestEnsureDefaultBranchEmpty(t *testing.T) { 88 | tmp := t.TempDir() 89 | r, err := git.Init(tmp, false) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | if err := EnsureDefaultBranch(context.TODO(), r.Path); !errors.Is(err, ErrNoBranches) { 95 | t.Errorf("EnsureDefaultBranch(%q) => %v, want ErrNoBranches", tmp, err) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/git/lfs_auth.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/charmbracelet/log" 11 | "github.com/charmbracelet/soft-serve/pkg/config" 12 | "github.com/charmbracelet/soft-serve/pkg/jwk" 13 | "github.com/charmbracelet/soft-serve/pkg/lfs" 14 | "github.com/charmbracelet/soft-serve/pkg/proto" 15 | "github.com/golang-jwt/jwt/v5" 16 | ) 17 | 18 | // LFSAuthenticate implements teh Git LFS SSH authentication command. 19 | // Context must have *config.Config, *log.Logger, proto.User. 20 | // cmd.Args should have the repo path and operation as arguments. 21 | func LFSAuthenticate(ctx context.Context, cmd ServiceCommand) error { 22 | if len(cmd.Args) < 2 { 23 | return errors.New("missing args") 24 | } 25 | 26 | logger := log.FromContext(ctx).WithPrefix("ssh.lfs-authenticate") 27 | operation := cmd.Args[1] 28 | if operation != lfs.OperationDownload && operation != lfs.OperationUpload { 29 | logger.Errorf("invalid operation: %s", operation) 30 | return errors.New("invalid operation") 31 | } 32 | 33 | user := proto.UserFromContext(ctx) 34 | if user == nil { 35 | logger.Errorf("missing user") 36 | return proto.ErrUserNotFound 37 | } 38 | 39 | repo := proto.RepositoryFromContext(ctx) 40 | if repo == nil { 41 | logger.Errorf("missing repository") 42 | return proto.ErrRepoNotFound 43 | } 44 | 45 | cfg := config.FromContext(ctx) 46 | kp, err := jwk.NewPair(cfg) 47 | if err != nil { 48 | logger.Error("failed to get JWK pair", "err", err) 49 | return err 50 | } 51 | 52 | now := time.Now() 53 | expiresIn := time.Minute * 5 54 | expiresAt := now.Add(expiresIn) 55 | claims := jwt.RegisteredClaims{ 56 | Subject: fmt.Sprintf("%s#%d", user.Username(), user.ID()), 57 | ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour 58 | NotBefore: jwt.NewNumericDate(now), 59 | IssuedAt: jwt.NewNumericDate(now), 60 | Issuer: cfg.HTTP.PublicURL, 61 | Audience: []string{ 62 | repo.Name(), 63 | }, 64 | } 65 | 66 | token := jwt.NewWithClaims(jwk.SigningMethod, claims) 67 | token.Header["kid"] = kp.JWK().KeyID 68 | j, err := token.SignedString(kp.PrivateKey()) 69 | if err != nil { 70 | logger.Error("failed to sign token", "err", err) 71 | return err 72 | } 73 | 74 | href := fmt.Sprintf("%s/%s.git/info/lfs", cfg.HTTP.PublicURL, repo.Name()) 75 | logger.Debug("generated token", "token", j, "href", href, "expires_at", expiresAt) 76 | 77 | return json.NewEncoder(cmd.Stdout).Encode(lfs.AuthenticateResponse{ 78 | Header: map[string]string{ 79 | "Authorization": fmt.Sprintf("Bearer %s", j), 80 | }, 81 | Href: href, 82 | ExpiresAt: expiresAt, 83 | ExpiresIn: expiresIn, 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /pkg/git/lfs_log.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "github.com/charmbracelet/git-lfs-transfer/transfer" 5 | "github.com/charmbracelet/log" 6 | ) 7 | 8 | type lfsLogger struct { 9 | l *log.Logger 10 | } 11 | 12 | var _ transfer.Logger = &lfsLogger{} 13 | 14 | // Log implements transfer.Logger. 15 | func (l *lfsLogger) Log(msg string, kv ...interface{}) { 16 | l.l.Debug(msg, kv...) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/hooks/gen_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/charmbracelet/soft-serve/git" 10 | "github.com/charmbracelet/soft-serve/pkg/config" 11 | ) 12 | 13 | func TestGenerateHooks(t *testing.T) { 14 | tmp := t.TempDir() 15 | cfg := config.DefaultConfig() 16 | cfg.DataPath = tmp 17 | repoPath := filepath.Join(tmp, "repos", "test.git") 18 | _, err := git.Init(repoPath, true) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | 23 | if err := GenerateHooks(context.TODO(), cfg, "test.git"); err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | for _, hn := range []string{ 28 | PreReceiveHook, 29 | UpdateHook, 30 | PostReceiveHook, 31 | PostUpdateHook, 32 | } { 33 | if _, err := os.Stat(filepath.Join(repoPath, "hooks", hn)); err != nil { 34 | t.Fatal(err) 35 | } 36 | if _, err := os.Stat(filepath.Join(repoPath, "hooks", hn+".d", "soft-serve")); err != nil { 37 | t.Fatal(err) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/hooks/hooks.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | // HookArg is an argument to a git hook. 9 | type HookArg struct { 10 | OldSha string 11 | NewSha string 12 | RefName string 13 | } 14 | 15 | // Hooks provides an interface for git server-side hooks. 16 | type Hooks interface { 17 | PreReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg) 18 | Update(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, arg HookArg) 19 | PostReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg) 20 | PostUpdate(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args ...string) 21 | } 22 | -------------------------------------------------------------------------------- /pkg/jobs/jobs.go: -------------------------------------------------------------------------------- 1 | package jobs 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | ) 7 | 8 | // Job is a job that can be registered with the scheduler. 9 | type Job struct { 10 | ID int 11 | Runner Runner 12 | } 13 | 14 | // Runner is a job runner. 15 | type Runner interface { 16 | Spec(context.Context) string 17 | Func(context.Context) func() 18 | } 19 | 20 | var ( 21 | mtx sync.Mutex 22 | jobs = make(map[string]*Job, 0) 23 | ) 24 | 25 | // Register registers a job. 26 | func Register(name string, runner Runner) { 27 | mtx.Lock() 28 | defer mtx.Unlock() 29 | jobs[name] = &Job{Runner: runner} 30 | } 31 | 32 | // List returns a map of registered jobs. 33 | func List() map[string]*Job { 34 | mtx.Lock() 35 | defer mtx.Unlock() 36 | return jobs 37 | } 38 | -------------------------------------------------------------------------------- /pkg/jwk/jwk.go: -------------------------------------------------------------------------------- 1 | package jwk 2 | 3 | import ( 4 | "crypto" 5 | "crypto/sha256" 6 | "fmt" 7 | 8 | "github.com/charmbracelet/soft-serve/pkg/config" 9 | "github.com/go-jose/go-jose/v3" 10 | "github.com/golang-jwt/jwt/v5" 11 | ) 12 | 13 | // SigningMethod is a JSON Web Token signing method. It uses Ed25519 keys to 14 | // sign and verify tokens. 15 | var SigningMethod = &jwt.SigningMethodEd25519{} 16 | 17 | // Pair is a JSON Web Key pair. 18 | type Pair struct { 19 | privateKey crypto.PrivateKey 20 | jwk jose.JSONWebKey 21 | } 22 | 23 | // PrivateKey returns the private key. 24 | func (p Pair) PrivateKey() crypto.PrivateKey { 25 | return p.privateKey 26 | } 27 | 28 | // JWK returns the JSON Web Key. 29 | func (p Pair) JWK() jose.JSONWebKey { 30 | return p.jwk 31 | } 32 | 33 | // NewPair creates a new JSON Web Key pair. 34 | func NewPair(cfg *config.Config) (Pair, error) { 35 | kp, err := config.KeyPair(cfg) 36 | if err != nil { 37 | return Pair{}, err 38 | } 39 | 40 | sum := sha256.Sum256(kp.RawPrivateKey()) 41 | kid := fmt.Sprintf("%x", sum) 42 | jwk := jose.JSONWebKey{ 43 | Key: kp.CryptoPublicKey(), 44 | KeyID: kid, 45 | Algorithm: SigningMethod.Alg(), 46 | } 47 | 48 | return Pair{privateKey: kp.PrivateKey(), jwk: jwk}, nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/jwk/jwk_test.go: -------------------------------------------------------------------------------- 1 | package jwk 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/charmbracelet/soft-serve/pkg/config" 8 | ) 9 | 10 | func TestBadNewPair(t *testing.T) { 11 | _, err := NewPair(nil) 12 | if !errors.Is(err, config.ErrNilConfig) { 13 | t.Errorf("NewPair(nil) => %v, want %v", err, config.ErrNilConfig) 14 | } 15 | } 16 | 17 | func TestGoodNewPair(t *testing.T) { 18 | cfg := config.DefaultConfig() 19 | if _, err := NewPair(cfg); err != nil { 20 | t.Errorf("NewPair(cfg) => _, %v, want nil error", err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/lfs/client.go: -------------------------------------------------------------------------------- 1 | package lfs 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | // DownloadCallback gets called for every requested LFS object to process its content 9 | type DownloadCallback func(p Pointer, content io.ReadCloser, objectError error) error 10 | 11 | // UploadCallback gets called for every requested LFS object to provide its content 12 | type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error) 13 | 14 | // Client is a Git LFS client to communicate with a LFS source API. 15 | type Client interface { 16 | Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error 17 | Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error 18 | } 19 | 20 | // NewClient returns a new Git LFS client. 21 | func NewClient(e Endpoint) Client { 22 | if e.Scheme == "http" || e.Scheme == "https" { 23 | return newHTTPClient(e) 24 | } 25 | // TODO: support ssh client 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/lfs/endpoint.go: -------------------------------------------------------------------------------- 1 | package lfs 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | // Endpoint is a Git LFS endpoint. 10 | type Endpoint = *url.URL 11 | 12 | // NewEndpoint returns a new Git LFS endpoint. 13 | func NewEndpoint(rawurl string) (Endpoint, error) { 14 | u, err := url.Parse(rawurl) 15 | if err != nil { 16 | e, err := endpointFromBareSSH(rawurl) 17 | if err != nil { 18 | return nil, err 19 | } 20 | u = e 21 | } 22 | 23 | u.Path = strings.TrimSuffix(u.Path, "/") 24 | 25 | switch u.Scheme { 26 | case "git": 27 | // Use https for git:// URLs and strip the port if it exists. 28 | u.Scheme = "https" 29 | if u.Port() != "" { 30 | u.Host = u.Hostname() 31 | } 32 | fallthrough 33 | case "http", "https": 34 | if strings.HasSuffix(u.Path, ".git") { 35 | u.Path += "/info/lfs" 36 | } else { 37 | u.Path += ".git/info/lfs" 38 | } 39 | case "ssh", "git+ssh", "ssh+git": 40 | default: 41 | return nil, fmt.Errorf("unknown url: %s", rawurl) 42 | } 43 | 44 | return u, nil 45 | } 46 | 47 | // endpointFromBareSSH creates a new endpoint from a bare ssh repo. 48 | // 49 | // user@host.com:path/to/repo.git or 50 | // [user@host.com:port]:path/to/repo.git 51 | func endpointFromBareSSH(rawurl string) (*url.URL, error) { 52 | parts := strings.Split(rawurl, ":") 53 | partsLen := len(parts) 54 | if partsLen < 2 { 55 | return url.Parse(rawurl) 56 | } 57 | 58 | // Treat presence of ':' as a bare URL 59 | var newPath string 60 | if len(parts) > 2 { // port included; really should only ever be 3 parts 61 | // Correctly handle [host:port]:path URLs 62 | parts[0] = strings.TrimPrefix(parts[0], "[") 63 | parts[1] = strings.TrimSuffix(parts[1], "]") 64 | newPath = fmt.Sprintf("%v:%v", parts[0], strings.Join(parts[1:], "/")) 65 | } else { 66 | newPath = strings.Join(parts, "/") 67 | } 68 | newrawurl := fmt.Sprintf("ssh://%v", newPath) 69 | return url.Parse(newrawurl) 70 | } 71 | -------------------------------------------------------------------------------- /pkg/lfs/pointer_test.go: -------------------------------------------------------------------------------- 1 | package lfs 2 | 3 | import ( 4 | "errors" 5 | "path" 6 | "strconv" 7 | "testing" 8 | ) 9 | 10 | func TestReadPointer(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | content string 14 | want Pointer 15 | wantErr error 16 | wantErrp interface{} 17 | }{ 18 | { 19 | name: "valid pointer", 20 | content: `version https://git-lfs.github.com/spec/v1 21 | oid sha256:1234567890123456789012345678901234567890123456789012345678901234 22 | size 1234 23 | `, 24 | want: Pointer{ 25 | Oid: "1234567890123456789012345678901234567890123456789012345678901234", 26 | Size: 1234, 27 | }, 28 | }, 29 | { 30 | name: "invalid prefix", 31 | content: `version https://foobar/spec/v2 32 | oid sha256:1234567890123456789012345678901234567890123456789012345678901234 33 | size 1234 34 | `, 35 | wantErr: ErrMissingPrefix, 36 | }, 37 | { 38 | name: "invalid oid", 39 | content: `version https://git-lfs.github.com/spec/v1 40 | oid sha256:&2345a78$012345678901234567890123456789012345678901234567890123 41 | size 1234 42 | `, 43 | wantErr: ErrInvalidOIDFormat, 44 | }, 45 | { 46 | name: "invalid size", 47 | content: `version https://git-lfs.github.com/spec/v1 48 | oid sha256:1234567890123456789012345678901234567890123456789012345678901234 49 | size abc 50 | `, 51 | wantErrp: &strconv.NumError{}, 52 | }, 53 | { 54 | name: "invalid structure", 55 | content: `version https://git-lfs.github.com/spec/v1 56 | `, 57 | wantErr: ErrInvalidStructure, 58 | }, 59 | { 60 | name: "empty pointer", 61 | wantErr: ErrMissingPrefix, 62 | }, 63 | } 64 | 65 | for _, tc := range cases { 66 | t.Run(tc.name, func(t *testing.T) { 67 | p, err := ReadPointerFromBuffer([]byte(tc.content)) 68 | if err != tc.wantErr && !errors.As(err, &tc.wantErrp) { 69 | t.Errorf("ReadPointerFromBuffer() error = %v(%T), wantErr %v(%T)", err, err, tc.wantErr, tc.wantErr) 70 | return 71 | } 72 | if err != nil { 73 | return 74 | } 75 | 76 | if err == nil { 77 | if !p.IsValid() { 78 | t.Errorf("Expected a valid pointer") 79 | return 80 | } 81 | if path.Join(p.Oid[:2], p.Oid[2:4], p.Oid) != p.RelativePath() { 82 | t.Errorf("Expected a valid relative path") 83 | return 84 | } 85 | } 86 | 87 | if p.Oid != tc.want.Oid { 88 | t.Errorf("ReadPointerFromBuffer() oid = %v, want %v", p.Oid, tc.want.Oid) 89 | } 90 | if p.Size != tc.want.Size { 91 | t.Errorf("ReadPointerFromBuffer() size = %v, want %v", p.Size, tc.want.Size) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/lfs/ssh_client.go: -------------------------------------------------------------------------------- 1 | package lfs 2 | 3 | // TODO: implement Git LFS SSH client. 4 | -------------------------------------------------------------------------------- /pkg/lfs/transfer.go: -------------------------------------------------------------------------------- 1 | package lfs 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | // TransferBasic is the name of the Git LFS basic transfer protocol. 9 | const TransferBasic = "basic" 10 | 11 | // TransferAdapter represents an adapter for downloading/uploading LFS objects 12 | type TransferAdapter interface { 13 | Name() string 14 | Download(ctx context.Context, p Pointer, l *Link) (io.ReadCloser, error) 15 | Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error 16 | Verify(ctx context.Context, p Pointer, l *Link) error 17 | } 18 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "time" 7 | 8 | "github.com/charmbracelet/log" 9 | "github.com/charmbracelet/soft-serve/pkg/config" 10 | ) 11 | 12 | // NewLogger returns a new logger with default settings. 13 | func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) { 14 | if cfg == nil { 15 | return nil, nil, config.ErrNilConfig 16 | } 17 | logger := log.NewWithOptions(os.Stderr, log.Options{ 18 | ReportTimestamp: true, 19 | TimeFormat: time.DateOnly, 20 | }) 21 | 22 | switch { 23 | case config.IsVerbose(): 24 | logger.SetReportCaller(true) 25 | fallthrough 26 | case config.IsDebug(): 27 | logger.SetLevel(log.DebugLevel) 28 | } 29 | 30 | logger.SetTimeFormat(cfg.Log.TimeFormat) 31 | 32 | switch strings.ToLower(cfg.Log.Format) { 33 | case "json": 34 | logger.SetFormatter(log.JSONFormatter) 35 | case "logfmt": 36 | logger.SetFormatter(log.LogfmtFormatter) 37 | case "text": 38 | logger.SetFormatter(log.TextFormatter) 39 | } 40 | 41 | var f *os.File 42 | if cfg.Log.Path != "" { 43 | var err error 44 | f, err = os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) 45 | if err != nil { 46 | return nil, nil, err 47 | } 48 | logger.SetOutput(f) 49 | } 50 | 51 | return logger, f, nil 52 | } 53 | -------------------------------------------------------------------------------- /pkg/log/log_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/charmbracelet/soft-serve/pkg/config" 8 | ) 9 | 10 | func TestGoodNewLogger(t *testing.T) { 11 | for _, c := range []*config.Config{ 12 | config.DefaultConfig(), 13 | {}, 14 | {Log: config.LogConfig{Path: filepath.Join(t.TempDir(), "logfile.txt")}}, 15 | } { 16 | _, f, err := NewLogger(c) 17 | if err != nil { 18 | t.Errorf("expected nil got %v", err) 19 | } 20 | if f != nil { 21 | if err := f.Close(); err != nil { 22 | t.Errorf("failed to close logger: %v", err) 23 | } 24 | } 25 | } 26 | } 27 | 28 | func TestBadNewLogger(t *testing.T) { 29 | for _, c := range []*config.Config{ 30 | nil, 31 | {Log: config.LogConfig{Path: "\x00"}}, 32 | } { 33 | _, f, err := NewLogger(c) 34 | if err == nil { 35 | t.Errorf("expected error got nil") 36 | } 37 | if f != nil { 38 | if err := f.Close(); err != nil { 39 | t.Errorf("failed to close logger: %v", err) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/proto/access_token.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import "time" 4 | 5 | // AccessToken represents an access token. 6 | type AccessToken struct { 7 | ID int64 8 | Name string 9 | UserID int64 10 | TokenHash string 11 | ExpiresAt time.Time 12 | CreatedAt time.Time 13 | } 14 | -------------------------------------------------------------------------------- /pkg/proto/context.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import "context" 4 | 5 | // ContextKeyRepository is the context key for the repository. 6 | var ContextKeyRepository = &struct{ string }{"repository"} 7 | 8 | // ContextKeyUser is the context key for the user. 9 | var ContextKeyUser = &struct{ string }{"user"} 10 | 11 | // RepositoryFromContext returns the repository from the context. 12 | func RepositoryFromContext(ctx context.Context) Repository { 13 | if r, ok := ctx.Value(ContextKeyRepository).(Repository); ok { 14 | return r 15 | } 16 | return nil 17 | } 18 | 19 | // UserFromContext returns the user from the context. 20 | func UserFromContext(ctx context.Context) User { 21 | if u, ok := ctx.Value(ContextKeyUser).(User); ok { 22 | return u 23 | } 24 | return nil 25 | } 26 | 27 | // WithRepositoryContext returns a new context with the repository. 28 | func WithRepositoryContext(ctx context.Context, r Repository) context.Context { 29 | return context.WithValue(ctx, ContextKeyRepository, r) 30 | } 31 | 32 | // WithUserContext returns a new context with the user. 33 | func WithUserContext(ctx context.Context, u User) context.Context { 34 | return context.WithValue(ctx, ContextKeyUser, u) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/proto/errors.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // ErrUnauthorized is returned when the user is not authorized to perform action. 9 | ErrUnauthorized = errors.New("unauthorized") 10 | // ErrFileNotFound is returned when the file is not found. 11 | ErrFileNotFound = errors.New("file not found") 12 | // ErrRepoNotFound is returned when a repository is not found. 13 | ErrRepoNotFound = errors.New("repository not found") 14 | // ErrRepoExist is returned when a repository already exists. 15 | ErrRepoExist = errors.New("repository already exists") 16 | // ErrUserNotFound is returned when a user is not found. 17 | ErrUserNotFound = errors.New("user not found") 18 | // ErrTokenNotFound is returned when a token is not found. 19 | ErrTokenNotFound = errors.New("token not found") 20 | // ErrTokenExpired is returned when a token is expired. 21 | ErrTokenExpired = errors.New("token expired") 22 | // ErrCollaboratorNotFound is returned when a collaborator is not found. 23 | ErrCollaboratorNotFound = errors.New("collaborator not found") 24 | // ErrCollaboratorExist is returned when a collaborator already exists. 25 | ErrCollaboratorExist = errors.New("collaborator already exists") 26 | ) 27 | -------------------------------------------------------------------------------- /pkg/proto/repo.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/soft-serve/git" 7 | ) 8 | 9 | // Repository is a Git repository interface. 10 | type Repository interface { 11 | // ID returns the repository's ID. 12 | ID() int64 13 | // Name returns the repository's name. 14 | Name() string 15 | // ProjectName returns the repository's project name. 16 | ProjectName() string 17 | // Description returns the repository's description. 18 | Description() string 19 | // IsPrivate returns whether the repository is private. 20 | IsPrivate() bool 21 | // IsMirror returns whether the repository is a mirror. 22 | IsMirror() bool 23 | // IsHidden returns whether the repository is hidden. 24 | IsHidden() bool 25 | // UserID returns the ID of the user who owns the repository. 26 | // It returns 0 if the repository is not owned by a user. 27 | UserID() int64 28 | // CreatedAt returns the time the repository was created. 29 | CreatedAt() time.Time 30 | // UpdatedAt returns the time the repository was last updated. 31 | // If the repository has never been updated, it returns the time it was created. 32 | UpdatedAt() time.Time 33 | // Open returns the underlying git.Repository. 34 | Open() (*git.Repository, error) 35 | } 36 | 37 | // RepositoryOptions are options for creating a new repository. 38 | type RepositoryOptions struct { 39 | Private bool 40 | Description string 41 | ProjectName string 42 | Mirror bool 43 | Hidden bool 44 | LFS bool 45 | LFSEndpoint string 46 | } 47 | 48 | // RepositoryDefaultBranch returns the default branch of a repository. 49 | func RepositoryDefaultBranch(repo Repository) (string, error) { 50 | r, err := repo.Open() 51 | if err != nil { 52 | return "", err 53 | } 54 | 55 | ref, err := r.HEAD() 56 | if err != nil { 57 | return "", err 58 | } 59 | 60 | return ref.Name().Short(), nil 61 | } 62 | -------------------------------------------------------------------------------- /pkg/proto/user.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import "golang.org/x/crypto/ssh" 4 | 5 | // User is an interface representing a user. 6 | type User interface { 7 | // ID returns the user's ID. 8 | ID() int64 9 | // Username returns the user's username. 10 | Username() string 11 | // IsAdmin returns whether the user is an admin. 12 | IsAdmin() bool 13 | // PublicKeys returns the user's public keys. 14 | PublicKeys() []ssh.PublicKey 15 | // Password returns the user's password hash. 16 | Password() string 17 | } 18 | 19 | // UserOptions are options for creating a user. 20 | type UserOptions struct { 21 | // Admin is whether the user is an admin. 22 | Admin bool 23 | // PublicKeys are the user's public keys. 24 | PublicKeys []ssh.PublicKey 25 | } 26 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/collab.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/charmbracelet/soft-serve/pkg/access" 5 | "github.com/charmbracelet/soft-serve/pkg/backend" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func collabCommand() *cobra.Command { 10 | cmd := &cobra.Command{ 11 | Use: "collab", 12 | Aliases: []string{"collabs", "collaborator", "collaborators"}, 13 | Short: "Manage collaborators", 14 | } 15 | 16 | cmd.AddCommand( 17 | collabAddCommand(), 18 | collabRemoveCommand(), 19 | collabListCommand(), 20 | ) 21 | 22 | return cmd 23 | } 24 | 25 | func collabAddCommand() *cobra.Command { 26 | cmd := &cobra.Command{ 27 | Use: "add REPOSITORY USERNAME [LEVEL]", 28 | Short: "Add a collaborator to a repo", 29 | 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.", 30 | Args: cobra.RangeArgs(2, 3), 31 | PersistentPreRunE: checkIfReadableAndCollab, 32 | RunE: func(cmd *cobra.Command, args []string) error { 33 | ctx := cmd.Context() 34 | be := backend.FromContext(ctx) 35 | repo := args[0] 36 | username := args[1] 37 | level := access.ReadWriteAccess 38 | if len(args) > 2 { 39 | level = access.ParseAccessLevel(args[2]) 40 | if level < 0 { 41 | return access.ErrInvalidAccessLevel 42 | } 43 | } 44 | 45 | return be.AddCollaborator(ctx, repo, username, level) 46 | }, 47 | } 48 | 49 | return cmd 50 | } 51 | 52 | func collabRemoveCommand() *cobra.Command { 53 | cmd := &cobra.Command{ 54 | Use: "remove REPOSITORY USERNAME", 55 | Args: cobra.ExactArgs(2), 56 | Short: "Remove a collaborator from a repo", 57 | PersistentPreRunE: checkIfReadableAndCollab, 58 | RunE: func(cmd *cobra.Command, args []string) error { 59 | ctx := cmd.Context() 60 | be := backend.FromContext(ctx) 61 | repo := args[0] 62 | username := args[1] 63 | 64 | return be.RemoveCollaborator(ctx, repo, username) 65 | }, 66 | } 67 | 68 | return cmd 69 | } 70 | 71 | func collabListCommand() *cobra.Command { 72 | cmd := &cobra.Command{ 73 | Use: "list REPOSITORY", 74 | Short: "List collaborators for a repo", 75 | Args: cobra.ExactArgs(1), 76 | PersistentPreRunE: checkIfReadableAndCollab, 77 | RunE: func(cmd *cobra.Command, args []string) error { 78 | ctx := cmd.Context() 79 | be := backend.FromContext(ctx) 80 | repo := args[0] 81 | collabs, err := be.Collaborators(ctx, repo) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | for _, c := range collabs { 87 | cmd.Println(c) 88 | } 89 | 90 | return nil 91 | }, 92 | } 93 | 94 | return cmd 95 | } 96 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/create.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/backend" 7 | "github.com/charmbracelet/soft-serve/pkg/config" 8 | "github.com/charmbracelet/soft-serve/pkg/proto" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // createCommand is the command for creating a new repository. 13 | func createCommand() *cobra.Command { 14 | var private bool 15 | var description string 16 | var projectName string 17 | var hidden bool 18 | 19 | cmd := &cobra.Command{ 20 | Use: "create REPOSITORY", 21 | Short: "Create a new repository", 22 | Args: cobra.ExactArgs(1), 23 | PersistentPreRunE: checkIfCollab, 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | ctx := cmd.Context() 26 | cfg := config.FromContext(ctx) 27 | be := backend.FromContext(ctx) 28 | user := proto.UserFromContext(ctx) 29 | name := args[0] 30 | r, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{ 31 | Private: private, 32 | Description: description, 33 | ProjectName: projectName, 34 | Hidden: hidden, 35 | }) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | cloneurl := fmt.Sprintf("%s/%s.git", cfg.SSH.PublicURL, r.Name()) 41 | cmd.PrintErrf("Created repository %s\n", r.Name()) 42 | cmd.Println(cloneurl) 43 | 44 | return nil 45 | }, 46 | } 47 | 48 | cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") 49 | cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") 50 | cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name") 51 | cmd.Flags().BoolVarP(&hidden, "hidden", "H", false, "hide the repository from the UI") 52 | 53 | return cmd 54 | } 55 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/charmbracelet/soft-serve/pkg/backend" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func deleteCommand() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "delete REPOSITORY", 11 | Aliases: []string{"del", "remove", "rm"}, 12 | Short: "Delete a repository", 13 | Args: cobra.ExactArgs(1), 14 | PersistentPreRunE: checkIfReadableAndCollab, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | ctx := cmd.Context() 17 | be := backend.FromContext(ctx) 18 | name := args[0] 19 | 20 | return be.DeleteRepository(ctx, name) 21 | }, 22 | } 23 | 24 | return cmd 25 | } 26 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/description.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/backend" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func descriptionCommand() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "description REPOSITORY [DESCRIPTION]", 13 | Aliases: []string{"desc"}, 14 | Short: "Set or get the description for a repository", 15 | Args: cobra.MinimumNArgs(1), 16 | PersistentPreRunE: checkIfReadable, 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | ctx := cmd.Context() 19 | be := backend.FromContext(ctx) 20 | rn := strings.TrimSuffix(args[0], ".git") 21 | switch len(args) { 22 | case 1: 23 | desc, err := be.Description(ctx, rn) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | cmd.Println(desc) 29 | default: 30 | if err := checkIfCollab(cmd, args); err != nil { 31 | return err 32 | } 33 | if err := be.SetDescription(ctx, rn, strings.Join(args[1:], " ")); err != nil { 34 | return err 35 | } 36 | } 37 | 38 | return nil 39 | }, 40 | } 41 | 42 | return cmd 43 | } 44 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/hidden.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/charmbracelet/soft-serve/pkg/backend" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func hiddenCommand() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "hidden REPOSITORY [TRUE|FALSE]", 11 | Short: "Hide or unhide a repository", 12 | Aliases: []string{"hide"}, 13 | Args: cobra.MinimumNArgs(1), 14 | PersistentPreRunE: checkIfReadable, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | ctx := cmd.Context() 17 | be := backend.FromContext(ctx) 18 | repo := args[0] 19 | switch len(args) { 20 | case 1: 21 | hidden, err := be.IsHidden(ctx, repo) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | cmd.Println(hidden) 27 | case 2: 28 | if err := checkIfCollab(cmd, args); err != nil { 29 | return err 30 | } 31 | 32 | hidden := args[1] == "true" 33 | if err := be.SetHidden(ctx, repo, hidden); err != nil { 34 | return err 35 | } 36 | } 37 | 38 | return nil 39 | }, 40 | } 41 | 42 | return cmd 43 | } 44 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/import.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/backend" 7 | "github.com/charmbracelet/soft-serve/pkg/proto" 8 | "github.com/charmbracelet/soft-serve/pkg/task" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // importCommand is the command for creating a new repository. 13 | func importCommand() *cobra.Command { 14 | var private bool 15 | var description string 16 | var projectName string 17 | var mirror bool 18 | var hidden bool 19 | var lfs bool 20 | var lfsEndpoint string 21 | 22 | cmd := &cobra.Command{ 23 | Use: "import REPOSITORY REMOTE", 24 | Short: "Import a new repository from remote", 25 | Args: cobra.ExactArgs(2), 26 | PersistentPreRunE: checkIfCollab, 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | ctx := cmd.Context() 29 | be := backend.FromContext(ctx) 30 | user := proto.UserFromContext(ctx) 31 | name := args[0] 32 | remote := args[1] 33 | if _, err := be.ImportRepository(ctx, name, user, remote, proto.RepositoryOptions{ 34 | Private: private, 35 | Description: description, 36 | ProjectName: projectName, 37 | Mirror: mirror, 38 | Hidden: hidden, 39 | LFS: lfs, 40 | LFSEndpoint: lfsEndpoint, 41 | }); err != nil { 42 | if errors.Is(err, task.ErrAlreadyStarted) { 43 | return errors.New("import already in progress") 44 | } 45 | 46 | return err 47 | } 48 | 49 | return nil 50 | }, 51 | } 52 | 53 | cmd.Flags().BoolVarP(&lfs, "lfs", "", false, "pull Git LFS objects") 54 | cmd.Flags().StringVarP(&lfsEndpoint, "lfs-endpoint", "", "", "set the Git LFS endpoint") 55 | cmd.Flags().BoolVarP(&mirror, "mirror", "m", false, "mirror the repository") 56 | cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") 57 | cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") 58 | cmd.Flags().StringVarP(&projectName, "name", "n", "", "set the project name") 59 | cmd.Flags().BoolVarP(&hidden, "hidden", "H", false, "hide the repository from the UI") 60 | 61 | return cmd 62 | } 63 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/info.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/charmbracelet/soft-serve/pkg/backend" 5 | "github.com/charmbracelet/soft-serve/pkg/sshutils" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // InfoCommand returns a command that shows the user's info 10 | func InfoCommand() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "info", 13 | Short: "Show your info", 14 | Args: cobra.NoArgs, 15 | RunE: func(cmd *cobra.Command, _ []string) error { 16 | ctx := cmd.Context() 17 | be := backend.FromContext(ctx) 18 | pk := sshutils.PublicKeyFromContext(ctx) 19 | user, err := be.UserByPublicKey(ctx, pk) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | cmd.Printf("Username: %s\n", user.Username()) 25 | cmd.Printf("Admin: %t\n", user.IsAdmin()) 26 | cmd.Printf("Public keys:\n") 27 | for _, pk := range user.PublicKeys() { 28 | cmd.Printf(" %s\n", sshutils.MarshalAuthorizedKey(pk)) 29 | } 30 | return nil 31 | }, 32 | } 33 | 34 | return cmd 35 | } 36 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/jwt.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/soft-serve/pkg/config" 8 | "github.com/charmbracelet/soft-serve/pkg/jwk" 9 | "github.com/charmbracelet/soft-serve/pkg/proto" 10 | "github.com/golang-jwt/jwt/v5" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // JWTCommand returns a command that generates a JSON Web Token. 15 | func JWTCommand() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "jwt [repository1 repository2...]", 18 | Short: "Generate a JSON Web Token", 19 | Args: cobra.MinimumNArgs(0), 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | ctx := cmd.Context() 22 | cfg := config.FromContext(ctx) 23 | kp, err := jwk.NewPair(cfg) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | user := proto.UserFromContext(ctx) 29 | if user == nil { 30 | return proto.ErrUserNotFound 31 | } 32 | 33 | now := time.Now() 34 | expiresAt := now.Add(time.Hour) 35 | claims := jwt.RegisteredClaims{ 36 | Subject: fmt.Sprintf("%s#%d", user.Username(), user.ID()), 37 | ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour 38 | NotBefore: jwt.NewNumericDate(now), 39 | IssuedAt: jwt.NewNumericDate(now), 40 | Issuer: cfg.HTTP.PublicURL, 41 | Audience: args, 42 | } 43 | 44 | token := jwt.NewWithClaims(jwk.SigningMethod, claims) 45 | token.Header["kid"] = kp.JWK().KeyID 46 | j, err := token.SignedString(kp.PrivateKey()) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | cmd.Println(j) 52 | return nil 53 | }, 54 | } 55 | 56 | return cmd 57 | } 58 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/charmbracelet/soft-serve/pkg/access" 5 | "github.com/charmbracelet/soft-serve/pkg/backend" 6 | "github.com/charmbracelet/soft-serve/pkg/sshutils" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // listCommand returns a command that list file or directory at path. 11 | func listCommand() *cobra.Command { 12 | var all bool 13 | 14 | listCmd := &cobra.Command{ 15 | Use: "list", 16 | Aliases: []string{"ls"}, 17 | Short: "List repositories", 18 | Args: cobra.NoArgs, 19 | RunE: func(cmd *cobra.Command, _ []string) error { 20 | ctx := cmd.Context() 21 | be := backend.FromContext(ctx) 22 | pk := sshutils.PublicKeyFromContext(ctx) 23 | repos, err := be.Repositories(ctx) 24 | if err != nil { 25 | return err 26 | } 27 | for _, r := range repos { 28 | if be.AccessLevelByPublicKey(ctx, r.Name(), pk) >= access.ReadOnlyAccess { 29 | if !r.IsHidden() || all { 30 | cmd.Println(r.Name()) 31 | } 32 | } 33 | } 34 | return nil 35 | }, 36 | } 37 | 38 | listCmd.Flags().BoolVarP(&all, "all", "a", false, "List all repositories") 39 | 40 | return listCmd 41 | } 42 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/mirror.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/charmbracelet/soft-serve/pkg/backend" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func mirrorCommand() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "is-mirror REPOSITORY", 11 | Short: "Whether a repository is a mirror", 12 | Args: cobra.ExactArgs(1), 13 | PersistentPreRunE: checkIfReadable, 14 | RunE: func(cmd *cobra.Command, args []string) error { 15 | ctx := cmd.Context() 16 | be := backend.FromContext(ctx) 17 | rn := args[0] 18 | rr, err := be.Repository(ctx, rn) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | isMirror := rr.IsMirror() 24 | cmd.Println(isMirror) 25 | return nil 26 | }, 27 | } 28 | 29 | return cmd 30 | } 31 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/private.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/charmbracelet/soft-serve/pkg/backend" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func privateCommand() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "private REPOSITORY [true|false]", 14 | Short: "Set or get a repository private property", 15 | Args: cobra.RangeArgs(1, 2), 16 | PersistentPreRunE: checkIfReadable, 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | ctx := cmd.Context() 19 | be := backend.FromContext(ctx) 20 | rn := strings.TrimSuffix(args[0], ".git") 21 | 22 | switch len(args) { 23 | case 1: 24 | isPrivate, err := be.IsPrivate(ctx, rn) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | cmd.Println(isPrivate) 30 | case 2: 31 | isPrivate, err := strconv.ParseBool(args[1]) 32 | if err != nil { 33 | return err 34 | } 35 | if err := checkIfCollab(cmd, args); err != nil { 36 | return err 37 | } 38 | if err := be.SetPrivate(ctx, rn, isPrivate); err != nil { 39 | return err 40 | } 41 | } 42 | return nil 43 | }, 44 | } 45 | 46 | return cmd 47 | } 48 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/project_name.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/backend" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func projectName() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "project-name REPOSITORY [NAME]", 13 | Aliases: []string{"project"}, 14 | Short: "Set or get the project name for a repository", 15 | Args: cobra.MinimumNArgs(1), 16 | PersistentPreRunE: checkIfReadable, 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | ctx := cmd.Context() 19 | be := backend.FromContext(ctx) 20 | rn := strings.TrimSuffix(args[0], ".git") 21 | switch len(args) { 22 | case 1: 23 | pn, err := be.ProjectName(ctx, rn) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | cmd.Println(pn) 29 | default: 30 | if err := checkIfCollab(cmd, args); err != nil { 31 | return err 32 | } 33 | if err := be.SetProjectName(ctx, rn, strings.Join(args[1:], " ")); err != nil { 34 | return err 35 | } 36 | } 37 | 38 | return nil 39 | }, 40 | } 41 | 42 | return cmd 43 | } 44 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/pubkey.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/backend" 7 | "github.com/charmbracelet/soft-serve/pkg/sshutils" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // PubkeyCommand returns a command that manages user public keys. 12 | func PubkeyCommand() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "pubkey", 15 | Aliases: []string{"pubkeys", "publickey", "publickeys"}, 16 | Short: "Manage your public keys", 17 | } 18 | 19 | pubkeyAddCommand := &cobra.Command{ 20 | Use: "add AUTHORIZED_KEY", 21 | Short: "Add a public key", 22 | Args: cobra.MinimumNArgs(1), 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | ctx := cmd.Context() 25 | be := backend.FromContext(ctx) 26 | pk := sshutils.PublicKeyFromContext(ctx) 27 | user, err := be.UserByPublicKey(ctx, pk) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | apk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, " ")) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | return be.AddPublicKey(ctx, user.Username(), apk) 38 | }, 39 | } 40 | 41 | pubkeyRemoveCommand := &cobra.Command{ 42 | Use: "remove AUTHORIZED_KEY", 43 | Args: cobra.MinimumNArgs(1), 44 | Short: "Remove a public key", 45 | RunE: func(cmd *cobra.Command, args []string) error { 46 | ctx := cmd.Context() 47 | be := backend.FromContext(ctx) 48 | pk := sshutils.PublicKeyFromContext(ctx) 49 | user, err := be.UserByPublicKey(ctx, pk) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | apk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, " ")) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | return be.RemovePublicKey(ctx, user.Username(), apk) 60 | }, 61 | } 62 | 63 | pubkeyListCommand := &cobra.Command{ 64 | Use: "list", 65 | Aliases: []string{"ls"}, 66 | Short: "List public keys", 67 | Args: cobra.NoArgs, 68 | RunE: func(cmd *cobra.Command, _ []string) error { 69 | ctx := cmd.Context() 70 | be := backend.FromContext(ctx) 71 | pk := sshutils.PublicKeyFromContext(ctx) 72 | user, err := be.UserByPublicKey(ctx, pk) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | pks := user.PublicKeys() 78 | for _, pk := range pks { 79 | cmd.Println(sshutils.MarshalAuthorizedKey(pk)) 80 | } 81 | 82 | return nil 83 | }, 84 | } 85 | 86 | cmd.AddCommand( 87 | pubkeyAddCommand, 88 | pubkeyRemoveCommand, 89 | pubkeyListCommand, 90 | ) 91 | 92 | return cmd 93 | } 94 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/rename.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/charmbracelet/soft-serve/pkg/backend" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func renameCommand() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "rename REPOSITORY NEW_NAME", 11 | Aliases: []string{"mv", "move"}, 12 | Short: "Rename an existing repository", 13 | Args: cobra.ExactArgs(2), 14 | PersistentPreRunE: checkIfReadableAndCollab, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | ctx := cmd.Context() 17 | be := backend.FromContext(ctx) 18 | oldName := args[0] 19 | newName := args[1] 20 | 21 | return be.RenameRepository(ctx, oldName, newName) 22 | }, 23 | } 24 | 25 | return cmd 26 | } 27 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/set_username.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/charmbracelet/soft-serve/pkg/backend" 5 | "github.com/charmbracelet/soft-serve/pkg/sshutils" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // SetUsernameCommand returns a command that sets the user's username. 10 | func SetUsernameCommand() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "set-username USERNAME", 13 | Short: "Set your username", 14 | Args: cobra.ExactArgs(1), 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | ctx := cmd.Context() 17 | be := backend.FromContext(ctx) 18 | pk := sshutils.PublicKeyFromContext(ctx) 19 | user, err := be.UserByPublicKey(ctx, pk) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | return be.SetUsername(ctx, user.Username(), args[0]) 25 | }, 26 | } 27 | 28 | return cmd 29 | } 30 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/settings.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/charmbracelet/soft-serve/pkg/access" 8 | "github.com/charmbracelet/soft-serve/pkg/backend" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // SettingsCommand returns a command that manages server settings. 13 | func SettingsCommand() *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "settings", 16 | Short: "Manage server settings", 17 | } 18 | 19 | cmd.AddCommand( 20 | &cobra.Command{ 21 | Use: "allow-keyless [true|false]", 22 | Short: "Set or get allow keyless access to repositories", 23 | Args: cobra.RangeArgs(0, 1), 24 | PersistentPreRunE: checkIfAdmin, 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | ctx := cmd.Context() 27 | be := backend.FromContext(ctx) 28 | switch len(args) { 29 | case 0: 30 | cmd.Println(be.AllowKeyless(ctx)) 31 | case 1: 32 | v, _ := strconv.ParseBool(args[0]) 33 | if err := be.SetAllowKeyless(ctx, v); err != nil { 34 | return err 35 | } 36 | } 37 | 38 | return nil 39 | }, 40 | }, 41 | ) 42 | 43 | als := []string{access.NoAccess.String(), access.ReadOnlyAccess.String(), access.ReadWriteAccess.String(), access.AdminAccess.String()} 44 | cmd.AddCommand( 45 | &cobra.Command{ 46 | Use: "anon-access [ACCESS_LEVEL]", 47 | Short: "Set or get the default access level for anonymous users", 48 | Args: cobra.RangeArgs(0, 1), 49 | ValidArgs: als, 50 | PersistentPreRunE: checkIfAdmin, 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | ctx := cmd.Context() 53 | be := backend.FromContext(ctx) 54 | switch len(args) { 55 | case 0: 56 | cmd.Println(be.AnonAccess(ctx)) 57 | case 1: 58 | al := access.ParseAccessLevel(args[0]) 59 | if al < 0 { 60 | return fmt.Errorf("invalid access level: %s. Please choose one of the following: %s", args[0], als) 61 | } 62 | if err := be.SetAnonAccess(ctx, al); err != nil { 63 | return err 64 | } 65 | } 66 | 67 | return nil 68 | }, 69 | }, 70 | ) 71 | 72 | return cmd 73 | } 74 | -------------------------------------------------------------------------------- /pkg/ssh/cmd/tree.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/soft-serve/git" 7 | "github.com/charmbracelet/soft-serve/pkg/backend" 8 | "github.com/charmbracelet/soft-serve/pkg/proto" 9 | "github.com/charmbracelet/soft-serve/pkg/ui/common" 10 | "github.com/dustin/go-humanize" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // treeCommand returns a command that list file or directory at path. 15 | func treeCommand() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "tree REPOSITORY [REFERENCE] [PATH]", 18 | Short: "Print repository tree at path", 19 | Args: cobra.RangeArgs(1, 3), 20 | PersistentPreRunE: checkIfReadable, 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | ctx := cmd.Context() 23 | be := backend.FromContext(ctx) 24 | rn := args[0] 25 | path := "" 26 | ref := "" 27 | switch len(args) { 28 | case 2: 29 | path = args[1] 30 | case 3: 31 | ref = args[1] 32 | path = args[2] 33 | } 34 | rr, err := be.Repository(ctx, rn) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | r, err := rr.Open() 40 | if err != nil { 41 | return err 42 | } 43 | 44 | if ref == "" { 45 | head, err := r.HEAD() 46 | if err != nil { 47 | if bs, err := r.Branches(); err != nil && len(bs) == 0 { 48 | return fmt.Errorf("repository is empty") 49 | } 50 | return err 51 | } 52 | 53 | ref = head.ID 54 | } 55 | 56 | tree, err := r.LsTree(ref) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | ents := git.Entries{} 62 | if path != "" && path != "/" { 63 | te, err := tree.TreeEntry(path) 64 | if err == git.ErrRevisionNotExist { 65 | return proto.ErrFileNotFound 66 | } 67 | if err != nil { 68 | return err 69 | } 70 | if te.Type() == "tree" { 71 | tree, err = tree.SubTree(path) 72 | if err != nil { 73 | return err 74 | } 75 | ents, err = tree.Entries() 76 | if err != nil { 77 | return err 78 | } 79 | } else { 80 | ents = append(ents, te) 81 | } 82 | } else { 83 | ents, err = tree.Entries() 84 | if err != nil { 85 | return err 86 | } 87 | } 88 | ents.Sort() 89 | for _, ent := range ents { 90 | size := ent.Size() 91 | ssize := "" 92 | if size == 0 { 93 | ssize = "-" 94 | } else { 95 | ssize = humanize.Bytes(uint64(size)) //nolint:gosec 96 | } 97 | cmd.Printf("%s\t%s\t %s\n", ent.Mode(), ssize, common.UnquoteFilename(ent.Name())) 98 | } 99 | return nil 100 | }, 101 | } 102 | return cmd 103 | } 104 | -------------------------------------------------------------------------------- /pkg/ssh/session.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/soft-serve/pkg/access" 9 | "github.com/charmbracelet/soft-serve/pkg/backend" 10 | "github.com/charmbracelet/soft-serve/pkg/config" 11 | "github.com/charmbracelet/soft-serve/pkg/proto" 12 | "github.com/charmbracelet/soft-serve/pkg/ui/common" 13 | "github.com/charmbracelet/ssh" 14 | "github.com/charmbracelet/wish" 15 | bm "github.com/charmbracelet/wish/bubbletea" 16 | "github.com/muesli/termenv" 17 | "github.com/prometheus/client_golang/prometheus" 18 | "github.com/prometheus/client_golang/prometheus/promauto" 19 | ) 20 | 21 | var tuiSessionCounter = promauto.NewCounterVec(prometheus.CounterOpts{ 22 | Namespace: "soft_serve", 23 | Subsystem: "ssh", 24 | Name: "tui_session_total", 25 | Help: "The total number of TUI sessions", 26 | }, []string{"repo", "term"}) 27 | 28 | var tuiSessionDuration = promauto.NewCounterVec(prometheus.CounterOpts{ 29 | Namespace: "soft_serve", 30 | Subsystem: "ssh", 31 | Name: "tui_session_seconds_total", 32 | Help: "The total time spent in TUI sessions", 33 | }, []string{"repo", "term"}) 34 | 35 | // SessionHandler is the soft-serve bubbletea ssh session handler. 36 | // This middleware must be run after the ContextMiddleware. 37 | func SessionHandler(s ssh.Session) *tea.Program { 38 | pty, _, active := s.Pty() 39 | if !active { 40 | return nil 41 | } 42 | 43 | ctx := s.Context() 44 | be := backend.FromContext(ctx) 45 | cfg := config.FromContext(ctx) 46 | cmd := s.Command() 47 | initialRepo := "" 48 | if len(cmd) == 1 { 49 | initialRepo = cmd[0] 50 | auth := be.AccessLevelByPublicKey(ctx, initialRepo, s.PublicKey()) 51 | if auth < access.ReadOnlyAccess { 52 | wish.Fatalln(s, proto.ErrUnauthorized) 53 | return nil 54 | } 55 | } 56 | 57 | renderer := bm.MakeRenderer(s) 58 | if testrun, ok := os.LookupEnv("SOFT_SERVE_NO_COLOR"); ok && testrun == "1" { 59 | // Disable colors when running tests. 60 | renderer.SetColorProfile(termenv.Ascii) 61 | } 62 | 63 | c := common.NewCommon(ctx, renderer, pty.Window.Width, pty.Window.Height) 64 | c.SetValue(common.ConfigKey, cfg) 65 | m := NewUI(c, initialRepo) 66 | opts := bm.MakeOptions(s) 67 | opts = append(opts, 68 | tea.WithAltScreen(), 69 | tea.WithoutCatchPanics(), 70 | tea.WithMouseCellMotion(), 71 | tea.WithContext(ctx), 72 | ) 73 | p := tea.NewProgram(m, opts...) 74 | 75 | tuiSessionCounter.WithLabelValues(initialRepo, pty.Term).Inc() 76 | 77 | start := time.Now() 78 | go func() { 79 | <-ctx.Done() 80 | tuiSessionDuration.WithLabelValues(initialRepo, pty.Term).Add(time.Since(start).Seconds()) 81 | }() 82 | 83 | return p 84 | } 85 | -------------------------------------------------------------------------------- /pkg/sshutils/utils.go: -------------------------------------------------------------------------------- 1 | package sshutils 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | 7 | "github.com/charmbracelet/ssh" 8 | gossh "golang.org/x/crypto/ssh" 9 | ) 10 | 11 | // ParseAuthorizedKey parses an authorized key string into a public key. 12 | func ParseAuthorizedKey(ak string) (gossh.PublicKey, string, error) { 13 | pk, c, _, _, err := gossh.ParseAuthorizedKey([]byte(ak)) 14 | return pk, c, err 15 | } 16 | 17 | // MarshalAuthorizedKey marshals a public key into an authorized key string. 18 | // 19 | // This is the inverse of ParseAuthorizedKey. 20 | // This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline. 21 | // It returns an empty string if pk is nil. 22 | func MarshalAuthorizedKey(pk gossh.PublicKey) string { 23 | if pk == nil { 24 | return "" 25 | } 26 | return string(bytes.TrimSuffix(gossh.MarshalAuthorizedKey(pk), []byte("\n"))) 27 | } 28 | 29 | // KeysEqual returns whether the two public keys are equal. 30 | func KeysEqual(a, b gossh.PublicKey) bool { 31 | return ssh.KeysEqual(a, b) 32 | } 33 | 34 | // PublicKeyFromContext returns the public key from the context. 35 | func PublicKeyFromContext(ctx context.Context) gossh.PublicKey { 36 | if pk, ok := ctx.Value(ssh.ContextKeyPublicKey).(gossh.PublicKey); ok { 37 | return pk 38 | } 39 | return nil 40 | } 41 | 42 | // ContextKeySession is the context key for the SSH session. 43 | var ContextKeySession = &struct{ string }{"session"} 44 | 45 | // SessionFromContext returns the SSH session from the context. 46 | func SessionFromContext(ctx context.Context) ssh.Session { 47 | if s, ok := ctx.Value(ContextKeySession).(ssh.Session); ok { 48 | return s 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/stats/stats.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/charmbracelet/soft-serve/pkg/config" 9 | "github.com/prometheus/client_golang/prometheus/promhttp" 10 | ) 11 | 12 | // StatsServer is a server for collecting and reporting statistics. 13 | type StatsServer struct { //nolint:revive 14 | ctx context.Context 15 | cfg *config.Config 16 | server *http.Server 17 | } 18 | 19 | // NewStatsServer returns a new StatsServer. 20 | func NewStatsServer(ctx context.Context) (*StatsServer, error) { 21 | cfg := config.FromContext(ctx) 22 | mux := http.NewServeMux() 23 | mux.Handle("/metrics", promhttp.Handler()) 24 | return &StatsServer{ 25 | ctx: ctx, 26 | cfg: cfg, 27 | server: &http.Server{ 28 | Addr: cfg.Stats.ListenAddr, 29 | Handler: mux, 30 | ReadHeaderTimeout: time.Second * 10, 31 | ReadTimeout: time.Second * 10, 32 | WriteTimeout: time.Second * 10, 33 | MaxHeaderBytes: http.DefaultMaxHeaderBytes, 34 | }, 35 | }, nil 36 | } 37 | 38 | // ListenAndServe starts the StatsServer. 39 | func (s *StatsServer) ListenAndServe() error { 40 | return s.server.ListenAndServe() 41 | } 42 | 43 | // Shutdown gracefully shuts down the StatsServer. 44 | func (s *StatsServer) Shutdown(ctx context.Context) error { 45 | return s.server.Shutdown(ctx) 46 | } 47 | 48 | // Close closes the StatsServer. 49 | func (s *StatsServer) Close() error { 50 | return s.server.Close() 51 | } 52 | -------------------------------------------------------------------------------- /pkg/storage/local.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // LocalStorage is a storage implementation that stores objects on the local 13 | // filesystem. 14 | type LocalStorage struct { 15 | root string 16 | } 17 | 18 | var _ Storage = (*LocalStorage)(nil) 19 | 20 | // NewLocalStorage creates a new LocalStorage. 21 | func NewLocalStorage(root string) *LocalStorage { 22 | return &LocalStorage{root: root} 23 | } 24 | 25 | // Delete implements Storage. 26 | func (l *LocalStorage) Delete(name string) error { 27 | name = l.fixPath(name) 28 | return os.Remove(name) 29 | } 30 | 31 | // Open implements Storage. 32 | func (l *LocalStorage) Open(name string) (Object, error) { 33 | name = l.fixPath(name) 34 | return os.Open(name) 35 | } 36 | 37 | // Stat implements Storage. 38 | func (l *LocalStorage) Stat(name string) (fs.FileInfo, error) { 39 | name = l.fixPath(name) 40 | return os.Stat(name) 41 | } 42 | 43 | // Put implements Storage. 44 | func (l *LocalStorage) Put(name string, r io.Reader) (int64, error) { 45 | name = l.fixPath(name) 46 | if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil { 47 | return 0, err 48 | } 49 | 50 | f, err := os.Create(name) 51 | if err != nil { 52 | return 0, err 53 | } 54 | defer f.Close() // nolint: errcheck 55 | return io.Copy(f, r) 56 | } 57 | 58 | // Exists implements Storage. 59 | func (l *LocalStorage) Exists(name string) (bool, error) { 60 | name = l.fixPath(name) 61 | _, err := os.Stat(name) 62 | if err == nil { 63 | return true, nil 64 | } 65 | if errors.Is(err, fs.ErrNotExist) { 66 | return false, nil 67 | } 68 | return false, err 69 | } 70 | 71 | // Rename implements Storage. 72 | func (l *LocalStorage) Rename(oldName, newName string) error { 73 | oldName = l.fixPath(oldName) 74 | newName = l.fixPath(newName) 75 | if err := os.MkdirAll(filepath.Dir(newName), os.ModePerm); err != nil { 76 | return err 77 | } 78 | 79 | return os.Rename(oldName, newName) 80 | } 81 | 82 | // Replace all slashes with the OS-specific separator 83 | func (l LocalStorage) fixPath(path string) string { 84 | path = strings.ReplaceAll(path, "/", string(os.PathSeparator)) 85 | if !filepath.IsAbs(path) { 86 | return filepath.Join(l.root, path) 87 | } 88 | 89 | return path 90 | } 91 | -------------------------------------------------------------------------------- /pkg/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "io" 5 | "io/fs" 6 | ) 7 | 8 | // Object is an interface for objects that can be stored. 9 | type Object interface { 10 | io.Seeker 11 | fs.File 12 | Name() string 13 | } 14 | 15 | // Storage is an interface for storing and retrieving objects. 16 | type Storage interface { 17 | Open(name string) (Object, error) 18 | Stat(name string) (fs.FileInfo, error) 19 | Put(name string, r io.Reader) (int64, error) 20 | Delete(name string) error 21 | Exists(name string) (bool, error) 22 | Rename(oldName, newName string) error 23 | } 24 | -------------------------------------------------------------------------------- /pkg/store/access_token.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/charmbracelet/soft-serve/pkg/db" 8 | "github.com/charmbracelet/soft-serve/pkg/db/models" 9 | ) 10 | 11 | // AccessTokenStore is an interface for managing access tokens. 12 | type AccessTokenStore interface { 13 | GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error) 14 | GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error) 15 | GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error) 16 | CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error) 17 | DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error 18 | DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error 19 | } 20 | -------------------------------------------------------------------------------- /pkg/store/collab.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/access" 7 | "github.com/charmbracelet/soft-serve/pkg/db" 8 | "github.com/charmbracelet/soft-serve/pkg/db/models" 9 | ) 10 | 11 | // CollaboratorStore is an interface for managing collaborators. 12 | type CollaboratorStore interface { 13 | GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error) 14 | AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string, level access.AccessLevel) error 15 | RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error 16 | ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error) 17 | ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/store/context.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "context" 4 | 5 | // ContextKey is the store context key. 6 | var ContextKey = &struct{ string }{"store"} 7 | 8 | // FromContext returns the store from the given context. 9 | func FromContext(ctx context.Context) Store { 10 | if s, ok := ctx.Value(ContextKey).(Store); ok { 11 | return s 12 | } 13 | 14 | return nil 15 | } 16 | 17 | // WithContext returns a new context with the given store. 18 | func WithContext(ctx context.Context, s Store) context.Context { 19 | return context.WithValue(ctx, ContextKey, s) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/store/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/log" 7 | "github.com/charmbracelet/soft-serve/pkg/config" 8 | "github.com/charmbracelet/soft-serve/pkg/db" 9 | "github.com/charmbracelet/soft-serve/pkg/store" 10 | ) 11 | 12 | type datastore struct { 13 | ctx context.Context 14 | cfg *config.Config 15 | db *db.DB 16 | logger *log.Logger 17 | 18 | *settingsStore 19 | *repoStore 20 | *userStore 21 | *collabStore 22 | *lfsStore 23 | *accessTokenStore 24 | *webhookStore 25 | } 26 | 27 | // New returns a new store.Store database. 28 | func New(ctx context.Context, db *db.DB) store.Store { 29 | cfg := config.FromContext(ctx) 30 | logger := log.FromContext(ctx).WithPrefix("store") 31 | 32 | s := &datastore{ 33 | ctx: ctx, 34 | cfg: cfg, 35 | db: db, 36 | logger: logger, 37 | 38 | settingsStore: &settingsStore{}, 39 | repoStore: &repoStore{}, 40 | userStore: &userStore{}, 41 | collabStore: &collabStore{}, 42 | lfsStore: &lfsStore{}, 43 | accessTokenStore: &accessTokenStore{}, 44 | } 45 | 46 | return s 47 | } 48 | -------------------------------------------------------------------------------- /pkg/store/database/settings.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/access" 7 | "github.com/charmbracelet/soft-serve/pkg/db" 8 | "github.com/charmbracelet/soft-serve/pkg/store" 9 | ) 10 | 11 | type settingsStore struct{} 12 | 13 | var _ store.SettingStore = (*settingsStore)(nil) 14 | 15 | // GetAllowKeylessAccess implements store.SettingStore. 16 | func (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx db.Handler) (bool, error) { 17 | var allow bool 18 | query := tx.Rebind(`SELECT value FROM settings WHERE "key" = 'allow_keyless'`) 19 | if err := tx.GetContext(ctx, &allow, query); err != nil { 20 | return false, db.WrapError(err) 21 | } 22 | return allow, nil 23 | } 24 | 25 | // GetAnonAccess implements store.SettingStore. 26 | func (*settingsStore) GetAnonAccess(ctx context.Context, tx db.Handler) (access.AccessLevel, error) { 27 | var level string 28 | query := tx.Rebind(`SELECT value FROM settings WHERE "key" = 'anon_access'`) 29 | if err := tx.GetContext(ctx, &level, query); err != nil { 30 | return access.NoAccess, db.WrapError(err) 31 | } 32 | return access.ParseAccessLevel(level), nil 33 | } 34 | 35 | // SetAllowKeylessAccess implements store.SettingStore. 36 | func (*settingsStore) SetAllowKeylessAccess(ctx context.Context, tx db.Handler, allow bool) error { 37 | query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE "key" = 'allow_keyless'`) 38 | _, err := tx.ExecContext(ctx, query, allow) 39 | return db.WrapError(err) 40 | } 41 | 42 | // SetAnonAccess implements store.SettingStore. 43 | func (*settingsStore) SetAnonAccess(ctx context.Context, tx db.Handler, level access.AccessLevel) error { 44 | query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE "key" = 'anon_access'`) 45 | _, err := tx.ExecContext(ctx, query, level.String()) 46 | return db.WrapError(err) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/store/lfs.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/db" 7 | "github.com/charmbracelet/soft-serve/pkg/db/models" 8 | ) 9 | 10 | // LFSStore is the interface for the LFS store. 11 | type LFSStore interface { 12 | CreateLFSObject(ctx context.Context, h db.Handler, repoID int64, oid string, size int64) error 13 | GetLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) (models.LFSObject, error) 14 | GetLFSObjects(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSObject, error) 15 | GetLFSObjectsByName(ctx context.Context, h db.Handler, name string) ([]models.LFSObject, error) 16 | DeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error 17 | 18 | CreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error 19 | GetLFSLocks(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) 20 | GetLFSLocksWithCount(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) 21 | GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) 22 | GetLFSLockForPath(ctx context.Context, h db.Handler, repoID int64, path string) (models.LFSLock, error) 23 | GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error) 24 | GetLFSLockByID(ctx context.Context, h db.Handler, id int64) (models.LFSLock, error) 25 | GetLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error) 26 | DeleteLFSLock(ctx context.Context, h db.Handler, repoID int64, id int64) error 27 | DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) error 28 | } 29 | -------------------------------------------------------------------------------- /pkg/store/repo.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/db" 7 | "github.com/charmbracelet/soft-serve/pkg/db/models" 8 | ) 9 | 10 | // RepositoryStore is an interface for managing repositories. 11 | type RepositoryStore interface { 12 | GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error) 13 | GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error) 14 | GetUserRepos(ctx context.Context, h db.Handler, userID int64) ([]models.Repo, error) 15 | CreateRepo(ctx context.Context, h db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error 16 | DeleteRepoByName(ctx context.Context, h db.Handler, name string) error 17 | SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error 18 | 19 | GetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error) 20 | SetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error 21 | GetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error) 22 | SetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error 23 | GetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error) 24 | SetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error 25 | GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error) 26 | SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error 27 | GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/store/settings.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/access" 7 | "github.com/charmbracelet/soft-serve/pkg/db" 8 | ) 9 | 10 | // SettingStore is an interface for managing settings. 11 | type SettingStore interface { 12 | GetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error) 13 | SetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error 14 | GetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error) 15 | SetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error 16 | } 17 | -------------------------------------------------------------------------------- /pkg/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | // Store is an interface for managing repositories, users, and settings. 4 | type Store interface { 5 | RepositoryStore 6 | UserStore 7 | CollaboratorStore 8 | SettingStore 9 | LFSStore 10 | AccessTokenStore 11 | WebhookStore 12 | } 13 | -------------------------------------------------------------------------------- /pkg/store/user.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/db" 7 | "github.com/charmbracelet/soft-serve/pkg/db/models" 8 | "golang.org/x/crypto/ssh" 9 | ) 10 | 11 | // UserStore is an interface for managing users. 12 | type UserStore interface { 13 | GetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error) 14 | FindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error) 15 | FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error) 16 | FindUserByAccessToken(ctx context.Context, h db.Handler, token string) (models.User, error) 17 | GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error) 18 | CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error 19 | DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error 20 | SetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error 21 | SetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error 22 | AddPublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error 23 | RemovePublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error 24 | ListPublicKeysByUserID(ctx context.Context, h db.Handler, id int64) ([]ssh.PublicKey, error) 25 | ListPublicKeysByUsername(ctx context.Context, h db.Handler, username string) ([]ssh.PublicKey, error) 26 | SetUserPassword(ctx context.Context, h db.Handler, userID int64, password string) error 27 | SetUserPasswordByUsername(ctx context.Context, h db.Handler, username string, password string) error 28 | } 29 | -------------------------------------------------------------------------------- /pkg/sync/workqueue.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "golang.org/x/sync/semaphore" 8 | ) 9 | 10 | // WorkPool is a pool of work to be done. 11 | type WorkPool struct { 12 | workers int 13 | work sync.Map 14 | sem *semaphore.Weighted 15 | ctx context.Context 16 | logger func(string, ...interface{}) 17 | } 18 | 19 | // WorkPoolOption is a function that configures a WorkPool. 20 | type WorkPoolOption func(*WorkPool) 21 | 22 | // WithWorkPoolLogger sets the logger to use. 23 | func WithWorkPoolLogger(logger func(string, ...interface{})) WorkPoolOption { 24 | return func(wq *WorkPool) { 25 | wq.logger = logger 26 | } 27 | } 28 | 29 | // NewWorkPool creates a new work pool. The workers argument specifies the 30 | // number of concurrent workers to run the work. 31 | // The queue will chunk the work into batches of workers size. 32 | func NewWorkPool(ctx context.Context, workers int, opts ...WorkPoolOption) *WorkPool { 33 | wq := &WorkPool{ 34 | workers: workers, 35 | ctx: ctx, 36 | } 37 | 38 | for _, opt := range opts { 39 | opt(wq) 40 | } 41 | 42 | if wq.workers <= 0 { 43 | wq.workers = 1 44 | } 45 | 46 | wq.sem = semaphore.NewWeighted(int64(wq.workers)) 47 | 48 | return wq 49 | } 50 | 51 | // Run starts the workers and waits for them to finish. 52 | func (wq *WorkPool) Run() { 53 | wq.work.Range(func(key, value any) bool { 54 | id := key.(string) 55 | fn := value.(func()) 56 | if err := wq.sem.Acquire(wq.ctx, 1); err != nil { 57 | wq.logf("workpool: %v", err) 58 | return false 59 | } 60 | 61 | go func(id string, fn func()) { 62 | defer wq.sem.Release(1) 63 | fn() 64 | wq.work.Delete(id) 65 | }(id, fn) 66 | 67 | return true 68 | }) 69 | 70 | if err := wq.sem.Acquire(wq.ctx, int64(wq.workers)); err != nil { 71 | wq.logf("workpool: %v", err) 72 | } 73 | } 74 | 75 | // Add adds a new job to the pool. 76 | // If the job already exists, it is a no-op. 77 | func (wq *WorkPool) Add(id string, fn func()) { 78 | if _, ok := wq.work.Load(id); ok { 79 | return 80 | } 81 | wq.work.Store(id, fn) 82 | } 83 | 84 | // Status checks if a job is in the queue. 85 | func (wq *WorkPool) Status(id string) bool { 86 | _, ok := wq.work.Load(id) 87 | return ok 88 | } 89 | 90 | func (wq *WorkPool) logf(format string, args ...interface{}) { 91 | if wq.logger != nil { 92 | wq.logger(format, args...) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pkg/sync/workqueue_test.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "sync" 7 | "testing" 8 | ) 9 | 10 | func TestWorkPool(t *testing.T) { 11 | mtx := &sync.Mutex{} 12 | values := make([]int, 0) 13 | wp := NewWorkPool(context.Background(), 3) 14 | for i := 0; i < 10; i++ { 15 | id := strconv.Itoa(i) 16 | i := i 17 | wp.Add(id, func() { 18 | mtx.Lock() 19 | values = append(values, i) 20 | mtx.Unlock() 21 | }) 22 | } 23 | wp.Run() 24 | 25 | if len(values) != 10 { 26 | t.Errorf("expected 10 values, got %d, %v", len(values), values) 27 | } 28 | 29 | for i := range values { 30 | id := strconv.Itoa(i) 31 | if wp.Status(id) { 32 | t.Errorf("expected %s to be false", id) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pkg/task/manager.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "sync/atomic" 8 | ) 9 | 10 | var ( 11 | // ErrNotFound is returned when a process is not found. 12 | ErrNotFound = errors.New("task not found") 13 | 14 | // ErrAlreadyStarted is returned when a process is already started. 15 | ErrAlreadyStarted = errors.New("task already started") 16 | ) 17 | 18 | // Task is a task that can be started and stopped. 19 | type Task struct { 20 | id string 21 | fn func(context.Context) error 22 | started atomic.Bool 23 | ctx context.Context 24 | cancel context.CancelFunc 25 | err error 26 | } 27 | 28 | // Manager manages tasks. 29 | type Manager struct { 30 | m sync.Map 31 | ctx context.Context 32 | } 33 | 34 | // NewManager returns a new task manager. 35 | func NewManager(ctx context.Context) *Manager { 36 | return &Manager{ 37 | m: sync.Map{}, 38 | ctx: ctx, 39 | } 40 | } 41 | 42 | // Add adds a task to the manager. 43 | // If the process already exists, it is a no-op. 44 | func (m *Manager) Add(id string, fn func(context.Context) error) { 45 | if m.Exists(id) { 46 | return 47 | } 48 | 49 | ctx, cancel := context.WithCancel(m.ctx) 50 | m.m.Store(id, &Task{ 51 | id: id, 52 | fn: fn, 53 | ctx: ctx, 54 | cancel: cancel, 55 | }) 56 | } 57 | 58 | // Stop stops the task and removes it from the manager. 59 | func (m *Manager) Stop(id string) error { 60 | v, ok := m.m.Load(id) 61 | if !ok { 62 | return ErrNotFound 63 | } 64 | 65 | p := v.(*Task) 66 | p.cancel() 67 | 68 | m.m.Delete(id) 69 | return nil 70 | } 71 | 72 | // Exists checks if a task exists. 73 | func (m *Manager) Exists(id string) bool { 74 | _, ok := m.m.Load(id) 75 | return ok 76 | } 77 | 78 | // Run starts the task if it exists. 79 | // Otherwise, it waits for the process to finish. 80 | func (m *Manager) Run(id string, done chan<- error) { 81 | v, ok := m.m.Load(id) 82 | if !ok { 83 | done <- ErrNotFound 84 | return 85 | } 86 | 87 | p := v.(*Task) 88 | if p.started.Load() { 89 | <-p.ctx.Done() 90 | if p.err != nil { 91 | done <- p.err 92 | return 93 | } 94 | 95 | done <- p.ctx.Err() 96 | } 97 | 98 | p.started.Store(true) 99 | m.m.Store(id, p) 100 | defer p.cancel() 101 | defer m.m.Delete(id) 102 | 103 | errc := make(chan error, 1) 104 | go func(ctx context.Context) { 105 | errc <- p.fn(ctx) 106 | }(p.ctx) 107 | 108 | select { 109 | case <-m.ctx.Done(): 110 | done <- m.ctx.Err() 111 | case err := <-errc: 112 | p.err = err 113 | m.m.Store(id, p) 114 | done <- err 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /pkg/test/test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "net" 5 | "sync" 6 | ) 7 | 8 | var ( 9 | used = map[int]struct{}{} 10 | lock sync.Mutex 11 | ) 12 | 13 | // RandomPort returns a random port number. 14 | // This is mainly used for testing. 15 | func RandomPort() int { 16 | addr, _ := net.Listen("tcp", ":0") //nolint:gosec 17 | _ = addr.Close() 18 | port := addr.Addr().(*net.TCPAddr).Port 19 | lock.Lock() 20 | 21 | if _, ok := used[port]; ok { 22 | lock.Unlock() 23 | return RandomPort() 24 | } 25 | 26 | used[port] = struct{}{} 27 | lock.Unlock() 28 | return port 29 | } 30 | -------------------------------------------------------------------------------- /pkg/ui/common/common_test.go: -------------------------------------------------------------------------------- 1 | package common_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/ui/common" 7 | ) 8 | 9 | func TestIsFileMarkdown(t *testing.T) { 10 | cases := []struct { 11 | name string 12 | filename string 13 | content string // XXX: chroma doesn't correctly analyze mk files 14 | isMkd bool 15 | }{ 16 | {"simple", "README.md", "", true}, 17 | {"empty", "", "", false}, 18 | {"no extension", "README", "", false}, 19 | {"weird extension", "README.foo", "", false}, 20 | {"long ext", "README.markdown", "", true}, 21 | } 22 | 23 | for _, c := range cases { 24 | t.Run(c.name, func(t *testing.T) { 25 | if got := common.IsFileMarkdown(c.content, c.filename); got != c.isMkd { 26 | t.Errorf("IsFileMarkdown(%q, %q) = %v, want %v", c.content, c.filename, got, c.isMkd) 27 | } 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/ui/common/component.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | tea "github.com/charmbracelet/bubbletea" 6 | ) 7 | 8 | // Component represents a Bubble Tea model that implements a SetSize function. 9 | type Component interface { 10 | tea.Model 11 | help.KeyMap 12 | SetSize(width, height int) 13 | } 14 | 15 | // TabComponenet represents a model that is mounted to a tab. 16 | // TODO: find a better name 17 | type TabComponent interface { 18 | Component 19 | 20 | // StatusBarValue returns the status bar value component. 21 | StatusBarValue() string 22 | 23 | // StatusBarInfo returns the status bar info component. 24 | StatusBarInfo() string 25 | 26 | // SpinnerID returns the ID of the spinner. 27 | SpinnerID() int 28 | 29 | // TabName returns the name of the tab. 30 | TabName() string 31 | 32 | // Path returns the hierarchical path of the tab. 33 | Path() string 34 | } 35 | -------------------------------------------------------------------------------- /pkg/ui/common/error.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | // ErrMissingRepo indicates that the requested repository could not be found. 10 | var ErrMissingRepo = errors.New("missing repo") 11 | 12 | // ErrorMsg is a Bubble Tea message that represents an error. 13 | type ErrorMsg error 14 | 15 | // ErrorCmd returns an ErrorMsg from error. 16 | func ErrorCmd(err error) tea.Cmd { 17 | return func() tea.Msg { 18 | return ErrorMsg(err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/ui/common/format.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/alecthomas/chroma/v2/lexers" 9 | gansi "github.com/charmbracelet/glamour/ansi" 10 | "github.com/charmbracelet/soft-serve/pkg/ui/styles" 11 | ) 12 | 13 | // FormatLineNumber adds line numbers to a string. 14 | func FormatLineNumber(styles *styles.Styles, s string, color bool) (string, int) { 15 | lines := strings.Split(s, "\n") 16 | // NB: len() is not a particularly safe way to count string width (because 17 | // it's counting bytes instead of runes) but in this case it's okay 18 | // because we're only dealing with digits, which are one byte each. 19 | mll := len(fmt.Sprintf("%d", len(lines))) 20 | for i, l := range lines { 21 | digit := fmt.Sprintf("%*d", mll, i+1) 22 | bar := "│" 23 | if color { 24 | digit = styles.Code.LineDigit.Render(digit) 25 | bar = styles.Code.LineBar.Render(bar) 26 | } 27 | if i < len(lines)-1 || len(l) != 0 { 28 | // If the final line was a newline we'll get an empty string for 29 | // the final line, so drop the newline altogether. 30 | lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l) 31 | } 32 | } 33 | return strings.Join(lines, "\n"), mll 34 | } 35 | 36 | // FormatHighlight adds syntax highlighting to a string. 37 | func FormatHighlight(p, c string) (string, error) { 38 | zero := uint(0) 39 | lang := "" 40 | lexer := lexers.Match(p) 41 | if lexer != nil && lexer.Config() != nil { 42 | lang = lexer.Config().Name 43 | } 44 | formatter := &gansi.CodeBlockElement{ 45 | Code: c, 46 | Language: lang, 47 | } 48 | r := strings.Builder{} 49 | styles := StyleConfig() 50 | styles.CodeBlock.Margin = &zero 51 | rctx := StyleRendererWithStyles(styles) 52 | err := formatter.Render(&r, rctx) 53 | if err != nil { 54 | return "", err 55 | } 56 | return r.String(), nil 57 | } 58 | 59 | // UnquoteFilename unquotes a filename. 60 | // When Git is with "core.quotePath" set to "true" (default), it will quote 61 | // the filename with double quotes if it contains control characters or unicode. 62 | // this function will unquote the filename. 63 | func UnquoteFilename(s string) string { 64 | name := s 65 | if n, err := strconv.Unquote(`"` + s + `"`); err == nil { 66 | name = n 67 | } 68 | 69 | name = strconv.Quote(name) 70 | return strings.Trim(name, `"`) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/ui/common/style.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | gansi "github.com/charmbracelet/glamour/ansi" 5 | "github.com/charmbracelet/glamour/styles" 6 | "github.com/muesli/termenv" 7 | ) 8 | 9 | // DefaultColorProfile is the default color profile used by the SSH server. 10 | var DefaultColorProfile = termenv.ANSI256 11 | 12 | func strptr(s string) *string { 13 | return &s 14 | } 15 | 16 | // StyleConfig returns the default Glamour style configuration. 17 | func StyleConfig() gansi.StyleConfig { 18 | noColor := strptr("") 19 | s := styles.DarkStyleConfig 20 | s.H1.BackgroundColor = noColor 21 | s.H1.Prefix = "# " 22 | s.H1.Suffix = "" 23 | s.H1.Color = strptr("39") 24 | s.Document.StylePrimitive.Color = noColor 25 | s.CodeBlock.Chroma.Text.Color = noColor 26 | s.CodeBlock.Chroma.Name.Color = noColor 27 | // This fixes an issue with the default style config. For example 28 | // highlighting empty spaces with red in Dockerfile type. 29 | s.CodeBlock.Chroma.Error.BackgroundColor = noColor 30 | return s 31 | } 32 | 33 | // StyleRenderer returns a new Glamour renderer with the DefaultColorProfile. 34 | func StyleRenderer() gansi.RenderContext { 35 | return StyleRendererWithStyles(StyleConfig()) 36 | } 37 | 38 | // StyleRendererWithStyles returns a new Glamour renderer with the 39 | // DefaultColorProfile and styles. 40 | func StyleRendererWithStyles(styles gansi.StyleConfig) gansi.RenderContext { 41 | return gansi.NewRenderContext(gansi.Options{ 42 | ColorProfile: DefaultColorProfile, 43 | Styles: styles, 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/ui/common/utils.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/charmbracelet/soft-serve/pkg/utils" 8 | "github.com/muesli/reflow/truncate" 9 | ) 10 | 11 | // TruncateString is a convenient wrapper around truncate.TruncateString. 12 | func TruncateString(s string, max int) string { //nolint:revive 13 | if max < 0 { 14 | max = 0 //nolint:revive 15 | } 16 | return truncate.StringWithTail(s, uint(max), "…") //nolint:gosec 17 | } 18 | 19 | // RepoURL returns the URL of the repository. 20 | func RepoURL(publicURL, name string) string { 21 | name = utils.SanitizeRepo(name) + ".git" 22 | url, err := url.Parse(publicURL) 23 | if err == nil { 24 | switch url.Scheme { 25 | case "ssh": 26 | port := url.Port() 27 | if port == "" || port == "22" { 28 | return fmt.Sprintf("git@%s:%s", url.Hostname(), name) 29 | } 30 | return fmt.Sprintf("ssh://%s:%s/%s", url.Hostname(), url.Port(), name) 31 | } 32 | } 33 | 34 | return fmt.Sprintf("%s/%s", publicURL, name) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/ui/components/footer/footer.go: -------------------------------------------------------------------------------- 1 | package footer 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/key" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/charmbracelet/soft-serve/pkg/ui/common" 9 | ) 10 | 11 | // ToggleFooterMsg is a message sent to show/hide the footer. 12 | type ToggleFooterMsg struct{} 13 | 14 | // Footer is a Bubble Tea model that displays help and other info. 15 | type Footer struct { 16 | common common.Common 17 | help help.Model 18 | keymap help.KeyMap 19 | } 20 | 21 | // New creates a new Footer. 22 | func New(c common.Common, keymap help.KeyMap) *Footer { 23 | h := help.New() 24 | h.Styles.ShortKey = c.Styles.HelpKey 25 | h.Styles.ShortDesc = c.Styles.HelpValue 26 | h.Styles.FullKey = c.Styles.HelpKey 27 | h.Styles.FullDesc = c.Styles.HelpValue 28 | f := &Footer{ 29 | common: c, 30 | help: h, 31 | keymap: keymap, 32 | } 33 | f.SetSize(c.Width, c.Height) 34 | return f 35 | } 36 | 37 | // SetSize implements common.Component. 38 | func (f *Footer) SetSize(width, height int) { 39 | f.common.SetSize(width, height) 40 | f.help.Width = width - 41 | f.common.Styles.Footer.GetHorizontalFrameSize() 42 | } 43 | 44 | // Init implements tea.Model. 45 | func (f *Footer) Init() tea.Cmd { 46 | return nil 47 | } 48 | 49 | // Update implements tea.Model. 50 | func (f *Footer) Update(_ tea.Msg) (tea.Model, tea.Cmd) { 51 | return f, nil 52 | } 53 | 54 | // View implements tea.Model. 55 | func (f *Footer) View() string { 56 | if f.keymap == nil { 57 | return "" 58 | } 59 | s := f.common.Styles.Footer. 60 | Width(f.common.Width) 61 | helpView := f.help.View(f.keymap) 62 | return f.common.Zone.Mark( 63 | "footer", 64 | s.Render(helpView), 65 | ) 66 | } 67 | 68 | // ShortHelp returns the short help key bindings. 69 | func (f *Footer) ShortHelp() []key.Binding { 70 | return f.keymap.ShortHelp() 71 | } 72 | 73 | // FullHelp returns the full help key bindings. 74 | func (f *Footer) FullHelp() [][]key.Binding { 75 | return f.keymap.FullHelp() 76 | } 77 | 78 | // ShowAll returns whether the full help is shown. 79 | func (f *Footer) ShowAll() bool { 80 | return f.help.ShowAll 81 | } 82 | 83 | // SetShowAll sets whether the full help is shown. 84 | func (f *Footer) SetShowAll(show bool) { 85 | f.help.ShowAll = show 86 | } 87 | 88 | // Height returns the height of the footer. 89 | func (f *Footer) Height() int { 90 | return lipgloss.Height(f.View()) 91 | } 92 | 93 | // ToggleFooterCmd sends a ToggleFooterMsg to show/hide the help footer. 94 | func ToggleFooterCmd() tea.Msg { 95 | return ToggleFooterMsg{} 96 | } 97 | -------------------------------------------------------------------------------- /pkg/ui/components/header/header.go: -------------------------------------------------------------------------------- 1 | package header 2 | 3 | import ( 4 | "strings" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/soft-serve/pkg/ui/common" 8 | ) 9 | 10 | // Header represents a header component. 11 | type Header struct { 12 | common common.Common 13 | text string 14 | } 15 | 16 | // New creates a new header component. 17 | func New(c common.Common, text string) *Header { 18 | return &Header{ 19 | common: c, 20 | text: text, 21 | } 22 | } 23 | 24 | // SetSize implements common.Component. 25 | func (h *Header) SetSize(width, height int) { 26 | h.common.SetSize(width, height) 27 | } 28 | 29 | // Init implements tea.Model. 30 | func (h *Header) Init() tea.Cmd { 31 | return nil 32 | } 33 | 34 | // Update implements tea.Model. 35 | func (h *Header) Update(_ tea.Msg) (tea.Model, tea.Cmd) { 36 | return h, nil 37 | } 38 | 39 | // View implements tea.Model. 40 | func (h *Header) View() string { 41 | return h.common.Styles.ServerName.Render(strings.TrimSpace(h.text)) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/ui/components/statusbar/statusbar.go: -------------------------------------------------------------------------------- 1 | package statusbar 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/charmbracelet/soft-serve/pkg/ui/common" 7 | "github.com/muesli/reflow/truncate" 8 | ) 9 | 10 | // Model is a status bar model. 11 | type Model struct { 12 | common common.Common 13 | key string 14 | value string 15 | info string 16 | extra string 17 | } 18 | 19 | // New creates a new status bar component. 20 | func New(c common.Common) *Model { 21 | s := &Model{ 22 | common: c, 23 | } 24 | return s 25 | } 26 | 27 | // SetSize implements common.Component. 28 | func (s *Model) SetSize(width, height int) { 29 | s.common.Width = width 30 | s.common.Height = height 31 | } 32 | 33 | // SetStatus sets the status bar status. 34 | func (s *Model) SetStatus(key, value, info, extra string) { 35 | if key != "" { 36 | s.key = key 37 | } 38 | if value != "" { 39 | s.value = value 40 | } 41 | if info != "" { 42 | s.info = info 43 | } 44 | if extra != "" { 45 | s.extra = extra 46 | } 47 | } 48 | 49 | // Init implements tea.Model. 50 | func (s *Model) Init() tea.Cmd { 51 | return nil 52 | } 53 | 54 | // Update implements tea.Model. 55 | func (s *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 56 | switch msg := msg.(type) { 57 | case tea.WindowSizeMsg: 58 | s.SetSize(msg.Width, msg.Height) 59 | } 60 | return s, nil 61 | } 62 | 63 | // View implements tea.Model. 64 | func (s *Model) View() string { 65 | st := s.common.Styles 66 | w := lipgloss.Width 67 | help := s.common.Zone.Mark( 68 | "repo-help", 69 | st.StatusBarHelp.Render("? Help"), 70 | ) 71 | key := st.StatusBarKey.Render(s.key) 72 | info := "" 73 | if s.info != "" { 74 | info = st.StatusBarInfo.Render(s.info) 75 | } 76 | branch := st.StatusBarBranch.Render(s.extra) 77 | maxWidth := s.common.Width - w(key) - w(info) - w(branch) - w(help) 78 | v := truncate.StringWithTail(s.value, uint(maxWidth-st.StatusBarValue.GetHorizontalFrameSize()), "…") //nolint:gosec 79 | value := st.StatusBarValue. 80 | Width(maxWidth). 81 | Render(v) 82 | 83 | return s.common.Renderer.NewStyle().MaxWidth(s.common.Width). 84 | Render( 85 | lipgloss.JoinHorizontal(lipgloss.Top, 86 | key, 87 | value, 88 | info, 89 | branch, 90 | help, 91 | ), 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /pkg/ui/pages/repo/empty.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/config" 7 | "github.com/charmbracelet/soft-serve/pkg/ui/common" 8 | ) 9 | 10 | func defaultEmptyRepoMsg(cfg *config.Config, repo string) string { 11 | return fmt.Sprintf(`# Quick Start 12 | 13 | Get started by cloning this repository, add your files, commit, and push. 14 | 15 | ## Clone this repository. 16 | 17 | `+"```"+`sh 18 | git clone %[1]s 19 | `+"```"+` 20 | 21 | ## Creating a new repository on the command line 22 | 23 | `+"```"+`sh 24 | touch README.md 25 | git init 26 | git add README.md 27 | git branch -M main 28 | git commit -m "first commit" 29 | git remote add origin %[1]s 30 | git push -u origin main 31 | `+"```"+` 32 | 33 | ## Pushing an existing repository from the command line 34 | 35 | `+"```"+`sh 36 | git remote add origin %[1]s 37 | git push -u origin main 38 | `+"```"+` 39 | `, common.RepoURL(cfg.SSH.PublicURL, repo)) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "strings" 7 | "unicode" 8 | ) 9 | 10 | // SanitizeRepo returns a sanitized version of the given repository name. 11 | func SanitizeRepo(repo string) string { 12 | // We need to use an absolute path for the path to be cleaned correctly. 13 | repo = strings.TrimPrefix(repo, "/") 14 | repo = "/" + repo 15 | 16 | // We're using path instead of filepath here because this is not OS dependent 17 | // looking at you Windows 18 | repo = path.Clean(repo) 19 | repo = strings.TrimSuffix(repo, ".git") 20 | return repo[1:] 21 | } 22 | 23 | // ValidateUsername returns an error if any of the given usernames are invalid. 24 | func ValidateUsername(username string) error { 25 | if username == "" { 26 | return fmt.Errorf("username cannot be empty") 27 | } 28 | 29 | if !unicode.IsLetter(rune(username[0])) { 30 | return fmt.Errorf("username must start with a letter") 31 | } 32 | 33 | for _, r := range username { 34 | if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' { 35 | return fmt.Errorf("username can only contain letters, numbers, and hyphens") 36 | } 37 | } 38 | 39 | return nil 40 | } 41 | 42 | // ValidateRepo returns an error if the given repository name is invalid. 43 | func ValidateRepo(repo string) error { 44 | if repo == "" { 45 | return fmt.Errorf("repo cannot be empty") 46 | } 47 | 48 | for _, r := range repo { 49 | if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' && r != '.' && r != '/' { 50 | return fmt.Errorf("repo can only contain letters, numbers, hyphens, underscores, periods, and slashes") 51 | } 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func TestValidateRepo(t *testing.T) { 6 | t.Run("valid", func(t *testing.T) { 7 | for _, repo := range []string{ 8 | "lower", 9 | "Upper", 10 | "with-dash", 11 | "with/slash", 12 | "withnumb3r5", 13 | "with.dot", 14 | "with_underline", 15 | } { 16 | t.Run(repo, func(t *testing.T) { 17 | if err := ValidateRepo(repo); err != nil { 18 | t.Errorf("expected no error, got %v", err) 19 | } 20 | }) 21 | } 22 | }) 23 | t.Run("invalid", func(t *testing.T) { 24 | for _, repo := range []string{ 25 | "with$", 26 | "with@", 27 | "with!", 28 | } { 29 | t.Run(repo, func(t *testing.T) { 30 | if err := ValidateRepo(repo); err == nil { 31 | t.Error("expected an error, got nil") 32 | } 33 | }) 34 | } 35 | }) 36 | } 37 | 38 | func TestSanitizeRepo(t *testing.T) { 39 | cases := []struct { 40 | in, out string 41 | }{ 42 | {"lower", "lower"}, 43 | {"Upper", "Upper"}, 44 | {"with/slash", "with/slash"}, 45 | {"with.dot", "with.dot"}, 46 | {"/with_forward_slash", "with_forward_slash"}, 47 | {"withgitsuffix.git", "withgitsuffix"}, 48 | } 49 | for _, c := range cases { 50 | t.Run(c.in, func(t *testing.T) { 51 | if got := SanitizeRepo(c.in); got != c.out { 52 | t.Errorf("expected %q, got %q", c.out, got) 53 | } 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | // Package version is used to store the version of the server during runtime. 2 | // The values are set during runtime in the main package. 3 | package version 4 | 5 | var ( 6 | // Version is the version of the server. 7 | Version = "" 8 | 9 | // CommitSHA is the commit SHA of the server. 10 | CommitSHA = "" 11 | 12 | // CommitDate is the commit date of the server. 13 | CommitDate = "" 14 | ) 15 | -------------------------------------------------------------------------------- /pkg/web/context.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/charmbracelet/log" 8 | "github.com/charmbracelet/soft-serve/pkg/backend" 9 | "github.com/charmbracelet/soft-serve/pkg/config" 10 | "github.com/charmbracelet/soft-serve/pkg/db" 11 | "github.com/charmbracelet/soft-serve/pkg/store" 12 | ) 13 | 14 | // NewContextHandler returns a new context middleware. 15 | // This middleware adds the config, backend, and logger to the request context. 16 | func NewContextHandler(ctx context.Context) func(http.Handler) http.Handler { 17 | cfg := config.FromContext(ctx) 18 | be := backend.FromContext(ctx) 19 | logger := log.FromContext(ctx).WithPrefix("http") 20 | dbx := db.FromContext(ctx) 21 | datastore := store.FromContext(ctx) 22 | return func(next http.Handler) http.Handler { 23 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | ctx := r.Context() 25 | ctx = config.WithContext(ctx, cfg) 26 | ctx = backend.WithContext(ctx, be) 27 | ctx = log.WithContext(ctx, logger.With( 28 | "method", r.Method, 29 | "path", r.URL, 30 | "addr", r.RemoteAddr, 31 | )) 32 | ctx = db.WithContext(ctx, dbx) 33 | ctx = store.WithContext(ctx, datastore) 34 | r = r.WithContext(ctx) 35 | 36 | next.ServeHTTP(w, r) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/web/http.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/charmbracelet/log" 9 | "github.com/charmbracelet/soft-serve/pkg/config" 10 | ) 11 | 12 | // HTTPServer is an http server. 13 | type HTTPServer struct { 14 | ctx context.Context 15 | cfg *config.Config 16 | 17 | Server *http.Server 18 | } 19 | 20 | // NewHTTPServer creates a new HTTP server. 21 | func NewHTTPServer(ctx context.Context) (*HTTPServer, error) { 22 | cfg := config.FromContext(ctx) 23 | logger := log.FromContext(ctx) 24 | s := &HTTPServer{ 25 | ctx: ctx, 26 | cfg: cfg, 27 | Server: &http.Server{ 28 | Addr: cfg.HTTP.ListenAddr, 29 | Handler: NewRouter(ctx), 30 | ReadHeaderTimeout: time.Second * 10, 31 | IdleTimeout: time.Second * 10, 32 | MaxHeaderBytes: http.DefaultMaxHeaderBytes, 33 | ErrorLog: logger.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel}), 34 | }, 35 | } 36 | 37 | return s, nil 38 | } 39 | 40 | // Close closes the HTTP server. 41 | func (s *HTTPServer) Close() error { 42 | return s.Server.Close() 43 | } 44 | 45 | // ListenAndServe starts the HTTP server. 46 | func (s *HTTPServer) ListenAndServe() error { 47 | if s.cfg.HTTP.TLSKeyPath != "" && s.cfg.HTTP.TLSCertPath != "" { 48 | return s.Server.ListenAndServeTLS(s.cfg.HTTP.TLSCertPath, s.cfg.HTTP.TLSKeyPath) 49 | } 50 | return s.Server.ListenAndServe() 51 | } 52 | 53 | // Shutdown gracefully shuts down the HTTP server. 54 | func (s *HTTPServer) Shutdown(ctx context.Context) error { 55 | return s.Server.Shutdown(ctx) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/web/logging.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/charmbracelet/log" 11 | "github.com/dustin/go-humanize" 12 | ) 13 | 14 | // logWriter is a wrapper around http.ResponseWriter that allows us to capture 15 | // the HTTP status code and bytes written to the response. 16 | type logWriter struct { 17 | http.ResponseWriter 18 | code, bytes int 19 | } 20 | 21 | var _ http.ResponseWriter = (*logWriter)(nil) 22 | 23 | var _ http.Flusher = (*logWriter)(nil) 24 | 25 | var _ http.Hijacker = (*logWriter)(nil) 26 | 27 | var _ http.CloseNotifier = (*logWriter)(nil) // nolint: staticcheck 28 | 29 | // Write implements http.ResponseWriter. 30 | func (r *logWriter) Write(p []byte) (int, error) { 31 | written, err := r.ResponseWriter.Write(p) 32 | r.bytes += written 33 | return written, err 34 | } 35 | 36 | // Note this is generally only called when sending an HTTP error, so it's 37 | // important to set the `code` value to 200 as a default. 38 | func (r *logWriter) WriteHeader(code int) { 39 | r.code = code 40 | r.ResponseWriter.WriteHeader(code) 41 | } 42 | 43 | // Unwrap returns the underlying http.ResponseWriter. 44 | func (r *logWriter) Unwrap() http.ResponseWriter { 45 | return r.ResponseWriter 46 | } 47 | 48 | // Flush implements http.Flusher. 49 | func (r *logWriter) Flush() { 50 | if f, ok := r.ResponseWriter.(http.Flusher); ok { 51 | f.Flush() 52 | } 53 | } 54 | 55 | // CloseNotify implements http.CloseNotifier. 56 | func (r *logWriter) CloseNotify() <-chan bool { 57 | if cn, ok := r.ResponseWriter.(http.CloseNotifier); ok { // nolint: staticcheck 58 | return cn.CloseNotify() 59 | } 60 | return nil 61 | } 62 | 63 | // Hijack implements http.Hijacker. 64 | func (r *logWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 65 | if h, ok := r.ResponseWriter.(http.Hijacker); ok { 66 | return h.Hijack() 67 | } 68 | return nil, nil, fmt.Errorf("http.Hijacker not implemented") 69 | } 70 | 71 | // NewLoggingMiddleware returns a new logging middleware. 72 | func NewLoggingMiddleware(next http.Handler, logger *log.Logger) http.Handler { 73 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 | start := time.Now() 75 | writer := &logWriter{code: http.StatusOK, ResponseWriter: w} 76 | logger.Debug("request", 77 | "method", r.Method, 78 | "path", r.URL, 79 | "addr", r.RemoteAddr) 80 | next.ServeHTTP(writer, r) 81 | elapsed := time.Since(start) 82 | logger.Debug("response", 83 | "status", fmt.Sprintf("%d %s", writer.code, http.StatusText(writer.code)), 84 | "bytes", humanize.Bytes(uint64(writer.bytes)), //nolint:gosec 85 | "time", elapsed) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /pkg/web/server.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/charmbracelet/log" 8 | "github.com/gorilla/handlers" 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | // NewRouter returns a new HTTP router. 13 | func NewRouter(ctx context.Context) http.Handler { 14 | logger := log.FromContext(ctx).WithPrefix("http") 15 | router := mux.NewRouter() 16 | 17 | // Git routes 18 | GitController(ctx, router) 19 | 20 | router.PathPrefix("/").HandlerFunc(renderNotFound) 21 | 22 | // Context handler 23 | // Adds context to the request 24 | h := NewLoggingMiddleware(router, logger) 25 | h = NewContextHandler(ctx)(h) 26 | h = handlers.CompressHandler(h) 27 | h = handlers.RecoveryHandler()(h) 28 | 29 | return h 30 | } 31 | -------------------------------------------------------------------------------- /pkg/web/util.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | func renderStatus(code int) http.HandlerFunc { 10 | return func(w http.ResponseWriter, _ *http.Request) { 11 | w.WriteHeader(code) 12 | io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) // nolint: errcheck 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/webhook/collaborator.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/charmbracelet/soft-serve/pkg/access" 7 | "github.com/charmbracelet/soft-serve/pkg/db" 8 | "github.com/charmbracelet/soft-serve/pkg/proto" 9 | "github.com/charmbracelet/soft-serve/pkg/store" 10 | ) 11 | 12 | // CollaboratorEvent is a collaborator event. 13 | type CollaboratorEvent struct { 14 | Common 15 | 16 | // Action is the collaborator event action. 17 | Action CollaboratorEventAction `json:"action" url:"action"` 18 | // AccessLevel is the collaborator access level. 19 | AccessLevel access.AccessLevel `json:"access_level" url:"access_level"` 20 | // Collaborator is the collaborator. 21 | Collaborator User `json:"collaborator" url:"collaborator"` 22 | } 23 | 24 | // CollaboratorEventAction is a collaborator event action. 25 | type CollaboratorEventAction string 26 | 27 | const ( 28 | // CollaboratorEventAdded is a collaborator added event. 29 | CollaboratorEventAdded CollaboratorEventAction = "added" 30 | // CollaboratorEventRemoved is a collaborator removed event. 31 | CollaboratorEventRemoved CollaboratorEventAction = "removed" 32 | ) 33 | 34 | // NewCollaboratorEvent sends a collaborator event. 35 | func NewCollaboratorEvent(ctx context.Context, user proto.User, repo proto.Repository, collabUsername string, action CollaboratorEventAction) (CollaboratorEvent, error) { 36 | event := EventCollaborator 37 | 38 | payload := CollaboratorEvent{ 39 | Action: action, 40 | Common: Common{ 41 | EventType: event, 42 | Repository: Repository{ 43 | ID: repo.ID(), 44 | Name: repo.Name(), 45 | Description: repo.Description(), 46 | ProjectName: repo.ProjectName(), 47 | Private: repo.IsPrivate(), 48 | CreatedAt: repo.CreatedAt(), 49 | UpdatedAt: repo.UpdatedAt(), 50 | }, 51 | Sender: User{ 52 | ID: user.ID(), 53 | Username: user.Username(), 54 | }, 55 | }, 56 | } 57 | 58 | // Find repo owner. 59 | dbx := db.FromContext(ctx) 60 | datastore := store.FromContext(ctx) 61 | owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID()) 62 | if err != nil { 63 | return CollaboratorEvent{}, db.WrapError(err) 64 | } 65 | 66 | payload.Repository.Owner.ID = owner.ID 67 | payload.Repository.Owner.Username = owner.Username 68 | payload.Repository.DefaultBranch, _ = getDefaultBranch(repo) 69 | 70 | collab, err := datastore.GetCollabByUsernameAndRepo(ctx, dbx, collabUsername, repo.Name()) 71 | if err != nil { 72 | return CollaboratorEvent{}, err 73 | } 74 | 75 | payload.AccessLevel = collab.AccessLevel 76 | payload.Collaborator.ID = collab.UserID 77 | payload.Collaborator.Username = collabUsername 78 | 79 | return payload, nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/webhook/content_type.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "encoding" 5 | "errors" 6 | "strings" 7 | ) 8 | 9 | // ContentType is the type of content that will be sent in a webhook request. 10 | type ContentType int8 11 | 12 | const ( 13 | // ContentTypeJSON is the JSON content type. 14 | ContentTypeJSON ContentType = iota 15 | // ContentTypeForm is the form content type. 16 | ContentTypeForm 17 | ) 18 | 19 | var contentTypeStrings = map[ContentType]string{ 20 | ContentTypeJSON: "application/json", 21 | ContentTypeForm: "application/x-www-form-urlencoded", 22 | } 23 | 24 | // String returns the string representation of the content type. 25 | func (c ContentType) String() string { 26 | return contentTypeStrings[c] 27 | } 28 | 29 | var stringContentType = map[string]ContentType{ 30 | "application/json": ContentTypeJSON, 31 | "application/x-www-form-urlencoded": ContentTypeForm, 32 | } 33 | 34 | // ErrInvalidContentType is returned when the content type is invalid. 35 | var ErrInvalidContentType = errors.New("invalid content type") 36 | 37 | // ParseContentType parses a content type string and returns the content type. 38 | func ParseContentType(s string) (ContentType, error) { 39 | for k, v := range stringContentType { 40 | if strings.HasPrefix(s, k) { 41 | return v, nil 42 | } 43 | } 44 | 45 | return -1, ErrInvalidContentType 46 | } 47 | 48 | var ( 49 | _ encoding.TextMarshaler = ContentType(0) 50 | _ encoding.TextUnmarshaler = (*ContentType)(nil) 51 | ) 52 | 53 | // UnmarshalText implements encoding.TextUnmarshaler. 54 | func (c *ContentType) UnmarshalText(text []byte) error { 55 | ct, err := ParseContentType(string(text)) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | *c = ct 61 | return nil 62 | } 63 | 64 | // MarshalText implements encoding.TextMarshaler. 65 | func (c ContentType) MarshalText() (text []byte, err error) { 66 | ct := c.String() 67 | if ct == "" { 68 | return nil, ErrInvalidContentType 69 | } 70 | 71 | return []byte(ct), nil 72 | } 73 | -------------------------------------------------------------------------------- /testscript/testdata/anon-access.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # start soft serve 4 | exec soft serve & 5 | # wait for SSH server to start 6 | ensureserverrunning SSH_PORT 7 | 8 | # set settings 9 | soft settings allow-keyless true 10 | soft settings anon-access no-access 11 | 12 | # create a repo 13 | soft repo create repo1 14 | git clone ssh://localhost:$SSH_PORT/repo1 repo1 15 | mkfile ./repo1/README.md '# Hello\n\nwelcome' 16 | git -C repo1 add -A 17 | git -C repo1 commit -m 'first' 18 | git -C repo1 push origin HEAD 19 | 20 | # access repo from anon 21 | ! ugit clone ssh://localhost:$SSH_PORT/repo1 urepo1 22 | stderr 'Error: you are not authorized to do this' 23 | 24 | # list repo as anon 25 | usoft repo list 26 | stdout '' 27 | 28 | # create repo as anon 29 | ! usoft repo create urepo2 30 | stderr 'Error: unauthorized' 31 | 32 | # stop the server 33 | [windows] stopserver 34 | -------------------------------------------------------------------------------- /testscript/testdata/config-servers-git_disabled.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # disable git listening 4 | env SOFT_SERVE_SSH_ENABLED=true 5 | env SOFT_SERVE_GIT_ENABLED=false 6 | env SOFT_SERVE_HTTP_ENABLED=true 7 | env SOFT_SERVE_STATS_ENABLED=true 8 | 9 | # start soft serve 10 | exec soft serve --sync-hooks & 11 | 12 | # wait for the ssh + other servers to come up 13 | ensureserverrunning SSH_PORT 14 | ensureserverrunning HTTP_PORT 15 | ensureserverrunning STATS_PORT 16 | 17 | # ensure that the disabled server is not running 18 | ensureservernotrunning GIT_PORT 19 | -------------------------------------------------------------------------------- /testscript/testdata/config-servers-http_disabled.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # disable http listening 4 | env SOFT_SERVE_SSH_ENABLED=true 5 | env SOFT_SERVE_GIT_ENABLED=true 6 | env SOFT_SERVE_HTTP_ENABLED=false 7 | env SOFT_SERVE_STATS_ENABLED=true 8 | 9 | # start soft serve 10 | exec soft serve --sync-hooks & 11 | 12 | # wait for the ssh + other servers to come up 13 | ensureserverrunning SSH_PORT 14 | ensureserverrunning GIT_PORT 15 | ensureserverrunning STATS_PORT 16 | 17 | # ensure that the disabled server is not running 18 | ensureservernotrunning HTTP_PORT 19 | 20 | -------------------------------------------------------------------------------- /testscript/testdata/config-servers-ssh_disabled.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # disable ssh listening 4 | env SOFT_SERVE_SSH_ENABLED=false 5 | env SOFT_SERVE_GIT_ENABLED=true 6 | env SOFT_SERVE_HTTP_ENABLED=true 7 | env SOFT_SERVE_STATS_ENABLED=true 8 | 9 | # start soft serve 10 | exec soft serve --sync-hooks & 11 | 12 | # wait for the git + other servers to come up 13 | ensureserverrunning GIT_PORT 14 | ensureserverrunning HTTP_PORT 15 | ensureserverrunning STATS_PORT 16 | 17 | # ensure that the disabled server is not running 18 | ensureservernotrunning SSH_PORT 19 | -------------------------------------------------------------------------------- /testscript/testdata/config-servers-stats_disabled.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # disable stats listening 4 | env SOFT_SERVE_SSH_ENABLED=true 5 | env SOFT_SERVE_GIT_ENABLED=true 6 | env SOFT_SERVE_HTTP_ENABLED=true 7 | env SOFT_SERVE_STATS_ENABLED=false 8 | 9 | # start soft serve 10 | exec soft serve --sync-hooks & 11 | 12 | # wait for the ssh + other servers to come up 13 | ensureserverrunning SSH_PORT 14 | ensureserverrunning GIT_PORT 15 | ensureserverrunning HTTP_PORT 16 | 17 | # ensure that the disabled server is not running 18 | ensureservernotrunning STATS_PORT 19 | -------------------------------------------------------------------------------- /testscript/testdata/help.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | [windows] dos2unix help.txt 3 | 4 | # start soft serve 5 | exec soft serve --sync-hooks & 6 | # wait for SSH server to start 7 | ensureserverrunning SSH_PORT 8 | 9 | soft --help 10 | cmpenv stdout help.txt 11 | 12 | # stop the server 13 | [windows] stopserver 14 | [windows] ! stderr . 15 | 16 | -- help.txt -- 17 | Soft Serve is a self-hostable Git server for the command line. 18 | 19 | Usage: 20 | ssh -p $SSH_PORT localhost [command] 21 | 22 | Available Commands: 23 | help Help about any command 24 | info Show your info 25 | jwt Generate a JSON Web Token 26 | pubkey Manage your public keys 27 | repo Manage repositories 28 | set-username Set your username 29 | settings Manage server settings 30 | token Manage access tokens 31 | user Manage users 32 | 33 | Flags: 34 | -h, --help help for this command 35 | 36 | Use "ssh -p $SSH_PORT localhost [command] --help" for more information about a command. 37 | -------------------------------------------------------------------------------- /testscript/testdata/jwt.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # start soft serve 4 | exec soft serve & 5 | # wait for SSH server to start 6 | ensureserverrunning SSH_PORT 7 | 8 | # create user 9 | soft user create user1 --key "$USER1_AUTHORIZED_KEY" 10 | 11 | # generate jwt token 12 | soft jwt 13 | stdout '.*\..*\..*' 14 | soft jwt repo 15 | stdout '.*\..*\..*' 16 | usoft jwt 17 | stdout '.*\..*\..*' 18 | usoft jwt repo 19 | stdout '.*\..*\..*' 20 | 21 | # stop the server 22 | [windows] stopserver 23 | [windows] ! stderr . 24 | -------------------------------------------------------------------------------- /testscript/testdata/repo-blob.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # convert crlf to lf on windows 4 | [windows] dos2unix blob1.txt blob2.txt blob3.txt 5 | 6 | # start soft serve 7 | exec soft serve & 8 | # wait for SSH server to start 9 | ensureserverrunning SSH_PORT 10 | 11 | # create a repo 12 | soft repo create repo1 13 | 14 | # clone repo 15 | git clone ssh://localhost:$SSH_PORT/repo1 repo1 16 | 17 | # create some files, commits, tags... 18 | mkfile ./repo1/README.md '# Hello\n\nwelcome' 19 | mkfile ./repo1/main.go 'package main\nconst foo = 2\n' 20 | mkfile ./repo1/.hidden '' 21 | mkdir ./repo1/folder 22 | mkdir ./repo1/.folder 23 | mkfile ./repo1/folder/lib.c '//#include ' 24 | git -C repo1 add -A 25 | git -C repo1 commit -m 'first' 26 | git -C repo1 push origin HEAD 27 | 28 | # print root blob 29 | soft repo blob repo1 README.md 30 | cmp stdout blob1.txt 31 | 32 | # print file blob with revision with line numbers and colors 33 | soft repo blob repo1 master main.go -l -c 34 | cmp stdout blob2.txt 35 | 36 | 37 | # print file blob with revision within folder with lineno 38 | soft repo blob repo1 master folder/lib.c -l 39 | cmp stdout blob3.txt 40 | 41 | # print blob of folder that does not exist 42 | ! soft repo blob repo1 folder/nope.txt 43 | ! stdout . 44 | stderr 'revision does not exist' 45 | 46 | # print blob of bad revision 47 | ! soft repo blob repo1 badrev README.md 48 | ! stdout . 49 | stderr 'revision does not exist' 50 | 51 | # stop the server 52 | [windows] stopserver 53 | 54 | -- blob1.txt -- 55 | # Hello\n\nwelcome 56 | -- blob2.txt -- 57 | 1 │ package main\nconst foo = 2\n 58 | -- blob3.txt -- 59 | 1 │ //#include 60 | -------------------------------------------------------------------------------- /testscript/testdata/repo-collab.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # start soft serve 4 | exec soft serve & 5 | # wait for SSH server to start 6 | ensureserverrunning SSH_PORT 7 | 8 | # setup 9 | soft repo import test https://github.com/charmbracelet/catwalk.git 10 | soft user create foo --key "$USER1_AUTHORIZED_KEY" 11 | 12 | # list collabs 13 | soft repo collab list test 14 | ! stdout . 15 | 16 | # add collab 17 | soft repo collab add test foo 18 | soft repo collab list test 19 | stdout 'foo' 20 | 21 | # remove collab 22 | soft repo collab remove test foo 23 | soft repo collab list test 24 | ! stdout . 25 | 26 | # create empty repo 27 | soft repo create empty '-d "empty repo"' 28 | 29 | # add collab 30 | soft repo collab add empty foo 31 | # add collab again 32 | # test issue #464 https://github.com/charmbracelet/soft-serve/issues/464 33 | ! soft repo collab add empty foo 34 | stderr '.*already exists.*' 35 | # a placeholder to reset stderr 36 | soft help 37 | 38 | # stop the server 39 | [windows] stopserver 40 | [windows] ! stderr . 41 | -------------------------------------------------------------------------------- /testscript/testdata/repo-commit.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # convert crlf to lf on windows 4 | [windows] dos2unix commit1.txt 5 | 6 | # start soft serve 7 | exec soft serve & 8 | # wait for SSH server to start 9 | ensureserverrunning SSH_PORT 10 | 11 | # create a repo 12 | soft repo import basic1 https://github.com/git-fixtures/basic 13 | 14 | # print commit 15 | soft repo commit basic1 b8e471f58bcbca63b07bda20e428190409c2db47 16 | cmp stdout commit1.txt 17 | 18 | # stop the server 19 | [windows] stopserver 20 | [windows] ! stderr . 21 | 22 | -- commit1.txt -- 23 | commit b8e471f58bcbca63b07bda20e428190409c2db47 24 | Author: Daniel Ripolles 25 | Date: Tue Mar 31 11:44:52 UTC 2015 26 | Creating changelog 27 | 28 | 29 | CHANGELOG | 1 + 30 | 1 file changed, 1 insertion(+) 31 | 32 | diff --git a/CHANGELOG b/CHANGELOG 33 | new file mode 100644 34 | index 0000000000000000000000000000000000000000..d3ff53e0564a9f87d8e84b6e28e5060e517008aa 35 | --- /dev/null 36 | +++ b/CHANGELOG 37 | @@ -0,0 +1 @@ 38 | +Initial changelog 39 | 40 | -------------------------------------------------------------------------------- /testscript/testdata/repo-delete.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # start soft serve 4 | exec soft serve & 5 | # wait for SSH server to start 6 | ensureserverrunning SSH_PORT 7 | 8 | soft repo create repo1 9 | soft repo create repo-to-delete 10 | soft repo delete repo-to-delete 11 | ! soft repo delete nope 12 | stderr '.*not found.*' 13 | 14 | # missing argument should fail 15 | ! soft repo branch delete repo1 16 | stderr 'Error.*accepts 2 arg.*' 17 | 18 | soft repo list 19 | stdout 'repo1' 20 | 21 | # stop the server 22 | [windows] stopserver 23 | [windows] ! stderr . 24 | -------------------------------------------------------------------------------- /testscript/testdata/repo-import.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # convert crlf to lf on windows 4 | [windows] dos2unix repo3.txt 5 | 6 | # start soft serve 7 | exec soft serve & 8 | # wait for SSH server to start 9 | ensureserverrunning SSH_PORT 10 | 11 | # import private 12 | soft repo import --private repo1 https://github.com/charmbracelet/catwalk.git 13 | soft repo private repo1 14 | stdout 'true' 15 | 16 | # import hidden 17 | soft repo import --hidden repo2 https://github.com/charmbracelet/catwalk.git 18 | soft repo hidden repo2 19 | stdout 'true' 20 | 21 | # import with name and description 22 | soft repo import --name 'repo33' --description 'descriptive' repo3 https://github.com/charmbracelet/catwalk.git 23 | soft repo info repo3 24 | cmp stdout repo3.txt 25 | 26 | # stop the server 27 | [windows] stopserver 28 | [windows] ! stderr . 29 | 30 | -- repo3.txt -- 31 | Project Name: repo33 32 | Repository: repo3 33 | Description: descriptive 34 | Private: false 35 | Hidden: false 36 | Mirror: false 37 | Owner: admin 38 | Default Branch: main 39 | Branches: 40 | - main 41 | -------------------------------------------------------------------------------- /testscript/testdata/repo-push.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # start soft serve 4 | exec soft serve & 5 | # wait for SSH server to start 6 | ensureserverrunning SSH_PORT 7 | 8 | # create a repo 9 | soft repo create repo-empty -d 'description' -H -p -n 'repo-empty' 10 | 11 | # clone repo 12 | git clone ssh://localhost:$SSH_PORT/repo-empty repo-empty 13 | 14 | # push repo without any commits 15 | ! git -C repo-empty push origin HEAD 16 | 17 | # push repo with a commit 18 | mkfile ./repo-empty/README.md '# Hello\n\nwelcome' 19 | git -C repo-empty add README.md 20 | git -C repo-empty commit -m 'first' 21 | git -C repo-empty push origin HEAD 22 | 23 | # stop the server 24 | [windows] stopserver 25 | -------------------------------------------------------------------------------- /testscript/testdata/repo-tree.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # convert crlf to lf on windows 4 | [windows] dos2unix tree1.txt tree2.txt tree3.txt tree4.txt 5 | 6 | # start soft serve 7 | exec soft serve & 8 | # wait for SSH server to start 9 | ensureserverrunning SSH_PORT 10 | 11 | # create a repo 12 | soft repo create repo1 13 | 14 | # clone repo 15 | git clone ssh://localhost:$SSH_PORT/repo1 repo1 16 | 17 | # create some files, commits, tags... 18 | mkfile ./repo1/README.md '# Hello' 19 | mkfile ./repo1/b.md 'hi' 20 | mkfile ./repo1/.hidden '' 21 | mkdir ./repo1/folder 22 | mkdir ./repo1/.folder 23 | mkfile ./repo1/folder/aa.md 'hello' 24 | git -C repo1 add -A 25 | git -C repo1 commit -m 'first' 26 | git -C repo1 push origin HEAD 27 | 28 | # print root tree 29 | soft repo tree repo1 30 | cmp stdout tree1.txt 31 | 32 | # print folder tree 33 | soft repo tree repo1 folder 34 | cmp stdout tree2.txt 35 | 36 | # print file tree with revision 37 | soft repo tree repo1 master b.md 38 | cmp stdout tree3.txt 39 | 40 | # print tree of folder that does not exist 41 | ! soft repo tree repo1 folder2 42 | ! stdout . 43 | stderr 'file not found' 44 | 45 | # print tree of bad revision 46 | ! soft repo tree repo1 badrev folder 47 | ! stdout . 48 | stderr 'revision does not exist' 49 | 50 | # test unicode file name issue #457 51 | soft repo create repo4 52 | git clone ssh://localhost:$SSH_PORT/repo4 repo4 53 | mkfile ./repo4/🍕.md '🍕' 54 | git -C repo4 add -A 55 | git -C repo4 commit -m 'unicode' 56 | git -C repo4 push origin HEAD 57 | 58 | # print root tree 59 | soft repo tree repo4 60 | cmp stdout tree4.txt 61 | 62 | # stop the server 63 | [windows] stopserver 64 | 65 | -- tree1.txt -- 66 | drwxrwxrwx - folder 67 | -rw-r--r-- - .hidden 68 | -rw-r--r-- 7 B README.md 69 | -rw-r--r-- 2 B b.md 70 | -- tree2.txt -- 71 | -rw-r--r-- 5 B aa.md 72 | -- tree3.txt -- 73 | -rw-r--r-- 2 B b.md 74 | -- tree4.txt -- 75 | -rw-r--r-- 4 B 🍕.md 76 | -------------------------------------------------------------------------------- /testscript/testdata/repo-webhooks.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # start soft serve 4 | exec soft serve & 5 | # wait for SSH server to start 6 | ensureserverrunning SSH_PORT 7 | 8 | # create a repo 9 | soft repo create repo-123 10 | stderr 'Created repository repo-123.*' 11 | stdout ssh://localhost:$SSH_PORT/repo-123.git 12 | 13 | # create webhook 14 | new-webhook WH_REPO_123 15 | 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 16 | 17 | # list webhooks 18 | soft repo webhook list repo-123 19 | stdout '1.*webhook.site/.*' 20 | 21 | # clone repo and commit files 22 | git clone ssh://localhost:$SSH_PORT/repo-123 repo-123 23 | mkfile ./repo-123/README.md 'foobar' 24 | git -C repo-123 add -A 25 | git -C repo-123 commit -m 'first' 26 | git -C repo-123 push origin HEAD 27 | 28 | # list webhook deliveries 29 | soft repo webhook deliver list repo-123 1 30 | stdout '✅.*push.*' 31 | 32 | # stop the server 33 | [windows] stopserver 34 | [windows] ! stderr . 35 | -------------------------------------------------------------------------------- /testscript/testdata/set-username.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # convert crlf to lf on windows 4 | [windows] dos2unix info1.txt info2.txt 5 | 6 | # start soft serve 7 | exec soft serve & 8 | # wait for SSH server to start 9 | ensureserverrunning SSH_PORT 10 | 11 | # get original username 12 | soft info 13 | cmpenv stdout info1.txt 14 | 15 | # set another username 16 | soft set-username test 17 | soft info 18 | cmpenv stdout info2.txt 19 | 20 | # stop the server 21 | [windows] stopserver 22 | [windows] ! stderr . 23 | 24 | -- info1.txt -- 25 | Username: admin 26 | Admin: true 27 | Public keys: 28 | $ADMIN1_AUTHORIZED_KEY 29 | -- info2.txt -- 30 | Username: test 31 | Admin: true 32 | Public keys: 33 | $ADMIN1_AUTHORIZED_KEY 34 | -------------------------------------------------------------------------------- /testscript/testdata/settings.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # start soft serve 4 | exec soft serve & 5 | # wait for SSH server to start 6 | ensureserverrunning SSH_PORT 7 | 8 | # check default allow-keyless 9 | soft settings allow-keyless true 10 | soft settings allow-keyless 11 | stdout 'true.*' 12 | 13 | # change allow-keyless and check 14 | soft settings allow-keyless false 15 | soft settings allow-keyless 16 | stdout 'false.*' 17 | 18 | # check default anon-access 19 | soft settings anon-access 20 | stdout 'read-only.*' 21 | 22 | # change anon-access to all available options, and check them 23 | soft settings anon-access no-access 24 | soft settings anon-access 25 | stdout 'no-access.*' 26 | 27 | soft settings anon-access read-only 28 | soft settings anon-access 29 | stdout 'read-only.*' 30 | 31 | soft settings anon-access read-write 32 | soft settings anon-access 33 | stdout 'read-write.*' 34 | 35 | soft settings anon-access admin-access 36 | soft settings anon-access 37 | stdout 'admin-access.*' 38 | 39 | # try to set a bad access 40 | ! soft settings anon-access nope 41 | ! stdout . 42 | stderr . 43 | 44 | # stop the server 45 | [windows] stopserver 46 | 47 | -------------------------------------------------------------------------------- /testscript/testdata/soft-browse.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | [windows] skip 4 | 5 | # clone repo 6 | #git clone https://github.com/charmbracelet/catwalk.git catwalk 7 | 8 | # run soft browse 9 | # disable this temporarily 10 | #ttyin input.txt 11 | #exec soft browse ./catwalk 12 | 13 | # cd and run soft 14 | # disable this temporarily 15 | #cd catwalk 16 | #ttyin ../input.txt 17 | #exec soft 18 | 19 | -- input.txt -- 20 | jjkkdduu 21 | 22 | jjkkdduu 23 | 24 | jjkkdduu 25 | 26 | jjkkdduu 27 | 28 | jjkkdduu 29 | 30 | qqq 31 | 32 | -------------------------------------------------------------------------------- /testscript/testdata/soft-manpages.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # test `soft man` output 4 | exec soft man 5 | stdout . 6 | -------------------------------------------------------------------------------- /testscript/testdata/ssh-lfs.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | [windows] dos2unix err1.txt err2.txt err3.txt errauth.txt 4 | 5 | skip 'breaks with git-lfs 3.5.1' 6 | 7 | # enable ssh lfs transfer 8 | env SOFT_SERVE_LFS_SSH_ENABLED=true 9 | # start soft serve 10 | exec soft serve & 11 | # wait for SSH server to start 12 | ensureserverrunning SSH_PORT 13 | 14 | # create a user 15 | soft user create foo --key "$USER1_AUTHORIZED_KEY" 16 | 17 | # create a repo 18 | soft repo create repo1 19 | soft repo create repo1p -p 20 | usoft repo create repo2 21 | usoft repo create repo2p -p 22 | 23 | # SSH Git LFS Transfer command 24 | ! soft git-lfs-transfer 25 | cmp stderr err1.txt 26 | ! soft git-lfs-transfer repo1 27 | cmp stderr err2.txt 28 | soft git-lfs-transfer repo1 download 29 | stdout '000eversion=1\n000clocking\n0000' 30 | soft git-lfs-transfer repo1 upload 31 | stdout '000eversion=1\n000clocking\n0000' 32 | usoft git-lfs-transfer repo1 download 33 | stdout '000eversion=1\n000clocking\n0000' 34 | ! usoft git-lfs-transfer repo1 upload 35 | cmp stderr errauth.txt 36 | 37 | # Unauthorized user 38 | ! usoft git-lfs-transfer 39 | cmp stderr err1.txt 40 | ! usoft git-lfs-transfer repo1p 41 | cmp stderr err2.txt 42 | ! usoft git-lfs-transfer repo1p download 43 | cmp stderr errauth.txt 44 | ! usoft git-lfs-transfer repo1p upload 45 | cmp stderr errauth.txt 46 | 47 | # push & create repo with some files, commits, tags... 48 | mkdir ./repo1 49 | git -c init.defaultBranch=master -C repo1 init 50 | mkfile ./repo1/README.md '# Project\nfoo' 51 | mkfile ./repo1/foo.png 'foo' 52 | mkfile ./repo1/bar.png 'bar' 53 | git -C repo1 remote add origin ssh://localhost:$SSH_PORT/repo1 54 | git -C repo1 lfs install --local 55 | git -C repo1 lfs track '*.png' 56 | git -C repo1 add -A 57 | git -C repo1 commit -m 'first' 58 | git -C repo1 tag v0.1.0 59 | git -C repo1 push origin HEAD 60 | git -C repo1 push origin HEAD --tags 61 | 62 | # clone repo with ssh lfs-transfer 63 | git clone ssh://localhost:$SSH_PORT/repo1 repo1c 64 | exists repo1c/README.md 65 | exists repo1c/foo.png 66 | exists repo1c/bar.png 67 | 68 | # stop the server 69 | [windows] stopserver 70 | 71 | -- err1.txt -- 72 | Error: accepts 2 arg(s), received 0 73 | -- err2.txt -- 74 | Error: accepts 2 arg(s), received 1 75 | -- err3.txt -- 76 | Error: invalid request 77 | -- errauth.txt -- 78 | Error: you are not authorized to do this 79 | -------------------------------------------------------------------------------- /testscript/testdata/token.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # start soft serve 4 | exec soft serve & 5 | # wait for SSH server to start 6 | ensureserverrunning SSH_PORT 7 | 8 | # create user 9 | soft user create user1 --key "$USER1_AUTHORIZED_KEY" 10 | 11 | # generate jwt token 12 | usoft token create 'test1' 13 | stdout 'ss_.*' 14 | stderr 'Access token created' 15 | usoft token create --expires-in 1y 'test2' 16 | stdout 'ss_.*' 17 | stderr 'Access token created' 18 | usoft token create --expires-in 1ns 'test3' 19 | stdout 'ss_.*' 20 | stderr 'Access token created' 21 | 22 | # list tokens 23 | usoft token list 24 | cp stdout tokens.txt 25 | grep '1.*test1.*-' tokens.txt 26 | grep '2.*test2.*1 year from now' tokens.txt 27 | grep '3.*est3.*expired' tokens.txt 28 | 29 | # delete token 30 | usoft token delete 1 31 | stderr 'Access token deleted' 32 | ! usoft token delete 1 33 | stderr 'token not found' 34 | 35 | # stop the server 36 | [windows] stopserver 37 | -------------------------------------------------------------------------------- /testscript/testdata/ui-home.txtar: -------------------------------------------------------------------------------- 1 | # vi: set ft=conf 2 | 3 | # start soft serve 4 | exec soft serve & 5 | # wait for SSH server to start 6 | ensureserverrunning SSH_PORT 7 | 8 | # test repositories tab 9 | ui '" q"' 10 | cp stdout home.txt 11 | grep 'Test Soft Serve' home.txt 12 | grep '• Repositories' home.txt 13 | grep 'No items' home.txt 14 | 15 | # test about tab 16 | ui '"\t q"' 17 | cp stdout about.txt 18 | grep 'Create a `.soft-serve` repository and add a `README.md` file' about.txt 19 | 20 | # add a new repo 21 | soft repo create .soft-serve -n 'Config' -d '"Test Soft Serve"' 22 | soft repo description .soft-serve 23 | stdout 'Test Soft Serve' 24 | soft repo project-name .soft-serve 25 | stdout 'Config' 26 | 27 | # clone repo 28 | git clone ssh://localhost:$SSH_PORT/.soft-serve config 29 | 30 | # create readme file 31 | mkfile ./config/README.md '# Hello World\nTest Soft Serve' 32 | git -C config add -A 33 | git -C config commit -m 'Initial commit' 34 | git -C config push origin HEAD 35 | 36 | # test repositories tab 37 | ui '" q"' 38 | cp stdout home2.txt 39 | grep 'Config' home2.txt 40 | grep 'Test Soft Serve' home2.txt 41 | grep 'git clone ssh://localhost:.*/.soft-serve' home2.txt 42 | 43 | # test about tab 44 | ui '"\t q"' 45 | cp stdout about2.txt 46 | grep '• About' about2.txt 47 | grep 'Hello World' about2.txt 48 | grep 'Test Soft Serve' about2.txt 49 | 50 | # stop the server 51 | [windows] stopserver 52 | [windows] ! stderr . 53 | 54 | --------------------------------------------------------------------------------