Browse Source

update docs

Nostr-Signature: 5a14564a2b82b3b4ee4e21d28e7b362cc82e3c27eac38691c85f46480b100cf1 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc d1369aff4db39f61aba5f0954c0c8ba92df4aec96f1fab7cc5af51d1b0667734f35dec99363290de2c248b7074369f592b238b1b66987e09f267062073167131
main
Silberengel 3 weeks ago
parent
commit
ca75bd0858
  1. 21
      LICENSE
  2. 65
      README.md
  3. 263
      docs/GIT_CREDENTIAL_HELPER.md
  4. 123
      docs/PUBLISH.md
  5. 1
      gitrepublic-cli
  6. 1
      package-lock.json
  7. 17
      package.json
  8. 402
      scripts/git-credential-nostr.js
  9. 125
      src/routes/api/git/[...path]/+server.ts
  10. 58
      src/routes/repos/[npub]/[repo]/+page.svelte

21
LICENSE

@ -0,0 +1,21 @@ @@ -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.

65
README.md

@ -134,7 +134,7 @@ These are not part of any NIP but are used by this application: @@ -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 @@ -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. @@ -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'
# Or with options:
gitrepublic-setup --domain your-domain.com # Configure for specific domain
gitrepublic-setup --global-hook # Install hook globally
```
3. **Make the script executable**:
```bash
chmod +x /absolute/path/to/gitrepublic-web/scripts/git-credential-nostr.js
```
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 <npub> <repo>`
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

263
docs/GIT_CREDENTIAL_HELPER.md

@ -1,263 +0,0 @@ @@ -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="<your-64-char-hex-private-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=<base64-event>`
5. Git converts this to `Authorization: Basic <base64(username:password)>` header
6. The GitRepublic server detects Basic auth with username "nostr" and converts it to `Authorization: Nostr <base64-event>` 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)

123
docs/PUBLISH.md

@ -0,0 +1,123 @@ @@ -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

1
gitrepublic-cli

@ -0,0 +1 @@ @@ -0,0 +1 @@
Subproject commit be480332ebd06991a3a88e22aa2d175596e20337

1
package-lock.json generated

@ -7,6 +7,7 @@ @@ -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",

17
package.json

@ -2,6 +2,9 @@ @@ -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 @@ @@ -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"
}
]
}

402
scripts/git-credential-nostr.js

@ -1,402 +0,0 @@ @@ -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="<hex-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);
});

125
src/routes/api/git/[...path]/+server.ts

@ -471,19 +471,45 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -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')) {
// 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 headerEnd = bodyStr.indexOf('\r\n\r\n');
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');
}
// 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
}, 'Stripped HTTP headers from info/refs response');
}
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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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';

58
src/routes/repos/[npub]/[repo]/+page.svelte

@ -35,6 +35,7 @@ @@ -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 @@ @@ -148,11 +149,52 @@
let isRepoCloned = $state<boolean | null>(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 @@ @@ -2983,6 +3025,22 @@
</div>
<div class="header-actions">
<div style="display: flex; align-items: center; gap: 0.5rem;">
{#if isRepoCloned === true}
<button
onclick={copyCloneUrl}
disabled={copyingCloneUrl}
class="clone-url-button"
title="Copy clone URL to clipboard"
style="padding: 0.5rem 1rem; font-size: 0.875rem; background: var(--primary, #3b82f6); color: white; border: none; border-radius: 0.25rem; cursor: pointer; display: flex; align-items: center; gap: 0.5rem;"
>
{#if copyingCloneUrl}
<span>Copying...</span>
{:else}
<img src="/icons/copy.svg" alt="" class="icon-inline" style="width: 1rem; height: 1rem;" />
<span>Clone</span>
{/if}
</button>
{/if}
<select bind:value={currentBranch} onchange={handleBranchChange} class="branch-select" disabled={branches.length === 0 && loading}>
{#if branches.length === 0}
<!-- Show current branch even if branches haven't loaded yet -->

Loading…
Cancel
Save