michal/tit
Browse tree · Show commit · Download archive
Diff
a99a15017f7a → 5ab87d988d78
.github/workflows/ci.yml
Mode 100644 → 100644; object b740b14062f4 → ecbc293549dc
@@ -31,16 +31,32 @@
- run: cargo --version
- run: cargo test --locked --all-targets --all-features
- run: cargo test --locked --release --test auth --test ssh --test account_lifecycle --test web_session --test repository_policy
- - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes --test serve
+ - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes
- run: cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
- run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
- run: cargo build --locked --release
- run: wc -c target/release/tit
+ - run: ./scripts/package-release target/release/tit dist
+ - name: Verify the release artifact
+ run: |
+ set -- dist/*.tar.gz
+ ./scripts/verify-release-artifact "$1" "$1.sha256"
+ mkdir release-test
+ tar -xzf "$1" -C release-test
+ set -- release-test/tit-*/bin/tit
+ TIT_RELEASE_BINARY="$1" cargo test --locked --release --test serve
+ - name: Store the release artifact
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: tit-${{ runner.os }}-${{ runner.arch }}
+ path: dist/*
+ if-no-files-found: error
bsd:
name: release (${{ matrix.platform.name }})
runs-on: ubuntu-latest
timeout-minutes: 60
+ continue-on-error: ${{ matrix.platform.experimental }}
strategy:
fail-fast: false
matrix:
@@ -49,14 +65,20 @@
os: freebsd
version: '15.1'
install: sudo pkg install -y git rust
+ experimental: false
- name: OpenBSD 7.9
os: openbsd
version: '7.9'
install: sudo pkg_add git rust
+ experimental: true
- name: NetBSD 10.1
os: netbsd
version: '10.1'
install: sudo pkgin -y install git-base rust
+ experimental: false
+ env:
+ CARGO_INCREMENTAL: 0
+ CARGO_PROFILE_TEST_DEBUG: 0
defaults:
run:
shell: cpa.sh {0}
@@ -74,6 +96,39 @@
- name: Install build and test tools
run: ${{ matrix.platform.install }}
- run: cargo --version
- - run: cargo test --locked --all-targets --all-features
+ - run: cargo test --locked --release --all-targets --all-features
- run: cargo build --locked --release
- run: wc -c target/release/tit
+ - run: ./scripts/package-release target/release/tit dist
+ - name: Verify the release artifact
+ run: |
+ set -- dist/*.tar.gz
+ ./scripts/verify-release-artifact "$1" "$1.sha256"
+ - name: Store the release artifact
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: tit-${{ matrix.platform.os }}-X64
+ path: dist/*
+ if-no-files-found: error
+
+ publish:
+ if: startsWith(github.ref, 'refs/tags/v')
+ needs: [quality, release, bsd]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Download release artifacts
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+ with:
+ path: release-artifacts
+ - name: Publish the GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -- release-artifacts/tit-Linux-*/*.tar.gz
+ case "$(basename "$1")" in
+ "tit-${GITHUB_REF_NAME#v}-"*) ;;
+ *) echo "The tag does not match the package version." >&2; exit 1 ;;
+ esac
+ gh release create "$GITHUB_REF_NAME" release-artifacts/*/* --verify-tag --generate-notes
README.md
Mode 100644 → 100644; object 2066b25c35f7 → 35e7ccbc5c50
@@ -7,6 +7,11 @@ Read [PLAN.md](PLAN.md) for the product design and implementation gates. Read [CONTRIBUTING.md](CONTRIBUTING.md) before you change code. +For a release installation, use the [installation procedure](docs/install.md). +Also keep the [upgrade procedure](docs/upgrade.md) and the +[disaster-recovery exercise](docs/disaster-recovery.md) with the instance +operations documentation. + ## Build Install the Rust toolchain that `rust-toolchain.toml` specifies. Then, run: @@ -193,6 +198,9 @@ ```text ssh -p 2222 tit.example repo create project ``` + +Run `ssh -p 2222 tit.example help` to list all SSH commands. A command that is +not valid returns a nonzero status and tells the user to run `help`. The account that owns the SSH key becomes the repository owner. The SSH login name does not select the owner. New repositories use SHA-1 unless the command @@ -665,3 +673,17 @@ operation IDs, fixed metrics, audit records, and secret redaction. Read the [observability architectural decision record](docs/adr/0032-observability.md) for the event and redaction contracts. + +## Milestone 6.6 gate + +Install stock Git, OpenSSH, and `ssh-keygen`. Then, run the release gate: + +```text +./scripts/check-m6-6 +``` + +This command runs the quality gate, creates and verifies the native release +archive, damages and restores a disposable instance, and runs the process +security and recovery tests with the packaged executable. Read the +[release packaging architectural decision record](docs/adr/0033-release-packaging.md) +for the artifact and platform gate contracts.
docs/adr/0033-release-packaging.md
Mode → 100644; object → fa1d828e6b5b
@@ -1,0 +1,40 @@ +# 0033: Release packages use native executable archives + +- Status: accepted +- Date: 2026-07-23 + +## Context + +An operator must be able to install and recover `tit` without a Rust build +environment. A release must also show which operating systems passed their +native gates. + +## Decision + +Each supported release job builds one native `tit` executable. The job puts the +executable, documentation, native service examples, a Caddy example, the +manual, and shell completions in a versioned archive. The job also makes a +SHA-256 checksum. + +The release verification runs the packaged executable with an empty `PATH`. +It starts an instance, runs `doctor`, makes and checks a backup, damages a +disposable database, restores the backup to an empty directory, and runs +`doctor` again. The process security and recovery tests also use the executable +from the archive. + +Linux and macOS are release gates. FreeBSD, OpenBSD, and NetBSD become release +gates when the current native Rust toolchain and the application pass on that +system. OpenBSD 7.9 is not a release gate while its native Rust package is older +than the `rust-version` in `Cargo.toml`. + +GitHub Actions stores each successful archive and checksum. A `v` tag publishes +the stored files only after the required jobs pass. + +## Consequences + +The archive does not need Git, OpenSSH, or a Rust build environment at run +time. The operating system can still supply its normal C and system libraries. + +An operating-system name in the release files means that the native build, +test, package, and clean-`PATH` verification passed. A platform that does not +pass its gate does not produce a release file.
docs/disaster-recovery.md
Mode → 100644; object → e6773262db17
@@ -1,0 +1,42 @@ +# Disaster-recovery exercise + +Do this exercise on a disposable copy. Do not damage the active instance. +The commands below use `/srv`. On macOS, use a test directory in +`/usr/local/var`. + +1. Stop the disposable service. Copy its configuration and data to a private + test directory. +2. Start the disposable service with unused listener ports. Make a backup: + + ```text + tit --config /srv/tit-exercise/config.toml backup /var/backups/tit-exercise.tar + ``` + +3. Check the archive: + + ```text + tit --config /srv/tit-exercise/config.toml doctor --backup /var/backups/tit-exercise.tar + ``` + +4. Stop the disposable service. Damage its database. For example, move + `tit.sqlite3` out of the instance directory. +5. Run `doctor` and confirm that it returns a failure. Do not use a repair + command for this exercise. +6. Create a different, empty directory with mode 700. Restore the archive: + + ```text + install -d -m 700 /srv/tit-exercise-restored + tit restore /var/backups/tit-exercise.tar /srv/tit-exercise-restored + tit --config /srv/tit-exercise-restored/config.toml doctor + ``` + +7. Change the restored configuration to unused listener ports. Start the + restored service. +8. Confirm that `/healthz` returns status 200. Confirm that a public repository + page works. Complete an SSH fetch and an administrator login. +9. Stop the restored service. Remove the two disposable directories only after + you record the exercise result. + +Record the release version, backup checksum, damage operation, `doctor` error, +restore duration, and validation result. Repeat the exercise after a storage, +service, or backup procedure change.
docs/install.md
Mode → 100644; object → 02f5e35f6f59
@@ -1,0 +1,128 @@ +# Install and remove tit + +This procedure installs one `tit` instance. The default instance directory is +`/srv/tit`. The release package contains the executable, configuration +example, service examples, Caddy example, manual, shell completions, upgrade +procedure, and disaster-recovery exercise. + +## Verify the release package + +Download one archive and its adjacent `.sha256` file from the same release. +Use the archive for your operating system and architecture. Then, compare its +SHA-256 checksum: + +```text +sha256sum -c tit-VERSION-TARGET.tar.gz.sha256 +``` + +On macOS, use this command: + +```text +shasum -a 256 -c tit-VERSION-TARGET.tar.gz.sha256 +``` + +The checksum detects a damaged download. Confirm that you got the checksum +from the expected release before you use the archive. + +## Install the files + +Extract the archive. Select the instance directory and service account. Use +`/srv/tit` and `tit` on Linux, FreeBSD, and NetBSD. Use `/srv/tit` and `_tit` +on OpenBSD. Use `/usr/local/var/tit` and `_tit` on macOS because `/usr/local` +is on the writable macOS data volume. + +Run these commands as `root` after you set `instance` and `account`: + +```text +instance=/srv/tit +account=tit +install -m 755 tit-VERSION-TARGET/bin/tit /usr/local/bin/tit +install -d -m 700 "$instance" +chown "$account" "$instance" +install -m 600 tit-VERSION-TARGET/etc/tit/config.toml "$instance/config.toml" +chown "$account" "$instance/config.toml" +``` + +Create a dedicated system account before these commands. Give the account no +login shell and no password. Use `tit` on Linux, FreeBSD, and NetBSD. Use +`_tit` on macOS and OpenBSD. The supplied service examples use these names. +Use the native account administration command for the operating system. Do not +give the account an interactive login or an administrator role. + +Edit the installed `config.toml`. Set `public_url`, the listener addresses, and +the public SSH hostname and port. Keep the file and directory readable only by +the service account. + +Install the applicable files from `share`: + +- Linux: install `share/doc/tit/examples/tit.service` in + `/etc/systemd/system/tit.service`. +- macOS: install `share/doc/tit/examples/com.tit-cde.tit.plist` in + `/Library/LaunchDaemons/com.tit-cde.tit.plist`. +- FreeBSD: install `share/doc/tit/examples/tit.freebsd` in + `/usr/local/etc/rc.d/tit`. +- OpenBSD: install `share/doc/tit/examples/tit.openbsd` in `/etc/rc.d/tit`. +- NetBSD: install `share/doc/tit/examples/tit.netbsd` in `/etc/rc.d/tit`. +- Caddy: copy the supplied `Caddyfile` content into the site configuration and + change `tit.example` to the public hostname. + +The package also supplies a `tit.1` manual and completions for Bash, Zsh, and +Fish. Copy them to the matching system directories if the package manager does +not do this operation. + +## Configure the first administrator + +The commands below use `/srv/tit`. On macOS, use `/usr/local/var/tit`. + +Run the setup command as the service account: + +```text +tit --config /srv/tit/config.toml setup admin USERNAME "SSH_PUBLIC_KEY" +``` + +Store the printed recovery credential offline. The command prints it one time. +Then, run the read-only check: + +```text +tit --config /srv/tit/config.toml doctor +``` + +The first `doctor` check requires the SSH host key. Start and stop the service +one time if the file does not exist. Then, run `doctor` again. + +## Start the service + +Use the native service manager: + +```text +systemctl enable --now tit +``` + +On macOS, use `launchctl bootstrap system +/Library/LaunchDaemons/com.tit-cde.tit.plist`. On a BSD system, enable `tit` +in the applicable system configuration: + +- FreeBSD: run `sysrc tit_enable=YES` and `service tit start`. +- OpenBSD: run `rcctl enable tit` and `rcctl start tit`. +- NetBSD: add `tit=YES` to `/etc/rc.conf` and run `service tit start`. + +Request `/healthz` through Caddy. Status 200 shows that the HTTP and SSH +listeners are ready. + +## Remove the service + +Make a final backup before removal. Stop and disable the native service: + +- Linux: run `systemctl disable --now tit`. +- macOS: run `launchctl bootout system/com.tit-cde.tit`. +- FreeBSD: run `service tit stop` and `sysrc tit_enable=NO`. +- OpenBSD: run `rcctl stop tit` and `rcctl disable tit`. +- NetBSD: run `service tit stop` and remove `tit=YES` from `/etc/rc.conf`. + +Remove the service file, `/usr/local/bin/tit`, the manual, and the shell +completions. Reload the service manager when it requires this operation. + +Keep `/srv/tit` if you can need its repositories, credentials, or audit +history. To remove all instance data, remove `/srv/tit` only after you verify +the final backup and its storage location. This operation cannot be reversed +without a backup.
docs/upgrade.md
Mode → 100644; object → 00a26a7b520f
@@ -1,0 +1,40 @@ +# Upgrade tit + +Use this procedure for an upgrade to a new release. Do not replace a running +executable before you make a backup. + +The commands below use `/srv/tit`. On macOS, use `/usr/local/var/tit`. + +1. Read the release notes and the new `config.example.toml`. +2. Download the archive and its `.sha256` file. Verify the checksum as + specified in `install.md`. +3. Make an online backup: + + ```text + tit --config /srv/tit/config.toml backup /var/backups/tit-before-upgrade.tar + ``` + +4. Check the active instance and the backup: + + ```text + tit --config /srv/tit/config.toml doctor --backup /var/backups/tit-before-upgrade.tar + ``` + +5. Stop the native service. Keep a copy of the old executable outside + `/srv/tit`. +6. Install the new `bin/tit` with mode 755. Do not replace the configuration + with the example. +7. Run the new read-only check: + + ```text + /usr/local/bin/tit --config /srv/tit/config.toml doctor + ``` + +8. Start the service. Request `/healthz` and complete one HTTP login and one + SSH fetch. + +If the new executable does not accept the current configuration or data, stop +it. Restore the old executable. If the new release changed the instance before +it failed, restore the backup to a new, empty directory. Do not restore into +the damaged directory. Activate the restored directory only after `doctor` +passes.
release/completions/_tit
Mode → 100644; object → ca607da6e543
@@ -1,0 +1,44 @@
+#compdef tit
+
+_tit()
+{
+ local -a commands
+ commands=(
+ 'serve:Start the HTTP and SSH servers'
+ 'invite-code:Create a single-use signup invitation'
+ 'doctor:Check the instance without changing it'
+ 'inspect:Show one typed instance record as JSON'
+ 'dump:Write all SQLite rows as deterministic JSON Lines'
+ 'repair:Run an explicit repair operation'
+ 'backup:Create a backup archive'
+ 'restore:Restore a backup archive to an empty instance directory'
+ 'setup:Set up an uninitialized instance'
+ 'admin:Run an offline administrator command'
+ )
+
+ _arguments \
+ '--config[Read configuration from a file]:configuration file:_files' \
+ '--user[Read configuration from the XDG data directory]' \
+ '--public-url[Override the canonical public URL]:URL:' \
+ '--http-listen[Override the HTTP listener address]:address:' \
+ '--ssh-listen[Override the SSH listener address]:address:' \
+ '--ssh-public-host[Override the advertised SSH hostname]:host:' \
+ '--ssh-public-port[Override the public SSH port]:port:' \
+ '(-h --help)'{-h,--help}'[Print help]' \
+ '(-V --version)'{-V,--version}'[Print the version]' \
+ '1:command:->command' \
+ '*::argument:->arguments'
+
+ case $state in
+ command)
+ _describe 'command' commands
+ ;;
+ arguments)
+ case $words[1] in
+ backup|restore) _files ;;
+ esac
+ ;;
+ esac
+}
+
+_tit "$@"
release/completions/tit.bash
Mode → 100644; object → 803085db73ca
@@ -1,0 +1,25 @@
+_tit()
+{
+ local cur prev commands
+ COMPREPLY=()
+ cur=${COMP_WORDS[COMP_CWORD]}
+ prev=${COMP_WORDS[COMP_CWORD-1]}
+ commands="serve invite-code doctor inspect dump repair backup restore setup admin help"
+
+ case "$prev" in
+ --config|--backup|backup|restore)
+ COMPREPLY=( $(compgen -f -- "$cur") )
+ return
+ ;;
+ --public-url|--http-listen|--ssh-listen|--ssh-public-host|--ssh-public-port)
+ return
+ ;;
+ esac
+
+ if [[ "$cur" == -* ]]; then
+ COMPREPLY=( $(compgen -W "--config --user --public-url --http-listen --ssh-listen --ssh-public-host --ssh-public-port --help --version" -- "$cur") )
+ elif [ "$COMP_CWORD" -eq 1 ]; then
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
+ fi
+}
+complete -F _tit tit
release/completions/tit.fish
Mode → 100644; object → 7cebc56b2c43
@@ -1,0 +1,20 @@ +complete -c tit -f +complete -c tit -l config -r -d 'Read configuration from a file' +complete -c tit -l user -d 'Read configuration from the XDG data directory' +complete -c tit -l public-url -r -d 'Override the canonical public URL' +complete -c tit -l http-listen -r -d 'Override the HTTP listener address' +complete -c tit -l ssh-listen -r -d 'Override the SSH listener address' +complete -c tit -l ssh-public-host -r -d 'Override the advertised SSH hostname' +complete -c tit -l ssh-public-port -r -d 'Override the public SSH port' +complete -c tit -s h -l help -d 'Print help' +complete -c tit -s V -l version -d 'Print the version' +complete -c tit -n '__fish_use_subcommand' -a serve -d 'Start the HTTP and SSH servers' +complete -c tit -n '__fish_use_subcommand' -a invite-code -d 'Create a single-use signup invitation' +complete -c tit -n '__fish_use_subcommand' -a doctor -d 'Check the instance without changing it' +complete -c tit -n '__fish_use_subcommand' -a inspect -d 'Show one typed instance record as JSON' +complete -c tit -n '__fish_use_subcommand' -a dump -d 'Write all SQLite rows as deterministic JSON Lines' +complete -c tit -n '__fish_use_subcommand' -a repair -d 'Run an explicit repair operation' +complete -c tit -n '__fish_use_subcommand' -a backup -d 'Create a backup archive' +complete -c tit -n '__fish_use_subcommand' -a restore -d 'Restore a backup archive' +complete -c tit -n '__fish_use_subcommand' -a setup -d 'Set up an uninitialized instance' +complete -c tit -n '__fish_use_subcommand' -a admin -d 'Run an offline administrator command'
release/examples/Caddyfile
Mode → 100644; object → 3e2b0b884ada
@@ -1,0 +1,3 @@
+tit.example {
+ reverse_proxy 127.0.0.1:3000
+}
release/examples/com.tit-cde.tit.plist
Mode → 100644; object → 4a7cb9e1f284
@@ -1,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>com.tit-cde.tit</string> + <key>ProgramArguments</key> + <array> + <string>/usr/local/bin/tit</string> + <string>--config</string> + <string>/usr/local/var/tit/config.toml</string> + <string>serve</string> + </array> + <key>RunAtLoad</key> + <true/> + <key>KeepAlive</key> + <dict> + <key>SuccessfulExit</key> + <false/> + </dict> + <key>ProcessType</key> + <string>Background</string> + <key>UserName</key> + <string>_tit</string> +</dict> +</plist>
release/examples/tit.freebsd
Mode → 100755; object → 329c2f2829d0
@@ -1,0 +1,21 @@
+#!/bin/sh
+
+# PROVIDE: tit
+# REQUIRE: LOGIN NETWORKING
+# KEYWORD: shutdown
+
+. /etc/rc.subr
+
+name="tit"
+rcvar="tit_enable"
+load_rc_config "$name"
+
+: "${tit_enable:=NO}"
+: "${tit_config:=/srv/tit/config.toml}"
+: "${tit_user:=tit}"
+
+command="/usr/sbin/daemon"
+command_args="-r -S -T tit -u ${tit_user} -p /var/run/tit.pid -f /usr/local/bin/tit --config ${tit_config} serve"
+pidfile="/var/run/tit.pid"
+
+run_rc_command "$1"
release/examples/tit.netbsd
Mode → 100755; object → e589f1071ed9
@@ -1,0 +1,17 @@ +#!/bin/sh + +# PROVIDE: tit +# REQUIRE: DAEMON NETWORKING +# KEYWORD: shutdown + +$_rc_subr_loaded . /etc/rc.subr + +name="tit" +rcvar="$name" +command="/usr/local/bin/tit" +command_args="--config /srv/tit/config.toml serve" +command_user="tit" +required_files="/srv/tit/config.toml" + +load_rc_config "$name" +run_rc_command "$1"
release/examples/tit.openbsd
Mode → 100755; object → 998bb6b0c3e4
@@ -1,0 +1,9 @@ +#!/bin/ksh + +daemon="/usr/local/bin/tit" +daemon_user="_tit" +daemon_flags="--config /srv/tit/config.toml serve" + +. /etc/rc.d/rc.subr + +rc_cmd "$1"
release/examples/tit.service
Mode → 100644; object → ccf7b70268dc
@@ -1,0 +1,20 @@ +[Unit] +Description=tit collaborative development environment +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=tit +Group=tit +ExecStart=/usr/local/bin/tit --config /srv/tit/config.toml serve +Restart=on-failure +RestartSec=5s +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/srv/tit + +[Install] +WantedBy=multi-user.target
release/man/tit.1
Mode → 100644; object → da34821ee57c
@@ -1,0 +1,100 @@ +.TH TIT 1 "2026-07-23" "tit 0.1.0" "User Commands" +.SH NAME +tit \- run a small self-hosted collaborative development environment +.SH SYNOPSIS +.B tit +[\fIOPTIONS\fR] \fICOMMAND\fR +.SH DESCRIPTION +.B tit +runs one HTTP server, one SSH server, Git transport, collaboration workflows, +backup, restore, and diagnostics in one executable. +It does not require a Git executable at run time. +.SH OPTIONS +.TP +.BR \-\-config " " \fIFILE\fR +Read configuration from FILE. +.TP +.B \-\-user +Read configuration from the XDG data directory. +.TP +.BR \-\-public\-url " " \fIURL\fR +Override the canonical public URL. +.TP +.BR \-\-http\-listen " " \fIADDRESS\fR +Override the HTTP listener address. +.TP +.BR \-\-ssh\-listen " " \fIADDRESS\fR +Override the SSH listener address. +.TP +.BR \-\-ssh\-public\-host " " \fIHOST\fR +Override the advertised SSH hostname. +.TP +.BR \-\-ssh\-public\-port " " \fIPORT\fR +Override the public SSH port. +.TP +.BR \-h ", " \-\-help +Print help. +.TP +.BR \-V ", " \-\-version +Print the version. +.SH COMMANDS +.TP +.B serve +Start the HTTP and SSH servers. +.TP +.B invite-code +Create a single-use signup invitation. +.TP +.B doctor +Check the instance without changing it. +Use +.B \-\-backup FILE +to also check a backup. +.TP +.B inspect +Show an account, repository, or Git operation intent as JSON. +.TP +.B dump +Write all SQLite rows as deterministic JSON Lines. +.TP +.B repair +Run an explicit intent or quarantine repair operation. +.TP +.BR backup " " \fIFILE\fR +Create an owner-only backup archive. +.TP +.BR restore " " \fIARCHIVE DIRECTORY\fR +Restore an archive to an empty private directory. +.TP +.B setup +Create the initial administrator. +.TP +.B admin +Run an offline account, repository, or audit command. +.SH SSH INTERFACE +Run +.B ssh -p 2222 tit.example help +to show the commands that the built-in SSH server accepts. +An invalid command returns a nonzero status and tells the user to run +.BR help . +.SH FILES +.TP +.B /srv/tit/config.toml +Default system configuration. +.TP +.B /srv/tit/tit.sqlite3 +SQLite metadata database. +.TP +.B /srv/tit/repositories +Bare Git repositories. +.TP +.B /srv/tit/ssh_host_ed25519_key +Built-in SSH server host key. +.SH SECURITY +The instance directory and configuration must have owner-only permissions. +Backups contain credentials. +Store them as secrets. +.SH SEE ALSO +The release package contains install, upgrade, and disaster-recovery +procedures in +.BR share/doc/tit .
scripts/check-m6-6
Mode → 100755; object → 805ddbf7536e
@@ -1,0 +1,25 @@
+#!/bin/sh
+set -eu
+
+project_dir=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
+work=$(mktemp -d "${TMPDIR:-/tmp}/tit-m6-6.XXXXXX")
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+
+cd "$project_dir"
+./scripts/check
+./scripts/package-release target/release/tit "$work/dist" >"$work/package.list"
+
+archive=$(sed -n '1p' "$work/package.list")
+checksum=$(sed -n '2p' "$work/package.list")
+./scripts/verify-release-artifact "$archive" "$checksum"
+
+mkdir "$work/package"
+tar -xzf "$archive" -C "$work/package"
+set -- "$work"/package/tit-*/bin/tit
+if [ "$#" -ne 1 ] || [ ! -x "$1" ]; then
+ echo "tit: the release test executable is not available" >&2
+ exit 1
+fi
+
+TIT_RELEASE_BINARY=$1 \
+ cargo test --locked --release --test serve
scripts/package-release
Mode → 100755; object → 2464317f8e8a
@@ -1,0 +1,112 @@
+#!/bin/sh
+set -eu
+
+project_dir=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
+binary=${1:-"$project_dir/target/release/tit"}
+output_dir=${2:-"$project_dir/dist"}
+
+case "$binary" in
+ /*) ;;
+ *) binary="$project_dir/$binary" ;;
+esac
+
+if [ ! -x "$binary" ]; then
+ echo "tit: the release executable does not exist: $binary" >&2
+ exit 1
+fi
+
+set -- $("$binary" --version)
+if [ "$#" -ne 2 ] || [ "$1" != "tit" ]; then
+ echo "tit: the release executable has invalid version output" >&2
+ exit 1
+fi
+version=$2
+case "$version" in
+ *[!0-9A-Za-z.-]* | "") echo "tit: the release version is not valid" >&2; exit 1 ;;
+esac
+
+target=$(rustc -vV | awk '/^host: / { print $2 }')
+case "$target" in
+ *[!0-9A-Za-z_.-]* | "") echo "tit: the Rust host target is not valid" >&2; exit 1 ;;
+esac
+
+mkdir -p "$output_dir"
+output_dir=$(CDPATH= cd "$output_dir" && pwd)
+package="tit-$version-$target"
+archive="$package.tar.gz"
+checksum="$archive.sha256"
+if [ -e "$output_dir/$archive" ] || [ -e "$output_dir/$checksum" ]; then
+ echo "tit: the release output already exists for $target" >&2
+ exit 1
+fi
+
+stage=$(mktemp -d "${TMPDIR:-/tmp}/tit-package.XXXXXX")
+trap 'rm -rf "$stage"' EXIT HUP INT TERM
+root="$stage/$package"
+
+install -d -m 755 \
+ "$root/bin" \
+ "$root/etc/tit" \
+ "$root/share/doc/tit/adr" \
+ "$root/share/doc/tit/examples" \
+ "$root/share/man/man1" \
+ "$root/share/bash-completion/completions" \
+ "$root/share/zsh/site-functions" \
+ "$root/share/fish/vendor_completions.d"
+install -m 755 "$binary" "$root/bin/tit"
+install -m 644 "$project_dir/LICENSE" "$root/LICENSE"
+install -m 644 "$project_dir/README.md" "$root/README.md"
+install -m 600 "$project_dir/config.example.toml" "$root/etc/tit/config.toml"
+install -m 644 "$project_dir/docs/install.md" "$root/share/doc/tit/install.md"
+install -m 644 "$project_dir/docs/upgrade.md" "$root/share/doc/tit/upgrade.md"
+install -m 644 \
+ "$project_dir/docs/disaster-recovery.md" \
+ "$root/share/doc/tit/disaster-recovery.md"
+install -m 644 \
+ "$project_dir/docs/adr/0029-backup-and-restore.md" \
+ "$root/share/doc/tit/adr/0029-backup-and-restore.md"
+install -m 644 \
+ "$project_dir/docs/adr/0033-release-packaging.md" \
+ "$root/share/doc/tit/adr/0033-release-packaging.md"
+install -m 644 "$project_dir/release/examples/Caddyfile" "$root/share/doc/tit/examples/Caddyfile"
+install -m 644 \
+ "$project_dir/release/examples/tit.service" \
+ "$root/share/doc/tit/examples/tit.service"
+install -m 644 \
+ "$project_dir/release/examples/com.tit-cde.tit.plist" \
+ "$root/share/doc/tit/examples/com.tit-cde.tit.plist"
+install -m 755 \
+ "$project_dir/release/examples/tit.freebsd" \
+ "$root/share/doc/tit/examples/tit.freebsd"
+install -m 755 \
+ "$project_dir/release/examples/tit.openbsd" \
+ "$root/share/doc/tit/examples/tit.openbsd"
+install -m 755 \
+ "$project_dir/release/examples/tit.netbsd" \
+ "$root/share/doc/tit/examples/tit.netbsd"
+install -m 644 "$project_dir/release/man/tit.1" "$root/share/man/man1/tit.1"
+install -m 644 \
+ "$project_dir/release/completions/tit.bash" \
+ "$root/share/bash-completion/completions/tit"
+install -m 644 \
+ "$project_dir/release/completions/_tit" \
+ "$root/share/zsh/site-functions/_tit"
+install -m 644 \
+ "$project_dir/release/completions/tit.fish" \
+ "$root/share/fish/vendor_completions.d/tit.fish"
+
+(CDPATH= cd "$stage" && tar -czf "$output_dir/$archive" "$package")
+
+if command -v sha256sum >/dev/null 2>&1; then
+ (CDPATH= cd "$output_dir" && sha256sum "$archive" >"$checksum")
+elif command -v shasum >/dev/null 2>&1; then
+ (CDPATH= cd "$output_dir" && shasum -a 256 "$archive" >"$checksum")
+elif command -v sha256 >/dev/null 2>&1; then
+ digest=$(sha256 -q "$output_dir/$archive")
+ printf '%s %s\n' "$digest" "$archive" >"$output_dir/$checksum"
+else
+ echo "tit: no SHA-256 command is available" >&2
+ exit 1
+fi
+
+printf '%s\n%s\n' "$output_dir/$archive" "$output_dir/$checksum"
scripts/verify-release-artifact
Mode → 100755; object → 398d14f853cd
@@ -1,0 +1,150 @@
+#!/bin/sh
+set -eu
+
+if [ "$#" -ne 2 ]; then
+ echo "usage: verify-release-artifact ARCHIVE CHECKSUM" >&2
+ exit 2
+fi
+
+archive=$1
+checksum=$2
+archive_dir=$(CDPATH= cd "$(dirname "$archive")" && pwd)
+archive_name=$(basename "$archive")
+checksum_name=$(basename "$checksum")
+
+if command -v sha256sum >/dev/null 2>&1; then
+ (CDPATH= cd "$archive_dir" && sha256sum -c "$checksum_name")
+elif command -v shasum >/dev/null 2>&1; then
+ (CDPATH= cd "$archive_dir" && shasum -a 256 -c "$checksum_name")
+elif command -v sha256 >/dev/null 2>&1; then
+ expected=$(awk '{ print $1 }' "$checksum")
+ actual=$(sha256 -q "$archive")
+ [ "$actual" = "$expected" ] || {
+ echo "tit: the release checksum does not match" >&2
+ exit 1
+ }
+else
+ echo "tit: no SHA-256 command is available" >&2
+ exit 1
+fi
+
+work=$(mktemp -d "${TMPDIR:-/tmp}/tit-verify.XXXXXX")
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+list="$work/archive.list"
+tar -tzf "$archive" >"$list"
+root=$(sed -n '1{s:/*$::;p;}' "$list")
+case "$root" in
+ tit-*-*) ;;
+ *) echo "tit: the release archive root is not valid" >&2; exit 1 ;;
+esac
+case "$root" in
+ *[!0-9A-Za-z_.-]*) echo "tit: the release archive root is not valid" >&2; exit 1 ;;
+esac
+while IFS= read -r path; do
+ case "$path" in
+ "$root" | "$root"/*) ;;
+ *) echo "tit: the release archive has an unsafe path" >&2; exit 1 ;;
+ esac
+ case "$path" in
+ /* | *"/../"* | *"/.." | ../*) echo "tit: the release archive has an unsafe path" >&2; exit 1 ;;
+ esac
+done <"$list"
+tar -xzf "$archive" -C "$work"
+package="$work/$root"
+binary="$package/bin/tit"
+
+for required in \
+ "$binary" \
+ "$package/LICENSE" \
+ "$package/README.md" \
+ "$package/etc/tit/config.toml" \
+ "$package/share/doc/tit/install.md" \
+ "$package/share/doc/tit/upgrade.md" \
+ "$package/share/doc/tit/disaster-recovery.md" \
+ "$package/share/doc/tit/examples/Caddyfile" \
+ "$package/share/man/man1/tit.1" \
+ "$package/share/bash-completion/completions/tit" \
+ "$package/share/zsh/site-functions/_tit" \
+ "$package/share/fish/vendor_completions.d/tit.fish"
+do
+ [ -f "$required" ] || {
+ echo "tit: a required release file is missing: $required" >&2
+ exit 1
+ }
+done
+[ -x "$binary" ] || {
+ echo "tit: the release executable is not executable" >&2
+ exit 1
+}
+
+empty_path="$work/empty-path"
+home="$work/home"
+instance="$work/instance"
+restored="$work/restored"
+mkdir -m 700 "$empty_path" "$home" "$instance" "$restored"
+config="$instance/config.toml"
+http_port=$((30000 + ($$ % 10000)))
+ssh_port=$((http_port + 1))
+cat >"$config" <<EOF
+version = 1
+public_url = "http://127.0.0.1:$http_port/"
+
+[http]
+listen = "127.0.0.1:$http_port"
+
+[ssh]
+listen = "127.0.0.1:$ssh_port"
+public_host = "127.0.0.1"
+public_port = $ssh_port
+EOF
+chmod 600 "$config"
+
+key='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJgOfb9397gwZRfsz2A6DN/VVw/+bdjMdVsJ89JDLAC0 tit-release-verification'
+setup_output="$work/setup.out"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+ "$binary" --config "$config" setup admin release-test "$key" >"$setup_output"
+grep -q '^Recovery code: tit-recovery-v1:' "$setup_output"
+
+server_log="$work/server.log"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+ "$binary" --config "$config" serve 2>"$server_log" &
+server_pid=$!
+sleep 1
+if ! kill -0 "$server_pid" 2>/dev/null; then
+ wait "$server_pid" || true
+ echo "tit: the release server did not start" >&2
+ sed -n '1,80p' "$server_log" >&2
+ exit 1
+fi
+kill -TERM "$server_pid"
+wait "$server_pid"
+
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+ "$binary" --config "$config" doctor
+backup="$work/tit-backup.tar"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+ "$binary" --config "$config" backup "$backup"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+ "$binary" --config "$config" doctor --backup "$backup"
+
+: >"$instance/tit.sqlite3"
+if env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+ "$binary" --config "$config" doctor >/dev/null 2>&1
+then
+ echo "tit: doctor accepted the damaged disposable instance" >&2
+ exit 1
+fi
+
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+ "$binary" restore "$backup" "$restored"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+ "$binary" --config "$restored/config.toml" doctor
+
+recovery=$(sed -n 's/^Recovery code: //p' "$setup_output")
+if grep -F "$recovery" "$server_log" >/dev/null; then
+ echo "tit: the server log contains a recovery credential" >&2
+ exit 1
+fi
+
+version=$("$binary" --version)
+printf 'verified %s from %s without commands in PATH\n' "$version" "$archive_name"
src/http/feeds.rs
Mode 100644 → 100644; object dbec8adff1c2 → 6cd94355ff00
@@ -65,6 +65,7 @@
StatusCode::OK,
&FeedTokensTemplate {
request_id: &request_id.0,
+ signed_in: true,
csrf: &csrf,
tokens: tokens.iter().map(token_view).collect(),
},
@@ -244,6 +245,7 @@
StatusCode::CREATED,
&IssuedFeedTokenTemplate {
request_id,
+ signed_in: true,
token: &issued.token,
scope: scope_label(&issued.record.scope),
target: token_target(&issued.record),
@@ -354,6 +356,7 @@
#[template(path = "feed-tokens.html")]
struct FeedTokensTemplate<'a> {
request_id: &'a str,
+ signed_in: bool,
csrf: &'a str,
tokens: Vec<FeedTokenView<'a>>,
}
@@ -362,6 +365,7 @@
#[template(path = "feed-token-issued.html")]
struct IssuedFeedTokenTemplate<'a> {
request_id: &'a str,
+ signed_in: bool,
token: &'a str,
scope: &'static str,
target: String,
src/http/issues.rs
Mode 100644 → 100644; object 2637940e6f76 → 0c098a44c454
@@ -77,6 +77,7 @@
StatusCode::OK,
&IssueListTemplate {
request_id: &request_id.0,
+ signed_in: authenticated,
owner: &record.owner,
repository: &record.slug,
issues: issues
@@ -360,6 +361,7 @@
StatusCode::OK,
&IssueTemplate {
request_id,
+ signed_in: !csrf.is_empty(),
owner: &detail.repository.owner,
repository: &detail.repository.slug,
number: detail.issue.number,
@@ -508,6 +510,7 @@
#[template(path = "issues.html")]
struct IssueListTemplate<'a> {
request_id: &'a str,
+ signed_in: bool,
owner: &'a str,
repository: &'a str,
issues: Vec<IssueListItem<'a>>,
@@ -527,6 +530,7 @@
#[template(path = "issue.html")]
struct IssueTemplate<'a> {
request_id: &'a str,
+ signed_in: bool,
owner: &'a str,
repository: &'a str,
number: i64,
src/http/metadata_search.rs
Mode 100644 → 100644; object 223673f2bc36 → 021d0b835bd7
@@ -21,8 +21,9 @@
Extension(actor): Extension<RequestActor>,
Query(query): Query<SearchQuery>,
) -> Response {
+ let signed_in = actor.0.is_some();
let Some(value) = query.q else {
- return search_page(&request_id.0, "", None);
+ return search_page(&request_id.0, "", None, signed_in);
};
let Some(service) = state.search.clone() else {
return search_internal(&request_id.0);
@@ -33,7 +34,7 @@
})
.await;
match result {
- Ok(outcome) => search_page(&request_id.0, &value, Some(outcome)),
+ Ok(outcome) => search_page(&request_id.0, &value, Some(outcome), signed_in),
Err(MetadataSearchError::InvalidQuery | MetadataSearchError::Auth(_)) => render_error(
StatusCode::BAD_REQUEST,
&request_id.0,
@@ -67,6 +68,7 @@
request_id: &str,
query: &str,
outcome: Option<crate::search::MetadataSearchOutcome>,
+ signed_in: bool,
) -> Response {
let searched = outcome.is_some();
let (rows_scanned, bytes_scanned, truncated, results) = outcome.map_or_else(
@@ -85,6 +87,7 @@
StatusCode::OK,
&MetadataSearchTemplate {
request_id,
+ signed_in,
query,
searched,
rows_scanned,
@@ -131,6 +134,7 @@
#[template(path = "metadata-search.html")]
struct MetadataSearchTemplate<'a> {
request_id: &'a str,
+ signed_in: bool,
query: &'a str,
searched: bool,
rows_scanned: usize,
src/http/mod.rs
Mode 100644 → 100644; object 0a4221f4d548 → f3af1d63554f
@@ -306,11 +306,7 @@
.merge(watches::routes())
.merge(issues::routes())
.merge(pull_requests::routes())
- .merge(public::routes())
- .layer(middleware::from_fn_with_state(
- state.clone(),
- repository_actor,
- ));
+ .merge(public::routes());
Router::new()
.route("/", get(home))
.route("/healthz", get(health))
@@ -349,7 +345,9 @@
)
.route(
"/logout",
- axum::routing::post(logout).layer(DefaultBodyLimit::max(1024)),
+ get(logout_form)
+ .post(logout)
+ .layer(DefaultBodyLimit::max(1024)),
)
.route("/assets/style.css", get(style))
.merge(feeds::routes())
@@ -357,6 +355,7 @@
.fallback(not_found)
.method_not_allowed_fallback(method_not_allowed)
.layer(RequestBodyLimitLayer::new(max_request_bytes))
+ .layer(middleware::from_fn_with_state(state.clone(), request_actor))
.layer(middleware::from_fn_with_state(state.clone(), request_guard))
.layer(middleware::from_fn_with_state(
state.clone(),
@@ -426,7 +425,7 @@
state.login_attempts.allow(peer.0)
}
-async fn repository_actor(
+async fn request_actor(
State(state): State<WebState>,
mut request: Request,
next: Next,
@@ -442,8 +441,51 @@
next.run(request).await
}
-async fn home(Extension(request_id): Extension<RequestId>) -> Response {
- render_home(StatusCode::OK, &request_id.0, "", "", "")
+async fn home(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
+) -> Response {
+ let signed_in = actor.0.is_some();
+ let username = actor.0.clone().unwrap_or_default();
+ if state.repositories.is_none() {
+ return render_home(
+ StatusCode::OK,
+ &request_id.0,
+ HomePage {
+ owner: "",
+ repository: "",
+ error: "",
+ signed_in,
+ username: &username,
+ repositories: &[],
+ },
+ );
+ }
+ let result = repository_job(state, move |repositories| {
+ repositories.home(actor.0.as_deref())
+ })
+ .await;
+ match result {
+ Ok(repositories) => render_home(
+ StatusCode::OK,
+ &request_id.0,
+ HomePage {
+ owner: "",
+ repository: "",
+ error: "",
+ signed_in,
+ username: &username,
+ repositories: &repositories,
+ },
+ ),
+ Err(_) => render_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ &request_id.0,
+ "Home error",
+ "The repository overview could not be completed.",
+ ),
+ }
}
async fn health(State(state): State<WebState>) -> Response {
@@ -466,6 +508,7 @@
async fn go_to_repository(
Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
RawQuery(query): RawQuery,
) -> Response {
match parse_location_query(query.as_deref()) {
@@ -481,18 +524,35 @@
Err(LocationQueryError { owner, repository }) => render_home(
StatusCode::BAD_REQUEST,
&request_id.0,
- &owner,
- &repository,
- "Enter a valid lowercase owner and repository.",
+ HomePage {
+ owner: &owner,
+ repository: &repository,
+ error: "Enter a valid lowercase owner and repository.",
+ signed_in: actor.0.is_some(),
+ username: actor.0.as_deref().unwrap_or_default(),
+ repositories: &[],
+ },
),
}
}
-async fn signup_form(Extension(request_id): Extension<RequestId>) -> Response {
+async fn signup_form(
+ Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
+) -> Response {
+ if actor.0.is_some() {
+ return account_redirect();
+ }
render_account_form(&request_id.0, AccountFormKind::Signup, "", "")
}
-async fn recovery_form(Extension(request_id): Extension<RequestId>) -> Response {
+async fn recovery_form(
+ Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
+) -> Response {
+ if actor.0.is_some() {
+ return account_redirect();
+ }
render_account_form(&request_id.0, AccountFormKind::Recovery, "", "")
}
@@ -560,7 +620,13 @@
account_result(result, &request_id.0, AccountFormKind::Recovery, &username)
}
-async fn login_form(Extension(request_id): Extension<RequestId>) -> Response {
+async fn login_form(
+ Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
+) -> Response {
+ if actor.0.is_some() {
+ return account_redirect();
+ }
render(
StatusCode::OK,
&LoginTemplate {
@@ -568,6 +634,7 @@
username: "",
error: "",
has_error: false,
+ signed_in: false,
},
)
}
@@ -604,6 +671,7 @@
public_key: &challenge.public_key,
challenge: &challenge.challenge,
login_csrf: &challenge.login_csrf,
+ signed_in: false,
},
);
append_cookie(
@@ -865,6 +933,7 @@
username: &session.username,
administrator: session.is_administrator,
csrf: &csrf,
+ signed_in: true,
},
),
Err(_) => login_redirect(true),
@@ -964,6 +1033,35 @@
fn repository_form_error(request_id: &str, status: StatusCode, message: &str) -> Response {
render_error(status, request_id, "Repository error", message)
+}
+
+async fn logout_form(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ headers: HeaderMap,
+) -> Response {
+ let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+ return login_redirect(false);
+ };
+ let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+ return login_redirect(true);
+ };
+ let csrf_for_auth = csrf.clone();
+ let result = login_job(state, move |login| {
+ login.authenticate(&session_token, Some(&csrf_for_auth))
+ })
+ .await;
+ match result {
+ Ok(_) => render(
+ StatusCode::OK,
+ &LogoutTemplate {
+ request_id: &request_id.0,
+ csrf: &csrf,
+ signed_in: true,
+ },
+ ),
+ Err(_) => login_redirect(true),
+ }
}
async fn logout(
@@ -1155,6 +1253,15 @@
response
}
+fn account_redirect() -> Response {
+ Response::builder()
+ .status(StatusCode::SEE_OTHER)
+ .header(header::LOCATION, "/account")
+ .header(header::CACHE_CONTROL, "no-store")
+ .body(Body::empty())
+ .expect("the account redirect is valid")
+}
+
fn login_error(request_id: &str, username: &str, error: &str) -> Response {
render(
StatusCode::BAD_REQUEST,
@@ -1163,6 +1270,7 @@
username,
error,
has_error: true,
+ signed_in: false,
},
)
}
@@ -1222,6 +1330,7 @@
&RecoveryCodeTemplate {
request_id,
recovery: &recovery,
+ signed_in: false,
},
),
Err(AccountError::Store(StoreError::UsernameUnavailable(_))) => render_account_error(
@@ -1310,6 +1419,7 @@
error,
has_error: !error.is_empty(),
recovery: matches!(kind, AccountFormKind::Recovery),
+ signed_in: false,
},
)
}
@@ -1355,8 +1465,8 @@
"This page does not accept the request method.",
);
let allow = match uri.path() {
- "/signup" | "/recover" | "/login" => "GET, HEAD, POST",
- "/login/verify" | "/login/verify-file" | "/logout" => "POST",
+ "/signup" | "/recover" | "/login" | "/logout" => "GET, HEAD, POST",
+ "/login/verify" | "/login/verify-file" => "POST",
path if path.ends_with("/git-upload-pack") => "POST",
_ => "GET, HEAD",
};
@@ -1446,23 +1556,38 @@
}
}
-fn render_home(
- status: StatusCode,
- request_id: &str,
- owner: &str,
- repository: &str,
- error: &str,
-) -> Response {
+fn render_home(status: StatusCode, request_id: &str, page: HomePage<'_>) -> Response {
render(
status,
&HomeTemplate {
request_id,
- owner,
- repository,
- error,
- has_error: !error.is_empty(),
+ owner: page.owner,
+ repository: page.repository,
+ error: page.error,
+ has_error: !page.error.is_empty(),
+ signed_in: page.signed_in,
+ username: page.username,
+ repositories: page
+ .repositories
+ .iter()
+ .map(|repository| HomeRepositoryView {
+ owner: &repository.owner,
+ slug: &repository.slug,
+ visibility: &repository.visibility,
+ updated_at: repository.updated_at,
+ })
+ .collect(),
},
)
+}
+
+struct HomePage<'a> {
+ owner: &'a str,
+ repository: &'a str,
+ error: &'a str,
+ signed_in: bool,
+ username: &'a str,
+ repositories: &'a [crate::store::HomeRepositoryRecord],
}
fn render_error(status: StatusCode, request_id: &str, heading: &str, message: &str) -> Response {
@@ -1472,6 +1597,7 @@
request_id,
status: heading,
message,
+ signed_in: false,
},
)
}
@@ -1550,6 +1676,16 @@
repository: &'a str,
error: &'a str,
has_error: bool,
+ signed_in: bool,
+ username: &'a str,
+ repositories: Vec<HomeRepositoryView<'a>>,
+}
+
+struct HomeRepositoryView<'a> {
+ owner: &'a str,
+ slug: &'a str,
+ visibility: &'a str,
+ updated_at: i64,
}
#[derive(Template)]
@@ -1558,6 +1694,7 @@
request_id: &'a str,
status: &'a str,
message: &'a str,
+ signed_in: bool,
}
#[derive(Template)]
@@ -1568,6 +1705,7 @@
error: &'a str,
has_error: bool,
recovery: bool,
+ signed_in: bool,
}
#[derive(Template)]
@@ -1575,6 +1713,7 @@
struct RecoveryCodeTemplate<'a> {
request_id: &'a str,
recovery: &'a str,
+ signed_in: bool,
}
#[derive(Template)]
@@ -1584,6 +1723,7 @@
username: &'a str,
error: &'a str,
has_error: bool,
+ signed_in: bool,
}
#[derive(Template)]
@@ -1594,6 +1734,7 @@
public_key: &'a str,
challenge: &'a str,
login_csrf: &'a str,
+ signed_in: bool,
}
#[derive(Template)]
@@ -1603,6 +1744,15 @@
username: &'a str,
administrator: bool,
csrf: &'a str,
+ signed_in: bool,
+}
+
+#[derive(Template)]
+#[template(path = "logout.html")]
+struct LogoutTemplate<'a> {
+ request_id: &'a str,
+ csrf: &'a str,
+ signed_in: bool,
}
#[derive(Debug, Error)]
src/http/public.rs
Mode 100644 → 100644; object 795cd40715cc → 5a8c593a84f9
@@ -445,6 +445,7 @@
let Some(web) = state.public else {
return route_error(RouteError::NotFound, &request_id.0);
};
+ let signed_in = actor.0.is_some();
let clone_urls = web.clone_urls(&path.owner, &path.repository);
let result = web
.read(
@@ -475,7 +476,7 @@
},
)
.await;
- render_page(result, &request_id.0)
+ render_page(result, &request_id.0, signed_in)
}
async fn refs(
@@ -487,6 +488,7 @@
let Some(web) = state.public else {
return route_error(RouteError::NotFound, &request_id.0);
};
+ let signed_in = actor.0.is_some();
let result = web
.read(actor.0, path.owner, path.repository, |record, service| {
let cancellation = ReadCancellation::default();
@@ -494,7 +496,7 @@
Ok(RepositoryPage::refs(record, references))
})
.await;
- render_page(result, &request_id.0)
+ render_page(result, &request_id.0, signed_in)
}
async fn search(
@@ -512,6 +514,7 @@
}) {
return route_error(RouteError::InvalidRequest, &request_id.0);
}
+ let signed_in = actor.0.is_some();
let result = web
.read(
actor.0,
@@ -538,7 +541,7 @@
},
)
.await;
- render_page(result, &request_id.0)
+ render_page(result, &request_id.0, signed_in)
}
async fn commit(
@@ -554,6 +557,7 @@
Ok(id) => id,
Err(error) => return route_error(error, &request_id.0),
};
+ let signed_in = actor.0.is_some();
let result = web
.read(
actor.0,
@@ -567,7 +571,7 @@
},
)
.await;
- render_page(result, &request_id.0)
+ render_page(result, &request_id.0, signed_in)
}
async fn diff(
@@ -583,6 +587,7 @@
(Ok(old), Ok(new)) => (old, new),
_ => return route_error(RouteError::NotFound, &request_id.0),
};
+ let signed_in = actor.0.is_some();
let result = web
.read(
actor.0,
@@ -597,7 +602,7 @@
},
)
.await;
- render_page(result, &request_id.0)
+ render_page(result, &request_id.0, signed_in)
}
async fn tree_root(
@@ -636,6 +641,7 @@
Ok(id) => id,
Err(error) => return route_error(error, &request_id.0),
};
+ let signed_in = actor.0.is_some();
let result = web
.read(
actor.0,
@@ -649,7 +655,7 @@
},
)
.await;
- render_page(result, &request_id.0)
+ render_page(result, &request_id.0, signed_in)
}
async fn blob(
@@ -669,6 +675,7 @@
Ok(id) => id,
Err(error) => return route_error(error, &request_id.0),
};
+ let signed_in = actor.0.is_some();
let result = web
.read(
actor.0,
@@ -682,7 +689,7 @@
},
)
.await;
- render_page(result, &request_id.0)
+ render_page(result, &request_id.0, signed_in)
}
async fn raw(
@@ -751,6 +758,7 @@
Ok(id) => id,
Err(error) => return route_error(error, &request_id.0),
};
+ let signed_in = actor.0.is_some();
let result = web
.read(
actor.0,
@@ -767,7 +775,7 @@
},
)
.await;
- render_page(result, &request_id.0)
+ render_page(result, &request_id.0, signed_in)
}
async fn archive(
@@ -896,10 +904,15 @@
}
}
-fn render_page(result: Result<RepositoryPage, RouteError>, request_id: &str) -> Response {
+fn render_page(
+ result: Result<RepositoryPage, RouteError>,
+ request_id: &str,
+ signed_in: bool,
+) -> Response {
match result {
Ok(mut page) => {
page.request_id = request_id.to_owned();
+ page.signed_in = signed_in;
match page.render() {
Ok(body) => Response::builder()
.status(StatusCode::OK)
@@ -1232,6 +1245,7 @@
#[template(path = "repository.html")]
struct RepositoryPage {
request_id: String,
+ signed_in: bool,
owner: String,
repository: String,
object_format: String,
@@ -1271,6 +1285,7 @@
fn base(record: RepositoryRecord, page_kind: &'static str, title: String) -> Self {
Self {
request_id: String::new(),
+ signed_in: false,
owner: record.owner,
repository: record.slug,
object_format: record.object_format,
src/http/pull_requests.rs
Mode 100644 → 100644; object 7258de976227 → d8c57d419770
@@ -55,6 +55,7 @@
};
let owner = path.owner.clone();
let repository = path.repository.clone();
+ let signed_in = actor.0.is_some();
let result = job(state, move || {
service.list(&owner, &repository, actor.0.as_deref())
})
@@ -66,6 +67,7 @@
StatusCode::OK,
&PullRequestListTemplate {
request_id: &request_id.0,
+ signed_in,
owner: &record.owner,
repository: &record.slug,
pull_requests: pull_requests
@@ -100,6 +102,7 @@
};
let owner = path.owner.clone();
let repository = path.repository.clone();
+ let signed_in = actor.0.is_some();
let result = job(state, move || {
service.compare(
&owner,
@@ -119,6 +122,7 @@
StatusCode::OK,
&PullRequestTemplate {
request_id: &request_id.0,
+ signed_in,
owner: &detail.repository.owner,
repository: &detail.repository.slug,
pull_request,
@@ -505,6 +509,7 @@
#[template(path = "pull_requests.html")]
struct PullRequestListTemplate<'a> {
request_id: &'a str,
+ signed_in: bool,
owner: &'a str,
repository: &'a str,
pull_requests: Vec<PullRequestListItem<'a>>,
@@ -524,6 +529,7 @@
#[template(path = "pull_request.html")]
struct PullRequestTemplate<'a> {
request_id: &'a str,
+ signed_in: bool,
owner: &'a str,
repository: &'a str,
pull_request: &'a crate::store::PullRequestRecord,
src/http/watches.rs
Mode 100644 → 100644; object 7400db49440e → 012a82e0512d
@@ -59,6 +59,7 @@
StatusCode::OK,
&WatchTemplate {
request_id: &request_id.0,
+ signed_in: authenticated,
owner: &record.owner,
repository: &record.slug,
csrf: &csrf,
@@ -198,6 +199,7 @@
#[template(path = "watch.html")]
struct WatchTemplate<'a> {
request_id: &'a str,
+ signed_in: bool,
owner: &'a str,
repository: &'a str,
csrf: &'a str,
src/repository.rs
Mode 100644 → 100644; object b17083f5db65 → bfd8fb2bf008
@@ -11,8 +11,11 @@
use crate::git::repository::{GitRepository, GitRepositoryError};
use crate::maintenance::MaintenanceGate;
use crate::store::{
- NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord, Store, StoreError,
+ HomeRepositoryRecord, NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord, Store,
+ StoreError,
};
+
+const HOME_REPOSITORY_LIMIT: usize = 20;
#[derive(Clone)]
pub(crate) struct RepositoryService {
@@ -46,6 +49,13 @@
correlation_id: &str,
) -> Result<RepositoryRecord, RepositoryServiceError> {
self.create(actor, actor, slug, object_format, correlation_id)
+ }
+
+ pub(crate) fn home(
+ &self,
+ owner: Option<&str>,
+ ) -> Result<Vec<HomeRepositoryRecord>, RepositoryServiceError> {
+ Ok(Store::open(&self.database)?.home_repositories(owner, HOME_REPOSITORY_LIMIT)?)
}
pub(crate) fn create_for_administrator(
src/ssh.rs
Mode 100644 → 100644; object 5406871dd451 → 5af100db1f04
@@ -28,6 +28,7 @@
use crate::telemetry::Telemetry;
const VERSION_COMMAND: &[u8] = b"tit --version";
+const HELP_COMMAND: &[u8] = b"help";
const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
const MAX_RECEIVE_PACK_BYTES: u64 = 128 * 1024 * 1024;
const MAX_REPOSITORY_COMMAND_BYTES: usize = 512;
@@ -42,6 +43,18 @@
const ISSUE_CREATE_USAGE: &str = "issue create OWNER/REPOSITORY [--output human|json]";
const PULL_REQUEST_CHECKOUT_USAGE: &str =
"pr checkout OWNER/REPOSITORY NUMBER [--output human|json]";
+const HELP_TEXT: &str = "\
+Available tit SSH commands:
+ help
+ tit --version
+ repo create NAME [--object-format sha1|sha256] [--output human|json]
+ issue list OWNER/REPOSITORY [--output human|json]
+ issue create OWNER/REPOSITORY [--output human|json]
+ pr checkout OWNER/REPOSITORY NUMBER [--output human|json]
+
+Git clients can also use git-upload-pack and git-receive-pack.
+";
+const HELP_GUIDANCE: &str = "Use 'help' to list the available commands.";
pub(crate) struct RunningSshServer {
address: SocketAddr,
@@ -580,6 +593,11 @@
session.exit_status_request(channel, 0)?;
session.eof(channel)?;
session.close(channel)?;
+ } else if command == HELP_COMMAND {
+ self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+ session.channel_success(channel)?;
+ session.data(channel, HELP_TEXT.as_bytes())?;
+ finish_git_channel(channel, 0, session)?;
} else if is_repository_command(command) {
self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
session.channel_success(channel)?;
@@ -702,9 +720,25 @@
}
}
} else {
- self.audit.rejected_exec.fetch_add(1, Ordering::Relaxed);
- session.channel_failure(channel)?;
- session.close(channel)?;
+ self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+ session.channel_success(channel)?;
+ if requests_json(command) {
+ session.data(
+ channel,
+ json_line(serde_json::json!({
+ "version": 1,
+ "status": "error",
+ "error": { "code": "invalid-command" },
+ })),
+ )?;
+ } else {
+ session.extended_data(
+ channel,
+ 1,
+ format!("tit: The command is not valid.\n{HELP_GUIDANCE}\n").into_bytes(),
+ )?;
+ }
+ finish_git_channel(channel, 1, session)?;
}
}
Ok(())
@@ -1083,7 +1117,9 @@
fn repository_command_error_message(error: &RepositoryCommandError) -> String {
match repository_command_error_code(error) {
- "invalid-command" => format!("usage: {REPOSITORY_CREATE_USAGE}"),
+ "invalid-command" => {
+ format!("usage: {REPOSITORY_CREATE_USAGE}\n{HELP_GUIDANCE}")
+ }
"repository-exists" => "A repository with this name already exists.".to_owned(),
"invalid-name" => "The repository name is not valid.".to_owned(),
"account-unavailable" => "The account is not active.".to_owned(),
@@ -1430,7 +1466,9 @@
fn issue_command_error_message(error: &IssueCommandError) -> String {
match issue_command_error_code(error) {
- "invalid-command" => format!("usage: {ISSUE_LIST_USAGE} or {ISSUE_CREATE_USAGE}"),
+ "invalid-command" => {
+ format!("usage: {ISSUE_LIST_USAGE} or {ISSUE_CREATE_USAGE}\n{HELP_GUIDANCE}")
+ }
"invalid-input" => {
"The first input line must be a valid title. The remaining input is the body."
.to_owned()
@@ -1604,7 +1642,9 @@
fn pull_request_command_error_message(error: &PullRequestCommandError) -> String {
match pull_request_command_error_code(error) {
- "invalid-command" => format!("usage: {PULL_REQUEST_CHECKOUT_USAGE}"),
+ "invalid-command" => {
+ format!("usage: {PULL_REQUEST_CHECKOUT_USAGE}\n{HELP_GUIDANCE}")
+ }
"pull-request-unavailable" => "The pull request is not available.".to_owned(),
"invalid-target" => "The pull-request target is not valid.".to_owned(),
"service-unavailable" => "The pull-request service is not available.".to_owned(),
src/store/mod.rs
Mode 100644 → 100644; object 82e53b0820df → 3dd39f7efb26
@@ -3190,6 +3190,39 @@
.map_err(Into::into)
}
+ pub(crate) fn home_repositories(
+ &self,
+ owner: Option<&str>,
+ limit: usize,
+ ) -> Result<Vec<HomeRepositoryRecord>, StoreError> {
+ let limit = i64::try_from(limit).expect("the home repository limit fits in SQLite");
+ let mut statement = self.connection.prepare(
+ "SELECT account.username, repository.slug, repository.visibility,
+ COALESCE(MAX(repository_event.created_at), repository.created_at)
+ FROM repository
+ JOIN account ON account.id = repository.owner_account_id
+ LEFT JOIN repository_event ON repository_event.repository_id = repository.id
+ WHERE repository.state = 'active'
+ AND ((?1 IS NULL AND repository.visibility = 'public')
+ OR account.username = ?1)
+ GROUP BY repository.id, account.username, repository.slug,
+ repository.visibility, repository.created_at
+ ORDER BY 4 DESC, account.username, repository.slug
+ LIMIT ?2",
+ )?;
+ statement
+ .query_map(rusqlite::params![owner, limit], |row| {
+ Ok(HomeRepositoryRecord {
+ owner: row.get(0)?,
+ slug: row.get(1)?,
+ visibility: row.get(2)?,
+ updated_at: row.get(3)?,
+ })
+ })?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
+ }
+
#[allow(
dead_code,
reason = "some integration tests import storage without public event pages"
@@ -3407,6 +3440,14 @@
pub(crate) object_format: String,
pub(crate) created_at: i64,
pub(crate) archived_at: Option<i64>,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+pub(crate) struct HomeRepositoryRecord {
+ pub(crate) owner: String,
+ pub(crate) slug: String,
+ pub(crate) visibility: String,
+ pub(crate) updated_at: i64,
}
#[derive(Debug, Serialize)]
templates/base.html
Mode 100644 → 100644; object 250279dbab58 → f7808adf24b9
@@ -13,9 +13,14 @@
<nav aria-label="Primary">
<a href="/">Home</a>
<a href="/search">Search</a>
+{% if signed_in %}
+ <a href="/account">Account</a>
+ <a href="/logout">Log out</a>
+{% else %}
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
<a href="/login">Log in</a>
+{% endif %}
</nav>
</header>
<main id="main">
templates/home.html
Mode 100644 → 100644; object 95483a140113 → da0d81525b54
@@ -1,7 +1,23 @@
{% extends "base.html" %}
-{% block title %}Repositories · tit{% endblock %}
+{% block title %}{% if signed_in %}{{ username }}{% else %}Repositories{% endif %} · tit{% endblock %}
{% block content %}
- <h1>Repositories</h1>
+{% if signed_in %}
+ <h1>{{ username }}</h1>
+ <p><a href="/account">Open your account profile</a>.</p>
+ <h2>Your repositories</h2>
+{% else %}
+ <h1>Recently updated public repositories</h1>
+{% endif %}
+{% if repositories.is_empty() %}
+ <p>No repositories are available.</p>
+{% else %}
+ <ul>
+ {% for item in repositories %}
+ <li><a href="/{{ item.owner }}/{{ item.slug }}">{{ item.owner }}/{{ item.slug }}</a> · {{ item.visibility }} · updated <time>{{ item.updated_at }}</time></li>
+ {% endfor %}
+ </ul>
+{% endif %}
+ <h2>Open a repository</h2>
<p>Enter an owner and a repository to open its public page.</p>
{% if has_error %}
<p class="error" role="alert">{{ error }}</p>
templates/issue.html
Mode 100644 → 100644; object ba864efceb81 → 3494e7a6aed3
@@ -3,15 +3,7 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
- <nav aria-label="Repository">
- <a href="/{{ owner }}/{{ repository }}">Summary</a>
- <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
- <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
- <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
- <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
- <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
- <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
- </nav>
+{% include "repository-nav.html" %}
</header>
<article>
templates/issues.html
Mode 100644 → 100644; object eac929adcef6 → eeb63fd6779e
@@ -3,20 +3,11 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
- <nav aria-label="Repository">
- <a href="/{{ owner }}/{{ repository }}">Summary</a>
- <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
- <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
- <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
- <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
- <a href="/{{ owner }}/{{ repository }}/issues/atom.xml">Issue Atom</a>
- <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a>
- <a href="/{{ owner }}/{{ repository }}/atom.xml">Activity Atom</a>
- <a href="/{{ owner }}/{{ repository }}/rss.xml">Activity RSS</a>
- </nav>
+{% include "repository-nav.html" %}
</header>
<h2>Issues</h2>
+ <p><a href="/{{ owner }}/{{ repository }}/issues/atom.xml">Issue Atom</a> · <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a></p>
{% if issues.is_empty() %}
<p>This repository has no issues.</p>
{% else %}
templates/logout.html
Mode → 100644; object → b915f353613d
@@ -1,0 +1,10 @@
+{% extends "base.html" %}
+{% block title %}Log out · tit{% endblock %}
+{% block content %}
+ <h1>Log out</h1>
+ <p>Log out of all browser sessions for this account?</p>
+ <form method="post" action="/logout">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <button type="submit">Log out</button>
+ </form>
+{% endblock %}
templates/pull_request.html
Mode 100644 → 100644; object a4c51e881f4b → ae8ff3c78b58
@@ -3,13 +3,7 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
- <nav aria-label="Repository">
- <a href="/{{ owner }}/{{ repository }}">Summary</a>
- <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
- <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
- <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
- <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
- </nav>
+{% include "repository-nav.html" %}
</header>
<article>
templates/pull_requests.html
Mode 100644 → 100644; object 8a8f2a52666a → 15154b9a98d2
@@ -3,13 +3,7 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
- <nav aria-label="Repository">
- <a href="/{{ owner }}/{{ repository }}">Summary</a>
- <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
- <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
- <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
- <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
- </nav>
+{% include "repository-nav.html" %}
</header>
<h2>Pull requests</h2>
templates/repository-nav.html
Mode → 100644; object → 22e8d70cf837
@@ -1,0 +1,10 @@
+ <nav aria-label="Repository">
+ <a href="/{{ owner }}/{{ repository }}">Summary</a>
+ <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+ <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+ <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
+ <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
+ <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
+ <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
+ <a href="/{{ owner }}/{{ repository }}/search">Search</a>
+ </nav>
templates/repository.html
Mode 100644 → 100644; object 3ac49b6e683a → c893168b491c
@@ -3,22 +3,12 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
- <nav aria-label="Repository">
- <a href="/{{ owner }}/{{ repository }}">Summary</a>
- <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
- <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
- <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
- <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
- <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
- <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
- <a href="/{{ owner }}/{{ repository }}/search">Search</a>
-{% if has_head %}
- <a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Tree</a>
- <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Commit</a>
- <a href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar">Archive</a>
-{% endif %}
- </nav>
+{% include "repository-nav.html" %}
</header>
+
+{% if has_head %}
+ <p><a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Browse tree</a> · <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Show commit</a> · <a href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar">Download archive</a></p>
+{% endif %}
{% if page_kind == "summary" %}
<section aria-labelledby="clone-heading">
templates/watch.html
Mode 100644 → 100644; object 149f5b9092c3 → 895db9ce7cae
@@ -3,15 +3,7 @@
{% block content %}
<header class="repository-header">
<h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
- <nav aria-label="Repository">
- <a href="/{{ owner }}/{{ repository }}">Summary</a>
- <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
- <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
- <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
- <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
- <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
- <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
- </nav>
+{% include "repository-nav.html" %}
</header>
<h2>Watch preferences</h2>
tests/public_routes.rs
Mode 100644 → 100644; object 176d62bd7508 → 899df2e2e7d7
@@ -109,6 +109,7 @@
let summary = request(server.address(), "GET", "/alice/example", &[], &[]);
assert_eq!(summary.status, 200);
assert_html_policy(&summary);
+ assert_repository_navigation(&summary, "alice", "example");
let summary_text = summary.text();
assert!(summary_text.contains("<h1><a href=\"/alice/example\">alice/example</a></h1>"));
assert!(summary_text.contains("https://tit.example/alice/example"));
@@ -127,7 +128,6 @@
assert!(summary_text.contains("/alice/example/atom.xml"));
assert!(summary_text.contains("/alice/example/rss.xml"));
assert!(summary_text.contains("/alice/example/search"));
-
let mut feed_entry_ids = Vec::new();
for (path, content_type) in [
(
@@ -631,6 +631,7 @@
let anonymous = request(server.address(), "GET", "/alice/example/issues", &[], &[]);
assert_eq!(anonymous.status, 200);
+ assert_repository_navigation(&anonymous, "alice", "example");
assert!(anonymous.text().contains("This repository has no issues."));
assert!(!anonymous.text().contains("Create an issue</h2>"));
@@ -750,6 +751,7 @@
let anonymous_pull_requests =
request(server.address(), "GET", "/alice/example/pulls", &[], &[]);
assert_eq!(anonymous_pull_requests.status, 200);
+ assert_repository_navigation(&anonymous_pull_requests, "alice", "example");
assert!(
anonymous_pull_requests
.text()
@@ -990,6 +992,7 @@
let anonymous_watch = request(server.address(), "GET", "/alice/example/watch", &[], &[]);
assert_eq!(anonymous_watch.status, 200);
+ assert_repository_navigation(&anonymous_watch, "alice", "example");
assert!(
anonymous_watch
.text()
@@ -1510,6 +1513,26 @@
assert_eq!(response.header("referrer-policy"), "no-referrer");
assert_eq!(response.header("cache-control"), "no-store");
assert_eq!(response.header("x-request-id").len(), 32);
+}
+
+fn assert_repository_navigation(response: &HttpResponse, owner: &str, repository: &str) {
+ let text = response.text();
+ for suffix in [
+ "",
+ "/refs",
+ "/issues",
+ "/pulls",
+ "/watch",
+ "/atom.xml",
+ "/rss.xml",
+ "/search",
+ ] {
+ let link = format!("/{owner}/{repository}{suffix}");
+ assert!(
+ text.contains(&format!("href=\"{link}\"")),
+ "repository navigation is missing {link}"
+ );
+ }
}
struct HttpResponse {
tests/serve.rs
Mode 100644 → 100644; object aa9ce9ead824 → 60d47e121da0
@@ -4,6 +4,7 @@
)]
mod support;
+use std::env;
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
@@ -79,7 +80,7 @@
let backup_directory = TempDir::new().expect("create a backup directory");
let backup = backup_directory.path().join("instance.tar");
- let backup_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+ let backup_output = Command::new(tit_binary())
.args([
"--config",
config.to_str().expect("a UTF-8 configuration path"),
@@ -106,7 +107,7 @@
let restored = TempDir::new().expect("create a restore target");
fs::set_permissions(restored.path(), fs::Permissions::from_mode(0o700))
.expect("make the restore target private");
- let restore_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+ let restore_output = Command::new(tit_binary())
.args([
"restore",
backup.to_str().expect("a UTF-8 backup path"),
@@ -144,7 +145,7 @@
assert!(restored_readme.status.success());
assert_eq!(restored_readme.stdout, b"serve fixture\n");
- let second = Command::new(env!("CARGO_BIN_EXE_tit"))
+ let second = Command::new(tit_binary())
.args([
"--config",
config.to_str().expect("a UTF-8 configuration path"),
@@ -164,6 +165,17 @@
& 0o777,
0o600
);
+
+ let anonymous_home = http_get(http, "/");
+ assert!(
+ anonymous_home.contains("Recently updated public repositories"),
+ "anonymous home response:\n{anonymous_home}"
+ );
+ assert!(anonymous_home.contains(">alice/example</a>"));
+ assert!(anonymous_home.contains("<a href=\"/signup\">Create account</a>"));
+ assert!(anonymous_home.contains("<a href=\"/recover\">Recover account</a>"));
+ assert!(anonymous_home.contains("<a href=\"/login\">Log in</a>"));
+ assert!(!anonymous_home.contains("<a href=\"/account\">Account</a>"));
let login_challenge = http_form(
http,
@@ -213,8 +225,31 @@
let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
assert!(account.starts_with("HTTP/1.1 200"));
assert!(account.contains("<dd>alice</dd>"));
+ assert!(account.contains("<a href=\"/account\">Account</a>"));
+ assert!(account.contains("<a href=\"/logout\">Log out</a>"));
+ assert!(!account.contains("<a href=\"/login\">Log in</a>"));
+ assert!(!account.contains("<a href=\"/signup\">Create account</a>"));
+ assert!(!account.contains("<a href=\"/recover\">Recover account</a>"));
assert!(account.contains("action=\"/account/repositories\""));
+ for path in ["/login", "/signup", "/recover"] {
+ let response = http_get_with_headers(http, path, &[("Cookie", &cookies)]);
+ assert!(response.starts_with("HTTP/1.1 303"));
+ assert_eq!(response_header(&response, "location"), "/account");
+ }
+ let signed_in_home = http_get_with_headers(http, "/", &[("Cookie", &cookies)]);
+ assert!(signed_in_home.contains("<h1>alice</h1>"));
+ assert!(signed_in_home.contains("<h2>Your repositories</h2>"));
+ assert!(signed_in_home.contains(">alice/example</a>"));
+ assert!(signed_in_home.contains("<a href=\"/account\">Account</a>"));
+ assert!(signed_in_home.contains("<a href=\"/logout\">Log out</a>"));
+ assert!(!signed_in_home.contains("<a href=\"/signup\">Create account</a>"));
+ assert!(!signed_in_home.contains("<a href=\"/recover\">Recover account</a>"));
+ assert!(!signed_in_home.contains("<a href=\"/login\">Log in</a>"));
let csrf = cookie_value(&cookies, "tit-csrf");
+ let logout_page = http_get_with_headers(http, "/logout", &[("Cookie", &cookies)]);
+ assert!(logout_page.starts_with("HTTP/1.1 200"));
+ assert!(logout_page.contains("<form method=\"post\" action=\"/logout\">"));
+ assert!(logout_page.contains(&format!("name=\"csrf\" value=\"{csrf}\"")));
let rejected_repository = http_form_with_headers(
http,
"/account/repositories",
@@ -326,7 +361,7 @@
.expect("make the repository public");
drop(database);
- let invitation_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+ let invitation_output = Command::new(tit_binary())
.args([
"--config",
config.to_str().expect("a UTF-8 configuration path"),
@@ -485,7 +520,7 @@
b"serve fixture\n"
);
- let locked = Command::new(env!("CARGO_BIN_EXE_tit"))
+ let locked = Command::new(tit_binary())
.args([
"--config",
config.to_str().expect("a UTF-8 configuration path"),
@@ -1335,7 +1370,7 @@
}
fn spawn_server(config: &Path) -> ChildGuard {
- let child = Command::new(env!("CARGO_BIN_EXE_tit"))
+ let child = Command::new(tit_binary())
.args([
"--config",
config.to_str().expect("a UTF-8 configuration path"),
@@ -1391,9 +1426,9 @@
fn command<const N: usize>(directory: &Path, arguments: [&str; N]) {
let executable = if matches!(arguments.first(), Some(&"--config")) {
- env!("CARGO_BIN_EXE_tit")
+ tit_binary()
} else {
- "git"
+ "git".into()
};
let output = Command::new(executable)
.args(arguments)
@@ -1405,6 +1440,12 @@
"fixture command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
+}
+
+fn tit_binary() -> std::path::PathBuf {
+ env::var_os("TIT_RELEASE_BINARY")
+ .map(Into::into)
+ .unwrap_or_else(|| env!("CARGO_BIN_EXE_tit").into())
}
fn wait_for_listener(address: SocketAddr, server: &mut ChildGuard) {
tests/snapshots/web/bad-request.html
Mode 100644 → 100644; object ff6cd3e01450 → 93583b049d5f
@@ -13,14 +13,22 @@
<nav aria-label="Primary">
<a href="/">Home</a>
<a href="/search">Search</a>
+
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
<a href="/login">Log in</a>
+
</nav>
</header>
<main id="main">
- <h1>Repositories</h1>
+
+ <h1>Recently updated public repositories</h1>
+
+
+ <p>No repositories are available.</p>
+
+ <h2>Open a repository</h2>
<p>Enter an owner and a repository to open its public page.</p>
<p class="error" role="alert">Enter a valid lowercase owner and repository.</p>
tests/snapshots/web/home.html
Mode 100644 → 100644; object 1a9666ebe846 → 5a39ae763eba
@@ -13,14 +13,22 @@
<nav aria-label="Primary">
<a href="/">Home</a>
<a href="/search">Search</a>
+
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
<a href="/login">Log in</a>
+
</nav>
</header>
<main id="main">
- <h1>Repositories</h1>
+
+ <h1>Recently updated public repositories</h1>
+
+
+ <p>No repositories are available.</p>
+
+ <h2>Open a repository</h2>
<p>Enter an owner and a repository to open its public page.</p>
<form action="/go" method="get">
tests/snapshots/web/method-not-allowed.html
Mode 100644 → 100644; object b7bab2ebc3c5 → 5619be0017f8
@@ -13,9 +13,11 @@
<nav aria-label="Primary">
<a href="/">Home</a>
<a href="/search">Search</a>
+
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
<a href="/login">Log in</a>
+
</nav>
</header>
<main id="main">
tests/snapshots/web/not-found.html
Mode 100644 → 100644; object 804649ae1202 → 5b1c09fd1ec8
@@ -13,9 +13,11 @@
<nav aria-label="Primary">
<a href="/">Home</a>
<a href="/search">Search</a>
+
<a href="/signup">Create account</a>
<a href="/recover">Recover account</a>
<a href="/login">Log in</a>
+
</nav>
</header>
<main id="main">
tests/ssh.rs
Mode 100644 → 100644; object 401c758568f6 → 4393063a9fe4
@@ -88,6 +88,55 @@
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn reports_available_commands_and_explains_invalid_commands() {
+ let directory = TempDir::new().expect("create a key directory");
+ let private_key = directory.path().join("ed25519");
+ generate_key(&private_key, KeyFixture::Ed25519);
+ let server = start(&[parse_public_key(&private_key)]).await;
+
+ let help = ssh(&server, &private_key, "alice", &["help"]);
+ assert!(
+ help.status.success(),
+ "help failed: {}",
+ String::from_utf8_lossy(&help.stderr)
+ );
+ let help_text = String::from_utf8(help.stdout).expect("read the help output");
+ assert!(help_text.contains("Available tit SSH commands:"));
+ assert!(help_text.contains("repo create NAME"));
+ assert!(help_text.contains("issue list OWNER/REPOSITORY"));
+ assert!(help_text.contains("pr checkout OWNER/REPOSITORY NUMBER"));
+
+ let invalid = ssh(&server, &private_key, "alice", &["not-a-command"]);
+ assert!(!invalid.status.success());
+ assert_eq!(
+ String::from_utf8(invalid.stderr).expect("read the invalid-command error"),
+ "tit: The command is not valid.\nUse 'help' to list the available commands.\n"
+ );
+
+ let malformed = ssh(&server, &private_key, "alice", &["repo create"]);
+ assert!(!malformed.status.success());
+ let malformed_error =
+ String::from_utf8(malformed.stderr).expect("read the malformed-command error");
+ assert!(malformed_error.contains("usage: repo create NAME"));
+ assert!(malformed_error.contains("Use 'help' to list the available commands."));
+
+ let invalid_json = ssh(
+ &server,
+ &private_key,
+ "alice",
+ &["not-a-command --output json"],
+ );
+ assert!(!invalid_json.status.success());
+ let response: serde_json::Value =
+ serde_json::from_slice(&invalid_json.stdout).expect("parse the JSON error");
+ assert_eq!(response["version"], 1);
+ assert_eq!(response["status"], "error");
+ assert_eq!(response["error"]["code"], "invalid-command");
+
+ server.shutdown().await.expect("stop the SSH server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rejects_unknown_keys_and_forced_rsa_sha1_authentication() {
let directory = TempDir::new().expect("create a key directory");
let authorized_private = directory.path().join("authorized");
@@ -190,7 +239,7 @@
let audit = server.audit();
assert!(audit.rejected_shell >= 1);
- assert!(audit.rejected_exec >= 2);
+ assert!(audit.accepted_exec >= 2);
assert!(audit.rejected_pty >= 1);
assert!(audit.rejected_agent >= 1);
assert!(audit.rejected_forward >= 1);