diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..014491d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License for GitRepublic Web + +Copyright (c) 2026 GitCitadel LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9737312..04d8289 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ These are not part of any NIP but are used by this application: ### Git Operations Flow 1. **Clone/Fetch**: - - User runs `git clone https://{domain}/{npub}/{repo}.git` + - User runs `git clone https://{domain}/api/git/{npub}/{repo}.git` (or `/repos/` path) - Server handles GET requests to `info/refs?service=git-upload-pack` - For private repos, verifies NIP-98 authentication - Proxies request to `git-http-backend` which serves the repository @@ -351,7 +351,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform ## Environment Variables -- `NOSTRGIT_SECRET_KEY`: User's Nostr private key (nsec bech32 or hex) for git command-line operations via credential helper. Required for `git clone`, `git push`, and `git pull` operations from the command line. See [Git Command Line Setup](#git-command-line-setup) above. +- `NOSTRGIT_SECRET_KEY`: User's Nostr private key (nsec bech32 or hex) for git command-line operations via credential helper. Required for `git clone`, `git push`, and `git pull` operations from the command line. See [Git Command Line Setup](#git-command-line-setup) above. **Note**: Install the [GitRepublic CLI](https://github.com/your-org/gitrepublic-cli) package to use this. - `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`) - `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`) - `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com`) @@ -448,59 +448,84 @@ The server will automatically provision the repository. ### Git Command Line Setup -To use git from the command line with GitRepublic, you need to configure the credential helper. This enables automatic NIP-98 authentication for all git operations (clone, push, pull). +To use git from the command line with GitRepublic, install the [GitRepublic CLI](https://github.com/your-org/gitrepublic-cli) tools. This lightweight package provides the credential helper and commit signing hook. **Quick Setup:** -1. **Set your Nostr private key**: +1. **Install via npm** (recommended): + ```bash + npm install -g gitrepublic-cli + ``` + + Or clone from GitHub: + ```bash + git clone https://github.com/your-org/gitrepublic-cli.git + cd gitrepublic-cli + npm install + ``` + +2. **Set your Nostr private key**: ```bash export NOSTRGIT_SECRET_KEY="nsec1..." # Or add to ~/.bashrc or ~/.zshrc for persistence echo 'export NOSTRGIT_SECRET_KEY="nsec1..."' >> ~/.bashrc ``` -2. **Configure git credential helper**: +3. **Run automatic setup**: ```bash - # For a specific domain (recommended) - git config --global credential.https://your-domain.com.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' + # Setup everything automatically + gitrepublic-setup - # For localhost development - git config --global credential.http://localhost:5173.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' - ``` - -3. **Make the script executable**: - ```bash - chmod +x /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js + # Or with options: + gitrepublic-setup --domain your-domain.com # Configure for specific domain + gitrepublic-setup --global-hook # Install hook globally ``` + + The setup script automatically: + - Finds the scripts (works with npm install or git clone) + - Configures git credential helper + - Installs commit signing hook + - Checks if `NOSTRGIT_SECRET_KEY` is set **Important Notes:** - The `NOSTRGIT_SECRET_KEY` must match the repository owner or you must have maintainer permissions - The credential helper generates fresh NIP-98 tokens for each request (per-request authentication) +- The commit signing hook only signs commits for GitRepublic repositories (detects `/api/git/npub` or `/repos/npub` URL patterns) - Never commit your private key to version control -For complete setup instructions and troubleshooting, see [docs/GIT_CREDENTIAL_HELPER.md](./docs/GIT_CREDENTIAL_HELPER.md). +**CLI Features:** +- Full API access: `gitrepublic repos list`, `gitrepublic file get`, etc. +- Server configuration: `gitrepublic config server` +- JSON output support: `gitrepublic --json repos get ` + +For complete setup instructions, API commands, and troubleshooting, see the [GitRepublic CLI README](https://github.com/your-org/gitrepublic-cli). ### Cloning a Repository ```bash -# Public repository -git clone https://{domain}/{npub}/{repo-name}.git +# Using GitRepublic API endpoint (recommended for commit signing detection) +git clone https://{domain}/api/git/{npub}/{repo-name}.git + +# Or using repos endpoint +git clone https://{domain}/repos/{npub}/{repo-name}.git -# Private repository (requires credential helper setup) +# Direct path (also works, but may conflict with GRASP servers) git clone https://{domain}/{npub}/{repo-name}.git ``` +**Note**: Use `/api/git/` or `/repos/` paths to ensure proper detection by the commit signing hook and to distinguish from GRASP servers. + ### Pushing to a Repository ```bash -# Add remote -git remote add origin https://{domain}/{npub}/{repo-name}.git +# Add remote (use /api/git/ or /repos/ path for best compatibility) +git remote add origin https://{domain}/api/git/{npub}/{repo-name}.git # Push (requires credential helper setup) git push origin main ``` -The credential helper will automatically generate NIP-98 authentication tokens for push operations. +The credential helper will automatically generate NIP-98 authentication tokens for push operations. The commit signing hook will automatically sign commits for GitRepublic repositories. ### Viewing Repositories diff --git a/docs/GIT_CREDENTIAL_HELPER.md b/docs/GIT_CREDENTIAL_HELPER.md deleted file mode 100644 index 846f6be..0000000 --- a/docs/GIT_CREDENTIAL_HELPER.md +++ /dev/null @@ -1,263 +0,0 @@ -# Git Credential Helper for GitRepublic - -This guide explains how to use the GitRepublic credential helper to authenticate git operations (clone, fetch, push) using your Nostr private key. - -## Overview - -GitRepublic uses NIP-98 HTTP Authentication for git operations. The credential helper automatically generates NIP-98 authentication tokens using your Nostr private key (nsec). - -## Setup - -### 1. Make the script executable - -```bash -chmod +x scripts/git-credential-nostr.js -``` - -### 2. Set your NOSTRGIT_SECRET_KEY environment variable - -**Important:** -- This is YOUR user private key (for authenticating your git operations) -- Never commit your private key to version control! - -```bash -# Option 1: Export in your shell session -export NOSTRGIT_SECRET_KEY="nsec1..." - -# Option 2: Add to your ~/.bashrc or ~/.zshrc (for persistent setup) -echo 'export NOSTRGIT_SECRET_KEY="nsec1..."' >> ~/.bashrc -source ~/.bashrc - -# Option 3: Use a hex private key (64 characters) -export NOSTRGIT_SECRET_KEY="" - -# Note: The script also supports NOSTR_PRIVATE_KEY and NSEC for backward compatibility -``` - -### 3. Configure git to use the credential helper - -**Important:** The credential helper must be called for EACH request (not just the first one), because NIP-98 requires per-request authentication tokens. Make sure it's configured BEFORE any caching credential helpers. - -#### Global configuration (for all GitRepublic repositories): - -```bash -# Add our helper FIRST (before any cache/store helpers) -git config --global credential.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' - -# Optional: Disable credential caching to ensure our helper is always called -git config --global credential.helper cache -# Or remove cache helper if you want to ensure fresh credentials each time: -# git config --global --unset credential.helper cache -``` - -#### Per-domain configuration (recommended): - -```bash -# Replace your-domain.com with your GitRepublic server domain -git config --global credential.https://your-domain.com.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' -``` - -#### Localhost configuration (for local development): - -If you're running GitRepublic on localhost, configure it like this: - -```bash -# For HTTP (http://localhost:5173) -git config --global credential.http://localhost.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' - -# For HTTPS (https://localhost:5173) - if using SSL locally -git config --global credential.https://localhost.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' - -# For a specific port (e.g., http://localhost:5173) -git config --global credential.http://localhost:5173.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' -``` - -**Note:** Git's credential helper matching is based on the hostname, so `localhost` will match `localhost:5173` automatically. If you need to match a specific port, include it in the configuration. - -#### Per-repository configuration: - -```bash -cd /path/to/your/repo -git config credential.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' -``` - -## Usage - -Once configured, git will automatically use the credential helper for authentication: - -### Clone a private repository - -```bash -# Remote server -git clone https://your-domain.com/npub1abc123.../my-repo.git - -# Localhost (local development) -# The git HTTP backend is at /api/git/ -git clone http://localhost:5173/api/git/npub1abc123.../my-repo.git -``` - -The credential helper will automatically generate a NIP-98 auth token using your NOSTRGIT_SECRET_KEY. - -## Localhost Setup Example - -Here's a complete example for setting up the credential helper with a local GitRepublic instance: - -### 1. Start your local GitRepublic server - -```bash -cd /path/to/gitrepublic-web -npm run dev -# Server runs on http://localhost:5173 -``` - -### 2. Set your NOSTRGIT_SECRET_KEY - -```bash -export NOSTRGIT_SECRET_KEY="nsec1..." -``` - -### 3. Configure git for localhost - -```bash -# Configure for localhost (any port) -git config --global credential.http://localhost.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' - -# Or for a specific port (e.g., 5173) -git config --global credential.http://localhost:5173.helper '!node /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js' -``` - -### 4. Clone a repository - -```bash -# Replace npub1abc123... with the actual npub and my-repo with your repo name -git clone http://localhost:5173/api/git/npub1abc123.../my-repo.git -``` - -### 5. Add remote and push - -```bash -cd my-repo - -# If you need to add the remote manually -git remote add gitrepublic-web http://localhost:5173/api/git/npub1abc123.../my-repo.git - -# Make some changes and push -git add . -git commit -m "Initial commit" -git push -u gitrepublic-web main -``` - -**Note:** The git HTTP backend endpoint is `/api/git/`, so the full URL format is: -- `http://localhost:5173/api/git/{npub}/{repo-name}.git` - -### Push changes - -```bash -git push gitrepublic-web main -``` - -The credential helper will generate the appropriate NIP-98 auth token for push operations. - -### Fetch/Pull - -```bash -git fetch gitrepublic-web -git pull gitrepublic-web main -``` - -## How It Works - -1. When git needs credentials, it calls the credential helper with the repository URL -2. The helper reads your `NOSTRGIT_SECRET_KEY` environment variable -3. It creates a NIP-98 authentication event signed with your private key for the specific URL and HTTP method -4. The signed event is base64-encoded and returned as `username=nostr` and `password=` -5. Git converts this to `Authorization: Basic ` header -6. The GitRepublic server detects Basic auth with username "nostr" and converts it to `Authorization: Nostr ` format -7. The server verifies the NIP-98 auth event (signature, URL, method, timestamp) and grants access if valid - -**Important:** The credential helper generates fresh credentials for each request because NIP-98 requires per-request authentication tokens. The URL and HTTP method are part of the signed event, so credentials cannot be reused. - -## Troubleshooting - -### Error: NOSTRGIT_SECRET_KEY environment variable is not set - -Make sure you've exported the NOSTRGIT_SECRET_KEY variable: -```bash -export NOSTRGIT_SECRET_KEY="nsec1..." -``` - -**Note:** The script also supports `NOSTR_PRIVATE_KEY` and `NSEC` for backward compatibility, but `NOSTRGIT_SECRET_KEY` is the preferred name. - -### Error: Invalid nsec format - -- Ensure your nsec starts with `nsec1` (bech32 encoded) -- Or use a 64-character hex private key -- Check that the key is not corrupted or truncated - -### Authentication fails - -- Verify your private key matches the public key that has access to the repository -- Check that the repository URL is correct -- Ensure your key has maintainer permissions for push operations - -### Push operations fail or show login dialog - -If you see a login dialog when pushing, git isn't calling the credential helper for the POST request. This usually happens because: - -1. **Credential helper not configured correctly**: - ```bash - # Check your credential helper configuration - git config --global --get-regexp credential.helper - - # Make sure the GitRepublic helper is configured for your domain - git config --global credential.http://localhost:5173.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js' - ``` - -2. **Other credential helpers interfering**: Git might be using cached credentials from another helper. Make sure the GitRepublic helper is listed FIRST: - ```bash - # Remove all credential helpers - git config --global --unset-all credential.helper - - # Add only the GitRepublic helper - git config --global credential.http://localhost:5173.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js' - ``` - -3. **NOSTRGIT_SECRET_KEY not set**: Make sure the environment variable is set in the shell where git runs: - ```bash - export NOSTRGIT_SECRET_KEY="nsec1..." - ``` - -4. **Wrong private key**: Ensure your `NOSTRGIT_SECRET_KEY` matches the repository owner or you have maintainer permissions for the repository you're pushing to. - -5. **Authorization failure (403)**: If authentication succeeds but push fails with 403, check: - - Your pubkey matches the repository owner, OR - - You have maintainer permissions for the repository - - Branch protection rules allow your push - -## Security Best Practices - -1. **Never commit your NOSTRGIT_SECRET_KEY to version control** - - Add `NOSTRGIT_SECRET_KEY` to your `.gitignore` if you store it in a file - - Use environment variables instead of hardcoding - - **Important:** This is YOUR user key for client-side operations - -2. **Use per-domain configuration** - - This limits the credential helper to only GitRepublic domains - - Prevents accidental credential leaks to other services - -3. **Protect your private key** - - Use file permissions: `chmod 600 ~/.nostr-key` (if storing in a file) - - Consider using a key management service for production - -4. **Rotate keys if compromised** - - If your NOSTR_PRIVATE_KEY is ever exposed, generate a new key pair - - Update repository maintainer lists with your new public key - -## Alternative: Manual Authentication - -If you prefer not to use the credential helper, you can manually generate NIP-98 auth tokens, but this is not recommended for regular use as it's cumbersome. - -## See Also - -- [NIP-98 Specification](https://github.com/nostr-protocol/nips/blob/master/98.md) -- [Git Credential Helper Documentation](https://git-scm.com/docs/gitcredentials) diff --git a/docs/PUBLISH.md b/docs/PUBLISH.md new file mode 100644 index 0000000..f260765 --- /dev/null +++ b/docs/PUBLISH.md @@ -0,0 +1,123 @@ +# Publishing GitRepublic CLI to npm + +## Prerequisites + +1. **Create npm account** (if you don't have one): + - Visit https://www.npmjs.com/signup + - Or run: `npm adduser` + +2. **Login to npm**: + ```bash + npm login + ``` + Enter your username, password, and email. + +3. **Check if package name is available**: + ```bash + npm view gitrepublic-cli + ``` + If it returns 404, the name is available. If it shows package info, the name is taken. + +## Publishing Steps + +### 1. Update version (if needed) + +```bash +# Patch version (1.0.0 -> 1.0.1) +npm version patch + +# Minor version (1.0.0 -> 1.1.0) +npm version minor + +# Major version (1.0.0 -> 2.0.0) +npm version major +``` + +Or manually edit `package.json` and update the version field. + +### 2. Verify package contents + +```bash +# See what will be published +npm pack --dry-run +``` + +This shows the files that will be included (based on `files` field in package.json). + +### 3. Test the package locally + +```bash +# Pack the package +npm pack + +# Install it locally to test +npm install -g ./gitrepublic-cli-1.0.0.tgz + +# Test the commands +gitrepublic-path --credential +gitrepublic-path --hook +``` + +### 4. Publish to npm + +```bash +cd gitrepublic-cli +npm publish +``` + +For scoped packages (if you want `@your-org/gitrepublic-cli`): +```bash +npm publish --access public +``` + +### 5. Verify publication + +```bash +# Check on npm website +# Visit: https://www.npmjs.com/package/gitrepublic-cli + +# Or via command line +npm view gitrepublic-cli +``` + +## After Publishing + +Users can now install via: +```bash +npm install -g gitrepublic-cli +``` + +## Updating the Package + +1. Make your changes +2. Update version: `npm version patch` (or minor/major) +3. Publish: `npm publish` + +## Important Notes + +- **Package name**: `gitrepublic-cli` must be unique on npm. If taken, use a scoped name like `@your-org/gitrepublic-cli` +- **Version**: Follow semantic versioning (semver) +- **Files**: Only files listed in `files` array (or not in `.npmignore`) will be published +- **Unpublishing**: You can unpublish within 72 hours, but it's discouraged. Use deprecation instead: + ```bash + npm deprecate gitrepublic-cli@1.0.0 "Use version 1.0.1 instead" + ``` + +## Troubleshooting + +### "Package name already exists" +- The name `gitrepublic-cli` is taken +- Options: + 1. Use a scoped package: Change name to `@your-org/gitrepublic-cli` in package.json + 2. Choose a different name + 3. Contact the owner of the existing package + +### "You do not have permission" +- Make sure you're logged in: `npm whoami` +- If using scoped package, add `--access public` flag + +### "Invalid package name" +- Package names must be lowercase +- Can contain hyphens and underscores +- Cannot start with dot or underscore +- Max 214 characters diff --git a/gitrepublic-cli b/gitrepublic-cli new file mode 160000 index 0000000..be48033 --- /dev/null +++ b/gitrepublic-cli @@ -0,0 +1 @@ +Subproject commit be480332ebd06991a3a88e22aa2d175596e20337 diff --git a/package-lock.json b/package-lock.json index 357c129..4109b52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "gitrepublic-web", "version": "0.1.0", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/basic-setup": "^0.20.0", diff --git a/package.json b/package.json index d090900..0cab2a5 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "gitrepublic-web", "version": "0.1.0", "type": "module", + "author": "GitCitadel LLC", + "license": "MIT", + "website": "https://gitcitadel.com", "description": "Nostr-based git server with NIP-34 repo announcements", "scripts": { "dev": "GIT_REPO_ROOT=./repos vite dev", @@ -54,5 +57,19 @@ "ajv": "^8.17.1", "cookie": "^0.7.2", "esbuild": "^0.24.0" - } + }, + "repository": { + "type": "git", + "url": "https://git.imwald.eu/silberengel/gitrepublic-web.git" + }, + "repositories": [ + { + "type": "git", + "url": "https://github.com/silberengel/gitrepublic-web.git" + }, + { + "type": "git", + "url": "https://git.imwald.eu/silberengel/gitrepublic-web.git" + } + ] } diff --git a/scripts/git-credential-nostr.js b/scripts/git-credential-nostr.js deleted file mode 100755 index b5bc3a3..0000000 --- a/scripts/git-credential-nostr.js +++ /dev/null @@ -1,402 +0,0 @@ -#!/usr/bin/env node -/** - * Git credential helper for GitRepublic using NIP-98 authentication - * - * This script implements the git credential helper protocol to automatically - * generate NIP-98 authentication tokens for git operations. - * - * Usage: - * 1. Make it executable: chmod +x scripts/git-credential-nostr.js - * 2. Configure git: - * git config --global credential.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js' - * 3. Or for a specific domain: - * git config --global credential.https://your-domain.com.helper '!node /path/to/gitrepublic-web/scripts/git-credential-nostr.js' - * - * Environment variables: - * NOSTRGIT_SECRET_KEY - Your Nostr private key (nsec format or hex) for client-side git operations - * - * Security: Keep your NOSTRGIT_SECRET_KEY secure and never commit it to version control! - */ - -import { createHash } from 'crypto'; -import { finalizeEvent, getPublicKey } from 'nostr-tools'; -import { decode } from 'nostr-tools/nip19'; -import { readFileSync, existsSync } from 'fs'; -import { join, resolve } from 'path'; - -// NIP-98 auth event kind -const KIND_NIP98_AUTH = 27235; - -/** - * Read input from stdin (git credential helper protocol) - */ -function readInput() { - const chunks = []; - process.stdin.setEncoding('utf8'); - - return new Promise((resolve) => { - process.stdin.on('readable', () => { - let chunk; - while ((chunk = process.stdin.read()) !== null) { - chunks.push(chunk); - } - }); - - process.stdin.on('end', () => { - const input = chunks.join(''); - const lines = input.trim().split('\n'); - const data = {}; - - for (const line of lines) { - if (!line) continue; - const [key, ...valueParts] = line.split('='); - if (key && valueParts.length > 0) { - data[key] = valueParts.join('='); - } - } - - resolve(data); - }); - }); -} - -/** - * Try to extract the git remote URL path from .git/config - * This is used as a fallback when git calls us with wwwauth[] but no path - */ -function tryGetPathFromGitRemote(host, protocol) { - try { - // Git sets GIT_DIR environment variable when calling credential helpers - // Use it if available, otherwise try to find .git directory - let gitDir = process.env.GIT_DIR; - let configPath = null; - - if (gitDir) { - // GIT_DIR might point directly to .git directory or to the config file - if (existsSync(gitDir) && existsSync(join(gitDir, 'config'))) { - configPath = join(gitDir, 'config'); - } else if (existsSync(gitDir) && gitDir.endsWith('config')) { - configPath = gitDir; - } - } - - // If GIT_DIR didn't work, try to find .git directory starting from current working directory - if (!configPath) { - let currentDir = process.cwd(); - const maxDepth = 10; // Limit search depth - let depth = 0; - - while (depth < maxDepth) { - const potentialGitDir = join(currentDir, '.git'); - if (existsSync(potentialGitDir) && existsSync(join(potentialGitDir, 'config'))) { - configPath = join(potentialGitDir, 'config'); - break; - } - - // Move up one directory - const parentDir = resolve(currentDir, '..'); - if (parentDir === currentDir) { - // Reached filesystem root - break; - } - currentDir = parentDir; - depth++; - } - } - - if (!configPath || !existsSync(configPath)) { - return null; - } - - // Read git config - const config = readFileSync(configPath, 'utf-8'); - - // Find remotes that match our host - // Match: [remote "name"] ... url = http://host/path - const remoteRegex = /\[remote\s+"([^"]+)"\][\s\S]*?url\s*=\s*([^\n]+)/g; - let match; - while ((match = remoteRegex.exec(config)) !== null) { - const remoteUrl = match[2].trim(); - - // Check if this remote URL matches our host - try { - const url = new URL(remoteUrl); - const remoteHost = url.hostname + (url.port ? ':' + url.port : ''); - if (url.host === host || remoteHost === host) { - // Extract path from remote URL - let path = url.pathname; - if (path && path.includes('git-receive-pack')) { - // Already has git-receive-pack in path - return path; - } else if (path && path.endsWith('.git')) { - // Add git-receive-pack to path - return path + '/git-receive-pack'; - } else if (path) { - // Path exists but doesn't end with .git, try adding /git-receive-pack - return path + '/git-receive-pack'; - } - } - } catch (e) { - // Not a valid URL, skip - continue; - } - } - } catch (err) { - // If anything fails, return null silently - } - - return null; -} - -/** - * Normalize URL for NIP-98 (remove trailing slashes, ensure consistent format) - * This must match the normalization used by the server in nip98-auth.ts - */ -function normalizeUrl(url) { - try { - const parsed = new URL(url); - // Remove trailing slash from pathname (must match server normalization) - parsed.pathname = parsed.pathname.replace(/\/$/, ''); - return parsed.toString(); - } catch { - return url; - } -} - -/** - * Calculate SHA256 hash of request body - */ -function calculateBodyHash(body) { - if (!body) return null; - const buffer = Buffer.from(body, 'utf-8'); - return createHash('sha256').update(buffer).digest('hex'); -} - -/** - * Create and sign a NIP-98 authentication event - * @param privateKeyBytes - Private key as Uint8Array (32 bytes) - * @param url - Request URL - * @param method - HTTP method (GET, POST, etc.) - * @param bodyHash - Optional SHA256 hash of request body (for POST requests) - */ -function createNIP98AuthEvent(privateKeyBytes, url, method, bodyHash = null) { - const pubkey = getPublicKey(privateKeyBytes); - const tags = [ - ['u', normalizeUrl(url)], - ['method', method.toUpperCase()] - ]; - - if (bodyHash) { - tags.push(['payload', bodyHash]); - } - - const eventTemplate = { - kind: KIND_NIP98_AUTH, - pubkey, - created_at: Math.floor(Date.now() / 1000), - content: '', - tags - }; - - // Sign the event using finalizeEvent (which computes id and sig) - const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes); - - return signedEvent; -} - -/** - * Main credential helper logic - */ -async function main() { - try { - // Read input from git - const input = await readInput(); - - // Get command (get, store, erase) - const command = process.argv[2] || 'get'; - - // For 'get' command, generate credentials - if (command === 'get') { - // Get private key from environment variable - // Support NOSTRGIT_SECRET_KEY (preferred), with fallbacks for backward compatibility - const nsec = process.env.NOSTRGIT_SECRET_KEY || process.env.NOSTR_PRIVATE_KEY || process.env.NSEC; - if (!nsec) { - console.error('Error: NOSTRGIT_SECRET_KEY environment variable is not set'); - console.error('Set it with: export NOSTRGIT_SECRET_KEY="nsec1..." or NOSTRGIT_SECRET_KEY=""'); - process.exit(1); - } - - // Parse private key (handle both nsec and hex formats) - // Convert to Uint8Array for nostr-tools functions - let privateKeyBytes; - if (nsec.startsWith('nsec')) { - try { - const decoded = decode(nsec); - if (decoded.type === 'nsec') { - // decoded.data is already Uint8Array for nsec - privateKeyBytes = decoded.data; - } else { - throw new Error('Invalid nsec format - decoded type is not nsec'); - } - } catch (err) { - console.error('Error decoding nsec:', err.message); - process.exit(1); - } - } else { - // Assume hex format (32 bytes = 64 hex characters) - if (nsec.length !== 64) { - console.error('Error: Hex private key must be 64 characters (32 bytes)'); - process.exit(1); - } - // Convert hex string to Uint8Array - privateKeyBytes = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - privateKeyBytes[i] = parseInt(nsec.slice(i * 2, i * 2 + 2), 16); - } - } - - // Extract URL components from input - // Git credential helper protocol passes: protocol, host, path (and sometimes username, password) - // Git may provide either individual attributes (protocol, host, path) or a url attribute - // If we have a url, use it; otherwise construct from individual attributes - let url; - if (input.url) { - // Git provided a url attribute - use it directly - url = input.url; - } else { - // Construct URL from individual attributes - const protocol = input.protocol || 'https'; - const host = input.host; - let path = input.path || ''; - const wwwauth = input['wwwauth[]'] || input.wwwauth; - - if (!host) { - console.error('Error: No host specified in credential request'); - process.exit(1); - } - - // If path is missing, try to extract it from git remote URL - // This happens when git calls us reactively after a 401 with wwwauth[] but no path - if (!path) { - if (wwwauth) { - // Try to get path from git remote URL - const extractedPath = tryGetPathFromGitRemote(host, protocol); - if (extractedPath) { - path = extractedPath; - } else { - // Exit without output - git should call us again with the full path when it retries - process.exit(0); - } - } else { - // Exit without output - git will call us again with the full path - process.exit(0); - } - } - - // Build full URL (include query string if present) - const query = input.query || ''; - const fullPath = query ? `${path}?${query}` : path; - url = `${protocol}://${host}${fullPath}`; - } - - // Parse URL to extract components for method detection - let urlPath = ''; - try { - const urlObj = new URL(url); - urlPath = urlObj.pathname; - } catch (err) { - // If URL parsing fails, try to extract path from the URL string - const match = url.match(/https?:\/\/[^\/]+(\/.*)/); - urlPath = match ? match[1] : ''; - } - - // Determine HTTP method based on git operation - // Git credential helper doesn't know the HTTP method, but we can infer it: - // - If path contains 'git-receive-pack', it's a push (POST) - // - If path contains 'git-upload-pack', it's a fetch (GET) - // - For info/refs requests, check the service query parameter - let method = 'GET'; - let authUrl = url; // The URL for which we generate credentials - - // Parse query string from URL if present - let query = ''; - try { - const urlObj = new URL(url); - query = urlObj.search.slice(1); // Remove leading '?' - } catch (err) { - // If URL parsing fails, try to extract query from the URL string - const match = url.match(/\?(.+)$/); - query = match ? match[1] : ''; - } - - if (urlPath.includes('git-receive-pack')) { - // Direct POST request to git-receive-pack - method = 'POST'; - authUrl = url; - } else if (urlPath.includes('git-upload-pack')) { - // Direct GET request to git-upload-pack - method = 'GET'; - authUrl = url; - } else if (query.includes('service=git-receive-pack')) { - // info/refs?service=git-receive-pack - this is a GET request - // However, git might not call us again for the POST request - // So we need to generate credentials for the POST request that will happen next - // Replace info/refs with git-receive-pack in the path - try { - const urlObj = new URL(url); - urlObj.pathname = urlObj.pathname.replace(/\/info\/refs$/, '/git-receive-pack'); - urlObj.search = ''; // Remove query string for POST request - authUrl = urlObj.toString(); - } catch (err) { - // Fallback: string replacement - authUrl = url.replace(/\/info\/refs(\?.*)?$/, '/git-receive-pack'); - } - method = 'POST'; - } else { - // Default: GET request (info/refs, etc.) - method = 'GET'; - authUrl = url; - } - - // Normalize the URL before creating the event (must match server normalization) - const normalizedAuthUrl = normalizeUrl(authUrl); - - // Create and sign NIP-98 auth event - const authEvent = createNIP98AuthEvent(privateKeyBytes, normalizedAuthUrl, method); - - // Encode event as base64 - const eventJson = JSON.stringify(authEvent); - const base64Event = Buffer.from(eventJson, 'utf-8').toString('base64'); - - // Output credentials in git credential helper format - // Username can be anything (git doesn't use it for NIP-98) - // Password is the base64-encoded signed event - console.log('username=nostr'); - console.log(`password=${base64Event}`); - - } else if (command === 'store') { - // For 'store', we don't store credentials because NIP-98 requires per-request credentials - // The URL and method are part of the signed event, so we can't reuse credentials - // However, we should NOT prevent git from storing - let other credential helpers handle it - // We just exit successfully without storing anything ourselves - // This allows git to call us again for each request - process.exit(0); - } else if (command === 'erase') { - // For 'erase', we don't need to do anything - // Just exit successfully - process.exit(0); - } else { - console.error(`Error: Unknown command: ${command}`); - process.exit(1); - } - } catch (error) { - console.error('Error:', error.message); - process.exit(1); - } -} - -// Run main function -main().catch((error) => { - console.error('Fatal error:', error); - process.exit(1); -}); diff --git a/src/routes/api/git/[...path]/+server.ts b/src/routes/api/git/[...path]/+server.ts index c914807..8014d92 100644 --- a/src/routes/api/git/[...path]/+server.ts +++ b/src/routes/api/git/[...path]/+server.ts @@ -471,19 +471,45 @@ export const GET: RequestHandler = async ({ params, url, request }) => { // For info/refs requests, git-http-backend includes HTTP headers in the body // We need to strip them and only send the git protocol data - // The format is: HTTP headers + blank line (\r\n\r\n) + git protocol data - if (pathInfo.includes('info/refs')) { - const bodyStr = body.toString('binary'); - const headerEnd = bodyStr.indexOf('\r\n\r\n'); - if (headerEnd !== -1) { + // The format is: HTTP headers + blank line (\r\n\r\n or \n\n) + git protocol data + // Also check for headers in POST responses (some git-http-backend versions include them) + const bodyStr = body.toString('binary'); + const hasHttpHeaders = bodyStr.match(/^(Expires|Content-Type|Cache-Control|Pragma):/i); + + if (hasHttpHeaders || pathInfo.includes('info/refs')) { + // Try to find header end with \r\n\r\n first (standard HTTP) + let headerEnd = bodyStr.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + // Fallback to \n\n (some systems use just \n) + headerEnd = bodyStr.indexOf('\n\n'); + if (headerEnd !== -1) { + // Extract only the git protocol data (after the blank line) + body = Buffer.from(bodyStr.slice(headerEnd + 2), 'binary'); + } + } else { // Extract only the git protocol data (after the blank line) body = Buffer.from(bodyStr.slice(headerEnd + 4), 'binary'); - logger.debug({ - originalLength: Buffer.concat(chunks).length, - protocolDataLength: body.length, - headerEnd - }, 'Stripped HTTP headers from info/refs response'); } + + // Additional safety: ensure body starts with git protocol format + // Git protocol should start with a length prefix (hex) or service line + const bodyStart = body.toString('utf-8', 0, Math.min(100, body.length)); + if (headerEnd !== -1 && !bodyStart.match(/^[0-9a-f]{4}|^# service=/i)) { + logger.warn({ + bodyStart: bodyStart.substring(0, 50), + headerEnd, + pathInfo + }, 'Warning: Stripped headers but body does not start with git protocol format'); + } + + logger.debug({ + originalLength: Buffer.concat(chunks).length, + protocolDataLength: body.length, + headerEnd, + bodyStart: bodyStart.substring(0, 50), + pathInfo, + hasHttpHeaders: !!hasHttpHeaders + }, 'Stripped HTTP headers from git-http-backend response'); } // Determine content type based on request type @@ -644,7 +670,7 @@ export const POST: RequestHandler = async ({ params, url, request }) => { // This ensures git calls the credential helper proactively // Git requires WWW-Authenticate header on ALL 401 responses, otherwise it won't retry if (!rawAuthHeader) { - return new Response('Authentication required. Please configure the git credential helper. See docs/GIT_CREDENTIAL_HELPER.md for setup instructions.', { + return new Response('Authentication required. Please configure the git credential helper. See https://github.com/your-org/gitrepublic-cli for setup instructions.', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="GitRepublic"', @@ -699,7 +725,60 @@ export const POST: RequestHandler = async ({ params, url, request }) => { ); if (authResult.pubkey !== currentOwnerPubkey && !isMaintainer) { - return error(403, 'Event pubkey does not match repository owner or maintainer'); + const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; + const authPubkey = authResult.pubkey || ''; + + logger.warn({ + authPubkey, + currentOwnerPubkey, + isMaintainer, + repoName: `${npub}/${repoName}` + }, 'Push denied: insufficient permissions'); + + auditLogger.logRepoAccess( + authPubkey, + clientIp, + 'push', + `${npub}/${repoName}`, + 'denied', + 'Not repository owner or maintainer' + ); + + // Get list of maintainers for the error message + const { maintainers } = await maintainerService.getMaintainers(currentOwnerPubkey, repoName); + const maintainerList = maintainers + .filter(m => m !== currentOwnerPubkey) // Exclude owner from maintainer list + .map(m => m.substring(0, 16) + '...') + .join(', '); + + // Return user-friendly error message as plain text + // Note: Git doesn't display response bodies for 403 errors, but the message is here + // for debugging and for tools that do read response bodies (like curl) + let errorMessage = `Permission denied: You are not the repository owner or a maintainer.\n` + + `Repository: ${npub}/${repoName}\n` + + `Your pubkey: ${authPubkey.substring(0, 16)}...\n` + + `Owner pubkey: ${currentOwnerPubkey.substring(0, 16)}...\n`; + + if (maintainerList) { + errorMessage += `Maintainers: ${maintainerList}\n`; + } else { + errorMessage += `Maintainers: (none - only owner can push)\n`; + } + + errorMessage += `\nTo push, use the private key (nsec) that matches the repository owner, or be added as a maintainer.\n` + + `Set NOSTRGIT_SECRET_KEY to the correct private key.\n` + + `\nNote: Use 'gitrepublic-push' instead of 'git push' to see this detailed error message.`; + + // Return plain text response so git can display it + // Git will show this in the terminal when verbose mode is enabled + return new Response(errorMessage, { + status: 403, + statusText: 'Forbidden', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': Buffer.byteLength(errorMessage, 'utf-8').toString() + } + }); } // Check branch protection rules @@ -900,7 +979,35 @@ export const POST: RequestHandler = async ({ params, url, request }) => { return; } - const responseBody = Buffer.concat(chunks); + let responseBody = Buffer.concat(chunks); + + // Check if git-http-backend included HTTP headers in POST response body + // Some versions include headers that need to be stripped + const bodyStr = responseBody.toString('binary'); + const hasHttpHeaders = bodyStr.match(/^(Expires|Content-Type|Cache-Control|Pragma):/i); + + if (hasHttpHeaders) { + // Try to find header end with \r\n\r\n first (standard HTTP) + let headerEnd = bodyStr.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + // Fallback to \n\n (some systems use just \n) + headerEnd = bodyStr.indexOf('\n\n'); + if (headerEnd !== -1) { + // Extract only the git protocol data (after the blank line) + responseBody = Buffer.from(bodyStr.slice(headerEnd + 2), 'binary'); + } + } else { + // Extract only the git protocol data (after the blank line) + responseBody = Buffer.from(bodyStr.slice(headerEnd + 4), 'binary'); + } + + logger.debug({ + originalLength: Buffer.concat(chunks).length, + protocolDataLength: responseBody.length, + headerEnd, + pathInfo + }, 'Stripped HTTP headers from POST response'); + } // Determine content type let contentType = 'application/x-git-receive-pack-result'; diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 114fa20..ed462eb 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -35,6 +35,7 @@ repoTopics?: string[]; repoWebsite?: string; repoIsPrivate?: boolean; + gitDomain?: string; }); const npub = ($page.params as { npub?: string; repo?: string }).npub || ''; @@ -148,11 +149,52 @@ let isRepoCloned = $state(null); // null = unknown, true = cloned, false = not cloned let checkingCloneStatus = $state(false); let cloning = $state(false); + let copyingCloneUrl = $state(false); // Helper: Check if repo needs to be cloned for write operations const needsClone = $derived(isRepoCloned === false); const cloneTooltip = 'Please clone this repo to use this feature.'; + // Copy clone URL to clipboard + async function copyCloneUrl() { + if (copyingCloneUrl) return; + + copyingCloneUrl = true; + try { + // Use the current page URL to get the correct host and port + // This ensures we use the same domain/port the user is currently viewing + const currentUrl = $page.url; + const host = currentUrl.host; // Includes port if present (e.g., "localhost:5173") + const protocol = currentUrl.protocol.slice(0, -1); // Remove trailing ":" + + // Use /api/git/ format for better compatibility with commit signing hook + const cloneUrl = `${protocol}://${host}/api/git/${npub}/${repo}.git`; + const cloneCommand = `git clone ${cloneUrl}`; + + // Try to use the Clipboard API + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(cloneCommand); + alert(`Clone command copied to clipboard!\n\n${cloneCommand}`); + } else { + // Fallback: create a temporary textarea + const textarea = document.createElement('textarea'); + textarea.value = cloneCommand; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + alert(`Clone command copied to clipboard!\n\n${cloneCommand}`); + } + } catch (err) { + console.error('Failed to copy clone command:', err); + alert('Failed to copy clone command to clipboard'); + } finally { + copyingCloneUrl = false; + } + } + // Verification status let verificationStatus = $state<{ verified: boolean; @@ -2983,6 +3025,22 @@
+ {#if isRepoCloned === true} + + {/if}