Browse Source

api refactor part 2

Nostr-Signature: ece894a60057bba46ebd4ac0dca2aca55ffce05e44671fe07b29516809fc86f6 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 176706a271659834e441ea5eab4bb1480667dad4468fe8315803284f4a183debf595523dd33d0d3cabe0c35013f4a72b9169b5f10afefaf8a82a721d8b0f3b08
main
Silberengel 2 weeks ago
parent
commit
4465a1074a
  1. 90
      docs/api-and-cli.md
  2. 18
      docs/editing-repos.md
  3. 16
      docs/repo-operations.md
  4. 1
      nostr/commit-signatures.jsonl
  5. 2
      src/hooks.server.ts
  6. 7
      src/lib/components/PRDetail.svelte
  7. 2276
      src/routes/api/openapi.json/openapi.json
  8. 543
      src/routes/api/repos/[npub]/[repo]/fork/+server.ts
  9. 181
      src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
  10. 343
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  11. 9
      src/routes/repos/[npub]/[repo]/+page.svelte
  12. 4
      src/routes/repos/[npub]/[repo]/components/DocsTab.svelte
  13. 2
      src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte
  14. 2
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte
  15. 6
      src/routes/repos/[npub]/[repo]/hooks/use-repo-api.ts
  16. 2
      src/routes/repos/[npub]/[repo]/services/branch-operations.ts
  17. 5
      src/routes/repos/[npub]/[repo]/services/code-search-operations.ts
  18. 4
      src/routes/repos/[npub]/[repo]/services/commit-operations.ts
  19. 37
      src/routes/repos/[npub]/[repo]/services/file-operations.ts
  20. 2
      src/routes/repos/[npub]/[repo]/services/pr-operations.ts
  21. 12
      src/routes/repos/[npub]/[repo]/services/repo-operations.ts
  22. 2
      src/routes/repos/[npub]/[repo]/utils/download.ts
  23. 2
      src/routes/repos/[npub]/[repo]/utils/file-processing.ts

90
docs/api-and-cli.md

@ -34,66 +34,80 @@ View interactive documentation at `/api/openapi.json` or use any OpenAPI viewer. @@ -34,66 +34,80 @@ View interactive documentation at `/api/openapi.json` or use any OpenAPI viewer.
#### Repository Management
- `GET /api/repos/list` - List all repositories
- `GET /api/repos/local` - List local repositories
- `GET /api/repos/list?domain={domain}` - List all registered repositories (optionally filter by domain)
- `GET /api/repos/local` - List local repositories (cloned on this server)
- `GET /api/repos/{npub}/{repo}` - Get repository information (with optional `?include=settings,maintainers,access,verification`)
- `PUT /api/repos/{npub}/{repo}` - Replace repository (full update)
- `PATCH /api/repos/{npub}/{repo}` - Partial update repository
- `DELETE /api/repos/{npub}/{repo}` - Delete repository
- `GET /api/repos/{npub}/{repo}/settings` - Get repository settings
- `POST /api/repos/{npub}/{repo}/settings` - Update repository settings
- `GET /api/repos/{npub}/{repo}/maintainers` - Get maintainers
- `POST /api/repos/{npub}/{repo}/maintainers` - Add maintainer
- `DELETE /api/repos/{npub}/{repo}/maintainers` - Remove maintainer
- `POST /api/repos/{npub}/{repo}/fork` - Fork repository
- `GET /api/repos/{npub}/{repo}/maintainers` - List maintainers
- `POST /api/repos/{npub}/{repo}/maintainers` - Add maintainer (body: `{ maintainer: "npub..." }`)
- `DELETE /api/repos/{npub}/{repo}/maintainers/{npub}` - Remove maintainer
- `GET /api/repos/{npub}/{repo}/forks` - Get fork information
- `POST /api/repos/{npub}/{repo}/forks` - Fork repository
- `DELETE /api/repos/{npub}/{repo}/delete` - Delete repository
- `POST /api/repos/{npub}/{repo}/transfer` - Transfer ownership
- `POST /api/repos/{npub}/{repo}/clone` - Clone to server
- `GET /api/repos/{npub}/{repo}/transfers` - Get ownership transfer history
- `POST /api/repos/{npub}/{repo}/transfers` - Transfer ownership
- `POST /api/repos/{npub}/{repo}/clone` - Clone repository to server
- `GET /api/repos/{npub}/{repo}/verification` - Verify repository ownership
- `POST /api/repos/{npub}/{repo}/verification` - Save announcement to repository for verification
- `GET /api/repos/{npub}/{repo}/validate` - Validate repository announcement
- `GET /api/repos/{npub}/{repo}/access` - Get repository access information
- `GET /api/repos/{npub}/{repo}/releases` - List releases
- `POST /api/repos/{npub}/{repo}/releases` - Create release
#### File Operations
- `GET /api/repos/{npub}/{repo}/file` - Get file content
- `POST /api/repos/{npub}/{repo}/file` - Create/update/delete file
- `GET /api/repos/{npub}/{repo}/tree` - List files and directories
- `GET /api/repos/{npub}/{repo}/raw` - Get raw file content
- `GET /api/repos/{npub}/{repo}/readme` - Get README content
- `GET /api/repos/{npub}/{repo}/files?path={path}&ref={ref}` - Get file content (JSON format)
- `GET /api/repos/{npub}/{repo}/files?action=tree&path={path}&ref={ref}` - List files and directories
- `GET /api/repos/{npub}/{repo}/files?path={path}&format=raw&ref={ref}` - Get raw file content
- `POST /api/repos/{npub}/{repo}/files?path={path}` - Create file
- `PUT /api/repos/{npub}/{repo}/files?path={path}` - Update file (replace)
- `PATCH /api/repos/{npub}/{repo}/files?path={path}` - Partial update
- `DELETE /api/repos/{npub}/{repo}/files?path={path}` - Delete file
- `GET /api/repos/{npub}/{repo}/readme?ref={ref}` - Get README content
#### Git Operations
- `GET /api/repos/{npub}/{repo}/branches` - List branches
- `POST /api/repos/{npub}/{repo}/branches` - Create branch
- `POST /api/repos/{npub}/{repo}/branches` - Create branch (requires maintainer auth)
- `GET /api/repos/{npub}/{repo}/branches/default` - Get default branch
- `GET /api/repos/{npub}/{repo}/tags` - List tags
- `POST /api/repos/{npub}/{repo}/tags` - Create tag
- `POST /api/repos/{npub}/{repo}/tags` - Create tag (requires maintainer auth)
- `GET /api/repos/{npub}/{repo}/commits` - List commits
- `GET /api/repos/{npub}/{repo}/commits/{hash}/verify` - Verify commit signature
- `GET /api/repos/{npub}/{repo}/diff` - Get diff between commits
- `GET /api/repos/{npub}/{repo}/default-branch` - Get default branch
- `POST /api/repos/{npub}/{repo}/default-branch` - Set default branch
- `GET /api/repos/{npub}/{repo}/commits/{hash}/verification` - Verify commit signature
- `GET /api/repos/{npub}/{repo}/diffs?from={from}&to={to}&path={path}` - Get diff between commits
- `GET /api/repos/{npub}/{repo}/archive?format=zip|tar.gz&ref={ref}` - Download repository archive
#### Collaboration
- `GET /api/repos/{npub}/{repo}/prs` - List pull requests
- `POST /api/repos/{npub}/{repo}/prs` - Create pull request
- `PATCH /api/repos/{npub}/{repo}/prs` - Update PR status
- `POST /api/repos/{npub}/{repo}/prs/{prId}/merge` - Merge PR
- `GET /api/repos/{npub}/{repo}/pull-requests` - List pull requests
- `POST /api/repos/{npub}/{repo}/pull-requests` - Create pull request
- `GET /api/repos/{npub}/{repo}/pull-requests/{id}` - Get pull request
- `PATCH /api/repos/{npub}/{repo}/pull-requests/{id}` - Update PR status
- `POST /api/repos/{npub}/{repo}/pull-requests/{id}/merge` - Merge PR
- `GET /api/repos/{npub}/{repo}/issues` - List issues
- `POST /api/repos/{npub}/{repo}/issues` - Create issue
- `PATCH /api/repos/{npub}/{repo}/issues` - Update issue status
- `GET /api/repos/{npub}/{repo}/patches` - List patches
- `POST /api/repos/{npub}/{repo}/patches` - Create patch
- `PATCH /api/repos/{npub}/{repo}/patches` - Update patch status
- `POST /api/repos/{npub}/{repo}/patches/{patchId}/apply` - Apply patch
- `POST /api/repos/{npub}/{repo}/patches/{id}/application` - Apply patch
- `GET /api/repos/{npub}/{repo}/highlights` - List highlights/comments
- `POST /api/repos/{npub}/{repo}/highlights` - Create highlight/comment
#### Search and Discovery
- `GET /api/search` - Search repositories
- `GET /api/repos/{npub}/{repo}/code-search` - Search code in repository
- `GET /api/code-search` - Global code search
- `GET /api/repos/{npub}/{repo}/clone-urls/reachability` - Check clone URL reachability
- `GET /api/search?type=repos&q={query}` - Search repositories (default)
- `GET /api/search?type=code&q={query}&repo={npub}/{repo}` - Search code (optionally filter by repository)
- `GET /api/repos/{npub}/{repo}/clone-urls` - List clone URLs
- `POST /api/repos/{npub}/{repo}/clone-urls/reachability` - Check clone URL reachability
#### User Operations
- `GET /api/users/{npub}/profile` - Get user profile
- `GET /api/users/{npub}/repos` - Get user's repositories
- `GET /api/user/level` - Get user access level
- `POST /api/user/level` - Verify user access level (relay write access)
- `GET /api/user/git-dashboard` - Get git dashboard
- `GET /api/user/messaging-preferences` - Get messaging preferences
- `POST /api/user/messaging-preferences` - Update messaging preferences
@ -102,6 +116,7 @@ View interactive documentation at `/api/openapi.json` or use any OpenAPI viewer. @@ -102,6 +116,7 @@ View interactive documentation at `/api/openapi.json` or use any OpenAPI viewer.
- `GET /api/config` - Get server configuration
- `GET /api/tor/onion` - Get Tor .onion address
- `POST /api/repos/poll` - Trigger repository polling (provisions new repos from Nostr)
- `GET /api/transfers/pending` - Get pending ownership transfers
#### Git HTTP Backend
@ -120,10 +135,19 @@ curl https://your-domain.com/api/repos/list @@ -120,10 +135,19 @@ curl https://your-domain.com/api/repos/list
curl https://your-domain.com/api/repos/{npub}/{repo}/settings
# Create file (requires NIP-98 auth)
curl -X POST https://your-domain.com/api/repos/{npub}/{repo}/file \
curl -X POST "https://your-domain.com/api/repos/{npub}/{repo}/files?path=test.txt" \
-H "Authorization: Nostr <base64-event>" \
-H "Content-Type: application/json" \
-d '{"path": "test.txt", "content": "Hello", "commitMessage": "Add file", "branch": "main", "action": "write"}'
-d '{"content": "Hello", "commitMessage": "Add file", "branch": "main"}'
# Get file content
curl "https://your-domain.com/api/repos/{npub}/{repo}/files?path=test.txt&ref=main"
# List files (tree view)
curl "https://your-domain.com/api/repos/{npub}/{repo}/files?action=tree&ref=main"
# Get raw file content
curl "https://your-domain.com/api/repos/{npub}/{repo}/files?path=test.txt&format=raw&ref=main"
```
## Command Line Interface (CLI)

18
docs/editing-repos.md

@ -42,9 +42,9 @@ git push origin feature/new-feature @@ -42,9 +42,9 @@ git push origin feature/new-feature
### Default Branch
The default branch (usually `main`) can be changed via:
The default branch (usually `main`) can be viewed via:
- **Web Interface**: Repository settings
- **API**: `POST /api/repos/{npub}/{repo}/default-branch`
- **API**: `GET /api/repos/{npub}/{repo}/branches/default`
## File Management
@ -60,7 +60,7 @@ The default branch (usually `main`) can be changed via: @@ -60,7 +60,7 @@ The default branch (usually `main`) can be changed via:
#### Via API
```bash
GET /api/repos/{npub}/{repo}/file?path={file-path}&branch={branch}
GET /api/repos/{npub}/{repo}/files?path={file-path}&ref={branch}
```
#### Via CLI
@ -84,13 +84,11 @@ gitrep file get <npub> <repo> <path> [branch] @@ -84,13 +84,11 @@ gitrep file get <npub> <repo> <path> [branch]
#### Via API
```bash
POST /api/repos/{npub}/{repo}/file
POST /api/repos/{npub}/{repo}/files?path=file.txt
{
"path": "file.txt",
"content": "File content",
"commitMessage": "Add file",
"branch": "main",
"action": "write"
"branch": "main"
}
```
@ -112,12 +110,10 @@ gitrep file put <npub> <repo> <path> [file] [message] [branch] @@ -112,12 +110,10 @@ gitrep file put <npub> <repo> <path> [file] [message] [branch]
#### Via API
```bash
POST /api/repos/{npub}/{repo}/file
DELETE /api/repos/{npub}/{repo}/files?path=file.txt
{
"path": "file.txt",
"commitMessage": "Remove file",
"branch": "main",
"action": "delete"
"branch": "main"
}
```

16
docs/repo-operations.md

@ -92,8 +92,22 @@ The transfer process: @@ -92,8 +92,22 @@ The transfer process:
### Via CLI
Ownership transfers are done via the API. Use the publish command to create an ownership transfer event:
```bash
gitrep publish ownership-transfer <repo> <new-owner-npub> [--self-transfer]
```
Or use the API directly:
```bash
gitrep repos transfer <npub> <repo> <new-owner-npub>
# Get transfer history
curl https://{domain}/api/repos/{npub}/{repo}/transfers
# Initiate transfer (requires NIP-98 auth)
curl -X POST https://{domain}/api/repos/{npub}/{repo}/transfers \
-H "Authorization: Nostr <base64-event>" \
-H "Content-Type: application/json" \
-d '{"transferEvent": {...}}'
```
**Important**: Ownership transfers are permanent and create a chain of ownership events. The new owner will have full control.

1
nostr/commit-signatures.jsonl

@ -116,3 +116,4 @@ @@ -116,3 +116,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772227102,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fix"]],"content":"Signed commit: bug-fix","id":"0f366a0cc7c003f74e375f40e7c322781746d12829943df1287bf67f36e1330a","sig":"167177ccfeb053cd645e50e7d00450b847ecd65c305165777bcfbe39fd3f48ccc86b57fdd183d2a4b138d94d27d11e4f1c121d702b295d94b9aee0a8dc81a744"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772261455,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix zombie spawning on polling\nmake announcement commits non-blocking on repo provision"]],"content":"Signed commit: fix zombie spawning on polling\nmake announcement commits non-blocking on repo provision","id":"b0da119e7477b46f5d82be831693a92e117f25379476488f19351e2bac8f88b8","sig":"b8ca18e8215a9f5b3fc877ce113936c582353d44f8d03cdccd9f9ee70fb3e6fdd64db7cc6a3ca15339fb21b9ca87ea8471a38b587721a594a189d97cc2964ad9"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772264490,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","polling update"]],"content":"Signed commit: polling update","id":"42c1a2a63a4568c65d82d78701451b3b4363bdf9c8c57e804535b5f3f0d7b6fc","sig":"8e5f32ecb79da876ac41eba04c3b1541b21d039ae50d1b9fefa630d35f31c97dd29af64e4b695742fa7d4eaec17db8f4a066b4db99ce628aed596971975d4a87"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772267611,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor API"]],"content":"Signed commit: refactor API","id":"934f8809638cea0bc7b8158fca959bc60880e0cae9ab8ff653687313adcd2f57","sig":"c9d8e5b821ae8182f8d39599c50fd0a4db6040ead1d8d83730a608a1d94d5078770a6ccbfc525a98691e98fabd9f9d24f0298680fb564c6b76c2f34bed9889b5"}

2
src/hooks.server.ts

@ -114,7 +114,7 @@ export const handle: Handle = async ({ event, resolve }) => { @@ -114,7 +114,7 @@ export const handle: Handle = async ({ event, resolve }) => {
let rateLimitType = 'api';
if (url.pathname.startsWith('/api/git/')) {
rateLimitType = 'git';
} else if (url.pathname.startsWith('/api/repos/') && url.pathname.includes('/file')) {
} else if (url.pathname.startsWith('/api/repos/') && url.pathname.includes('/files')) {
rateLimitType = 'file';
} else if (url.pathname.startsWith('/api/search')) {
rateLimitType = 'search';

7
src/lib/components/PRDetail.svelte

@ -175,7 +175,7 @@ @@ -175,7 +175,7 @@
try {
// Load diff for the commit
const response = await fetch(
`/api/repos/${npub}/${repo}/diff?from=${pr.commitId}^&to=${pr.commitId}`
`/api/repos/${npub}/${repo}/diffs?from=${pr.commitId}^&to=${pr.commitId}`
);
if (response.ok) {
const data = await response.json();
@ -356,11 +356,10 @@ @@ -356,11 +356,10 @@
error = null;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/prs`, {
const response = await fetch(`/api/repos/${npub}/${repo}/pull-requests/${pr.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prId: pr.id,
prAuthor: pr.author,
status
})
@ -397,7 +396,7 @@ @@ -397,7 +396,7 @@
error = null;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/prs/${pr.id}/merge`, {
const response = await fetch(`/api/repos/${npub}/${repo}/pull-requests/${pr.id}/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

2276
src/routes/api/openapi.json/openapi.json

File diff suppressed because it is too large Load Diff

543
src/routes/api/repos/[npub]/[repo]/fork/+server.ts

@ -1,543 +0,0 @@ @@ -1,543 +0,0 @@
/**
* API endpoint for forking repositories
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { KIND, type NostrEvent } from '$lib/types/nostr.js';
import { getVisibility, getProjectRelays } from '$lib/utils/repo-visibility.js';
import { nip19 } from 'nostr-tools';
import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js';
import { existsSync } from 'fs';
import { rm } from 'fs/promises';
import { join, resolve } from 'path';
import simpleGit from 'simple-git';
import { isValidBranchName, validateRepoPath } from '$lib/utils/security.js';
import { ResourceLimits } from '$lib/services/security/resource-limits.js';
import { auditLogger } from '$lib/services/security/audit-logger.js';
import { ForkCountService } from '$lib/services/nostr/fork-count-service.js';
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js';
import { hasUnlimitedAccess } from '$lib/utils/user-access.js';
import logger from '$lib/services/logger.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js';
import { eventCache } from '$lib/services/nostr/event-cache.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js';
import { repoManager, nostrClient, forkCountService } from '$lib/services/service-registry.js';
// Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths)
const repoRootEnv = process.env.GIT_REPO_ROOT || '/repos';
const repoRoot = resolve(repoRootEnv);
const resourceLimits = new ResourceLimits(repoRoot);
/**
* Retry publishing an event with exponential backoff
* Attempts up to 3 times with delays: 1s, 2s, 4s
*/
async function publishEventWithRetry(
event: NostrEvent,
relays: string[],
eventName: string,
maxAttempts: number = 3,
context?: string
): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> {
let lastResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null;
// Extract context from event if available (for better logging)
const eventId = event.id.slice(0, 8);
const logContext = context || `[event:${eventId}]`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
logger.info({ logContext, eventName, attempt, maxAttempts }, `[Fork] Publishing ${eventName} - Attempt ${attempt}/${maxAttempts}...`);
lastResult = await nostrClient.publishEvent(event, relays);
if (lastResult.success.length > 0) {
logger.info({ logContext, eventName, successCount: lastResult.success.length, relays: lastResult.success }, `[Fork] ${eventName} published successfully`);
if (lastResult.failed.length > 0) {
logger.warn({ logContext, eventName, failed: lastResult.failed }, `[Fork] Some relays failed`);
}
return lastResult;
}
if (attempt < maxAttempts) {
const delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
logger.warn({ logContext, eventName, attempt, delayMs, failed: lastResult.failed }, `[Fork] ${eventName} failed on attempt ${attempt}. Retrying...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
// All attempts failed
logger.error({ logContext, eventName, maxAttempts, failed: lastResult?.failed }, `[Fork] ${eventName} failed after ${maxAttempts} attempts`);
return lastResult!;
}
/**
* POST - Fork a repository
* Body: { userPubkey, forkName? }
*/
export const POST: RequestHandler = async ({ params, request }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
const body = await request.json();
const { userPubkey, forkName, localOnly } = body;
if (!userPubkey) {
return error(401, 'Authentication required. Please provide userPubkey.');
}
// Validate localOnly parameter
const isLocalOnly = localOnly === true;
// Decode original repo owner npub
let originalOwnerPubkey: string;
try {
originalOwnerPubkey = requireNpubHex(npub);
} catch {
return error(400, 'Invalid npub format');
}
// Decode user pubkey if needed (must be done before using it)
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey;
// Convert to npub for resource check and path construction
const userNpub = nip19.npubEncode(userPubkeyHex);
// Determine fork name (use original name if not specified)
const forkRepoName = forkName || repo;
// Check if user has unlimited access (required for storing repos locally)
const userLevel = getCachedUserLevel(userPubkeyHex);
if (!hasUnlimitedAccess(userLevel?.level)) {
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
auditLogger.logRepoFork(
userPubkeyHex,
`${npub}/${repo}`,
`${userNpub}/${forkRepoName}`,
'failure',
'User does not have unlimited access'
);
return error(403, 'Repository creation requires unlimited access. Please verify you can write to at least one default Nostr relay.');
}
// Check resource limits before forking
const resourceCheck = await resourceLimits.canCreateRepo(userNpub);
if (!resourceCheck.allowed) {
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
auditLogger.logRepoFork(
userPubkeyHex,
`${npub}/${repo}`,
`${userNpub}/${forkRepoName}`,
'failure',
resourceCheck.reason
);
return error(403, resourceCheck.reason || 'Resource limit exceeded');
}
// Check if original repo exists
const originalRepoPath = join(repoRoot, npub, `${repo}.git`);
// Security: Ensure resolved path is within repoRoot
const originalPathValidation = validateRepoPath(originalRepoPath, repoRoot);
if (!originalPathValidation.valid) {
return error(403, originalPathValidation.error || 'Invalid repository path');
}
if (!existsSync(originalRepoPath)) {
return error(404, 'Original repository not found');
}
// Get original repo announcement (case-insensitive) with caching
const allAnnouncements = await fetchRepoAnnouncementsWithCache(nostrClient, originalOwnerPubkey, eventCache);
const originalAnnouncement = findRepoAnnouncement(allAnnouncements, repo);
if (!originalAnnouncement) {
return error(404, 'Original repository announcement not found');
}
// Check if fork already exists
const forkRepoPath = join(repoRoot, userNpub, `${forkRepoName}.git`);
// Security: Ensure resolved path is within repoRoot
const forkPathValidation = validateRepoPath(forkRepoPath, repoRoot);
if (!forkPathValidation.valid) {
return error(403, forkPathValidation.error || 'Invalid fork repository path');
}
if (existsSync(forkRepoPath)) {
return error(409, 'Fork already exists');
}
// Clone the repository using simple-git (safer than shell commands)
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
auditLogger.logRepoFork(
userPubkeyHex,
`${npub}/${repo}`,
`${userNpub}/${forkRepoName}`,
'success'
);
const git = simpleGit();
await git.clone(originalRepoPath, forkRepoPath, ['--bare']);
// Invalidate resource limit cache after creating repo
resourceLimits.invalidateCache(userNpub);
// Create fork announcement
const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543';
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1');
const protocol = isLocalhost ? 'http' : 'https';
const forkGitUrl = `${protocol}://${gitDomain}/${userNpub}/${forkRepoName}.git`;
// Get Tor .onion URL if available
const { getTorGitUrl } = await import('$lib/services/tor/hidden-service.js');
const torOnionUrl = await getTorGitUrl(userNpub, forkRepoName);
// Extract original clone URLs and earliest unique commit
const originalCloneUrls = originalAnnouncement.tags
.filter(t => t[0] === 'clone')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string')
.filter(url => {
// Exclude our domain and .onion URLs (we'll add our own if available)
if (url.includes(gitDomain)) return false;
if (url.includes('.onion')) return false;
return true;
}) as string[];
const earliestCommitTag = originalAnnouncement.tags.find(t => t[0] === 'r' && t[2] === 'euc');
const earliestCommit = earliestCommitTag?.[1];
// Get original repo name and description
const originalName = originalAnnouncement.tags.find(t => t[0] === 'name')?.[1] || repo;
const originalDescription = originalAnnouncement.tags.find(t => t[0] === 'description')?.[1] || '';
// Build clone URLs for fork - NEVER include localhost, only include public domain or Tor .onion
const forkCloneUrls: string[] = [];
// Add our domain URL only if it's NOT localhost (explicitly check the URL)
if (!isLocalhost && !forkGitUrl.includes('localhost') && !forkGitUrl.includes('127.0.0.1')) {
forkCloneUrls.push(forkGitUrl);
}
// Add Tor .onion URL if available
if (torOnionUrl) {
forkCloneUrls.push(torOnionUrl);
}
// Add original clone URLs
forkCloneUrls.push(...originalCloneUrls);
// Validate: If using localhost, require either Tor .onion URL or at least one other clone URL
if (isLocalhost && !torOnionUrl && originalCloneUrls.length === 0) {
return error(400, 'Cannot create fork with only localhost. The original repository must have at least one public clone URL, or you need to configure a Tor .onion address.');
}
// Preserve visibility and project-relay from original repo
const originalVisibility = getVisibility(originalAnnouncement);
const originalProjectRelays = getProjectRelays(originalAnnouncement);
// Build fork announcement tags
// Use standardized fork tag: ['fork', '30617:pubkey:d-tag']
const originalRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`;
const tags: string[][] = [
['d', forkRepoName],
['name', `${originalName} (fork)`],
['description', `Fork of ${originalName}${originalDescription ? `: ${originalDescription}` : ''}`],
['clone', ...forkCloneUrls],
['relays', ...DEFAULT_NOSTR_RELAYS],
['fork', originalRepoTag], // Standardized fork tag format
['p', originalOwnerPubkey], // Original owner
];
// Local-only forks are always private and marked as synthetic
if (isLocalOnly) {
tags.push(['visibility', 'private']);
tags.push(['local-only', 'true']); // Mark as synthetic/local-only
} else {
// Preserve visibility from original repo (defaults to public if not set)
if (originalVisibility !== 'public') {
tags.push(['visibility', originalVisibility]);
}
}
// Preserve project-relay tags from original repo
for (const relay of originalProjectRelays) {
tags.push(['project-relay', relay]);
}
// Add earliest unique commit if available
if (earliestCommit) {
tags.push(['r', earliestCommit, 'euc']);
}
// Create fork announcement event
const forkAnnouncementTemplate = {
kind: KIND.REPO_ANNOUNCEMENT,
pubkey: userPubkeyHex,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags
};
// Sign fork announcement
const signedForkAnnouncement = await signEventWithNIP07(forkAnnouncementTemplate);
// Security: Truncate npub in logs and create context (must be before use)
const truncatedNpub = userNpub.length > 16 ? `${userNpub.slice(0, 12)}...` : userNpub;
const truncatedOriginalNpub = npub.length > 16 ? `${npub.slice(0, 12)}...` : npub;
const context = `[${truncatedOriginalNpub}/${repo}${truncatedNpub}/${forkRepoName}]`;
let publishResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null;
let ownershipPublishResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null;
let signedOwnershipEvent: NostrEvent | null = null;
if (isLocalOnly) {
// Local-only fork: Skip publishing to Nostr relays
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, localOnly: true }, 'Creating local-only fork (not publishing to Nostr)');
publishResult = { success: [], failed: [] };
ownershipPublishResult = { success: [], failed: [] };
// For local-only forks, create a synthetic ownership event (not published)
const ownershipService = new OwnershipTransferService([]);
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName);
signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent);
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Created synthetic ownership event for local-only fork');
} else {
// Public fork: Publish to Nostr relays
const { outbox } = await getUserRelays(userPubkeyHex, nostrClient);
const combinedRelays = combineRelays(outbox);
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, relayCount: combinedRelays.length, relays: combinedRelays }, 'Starting fork process');
publishResult = await publishEventWithRetry(
signedForkAnnouncement,
combinedRelays,
'fork announcement',
3,
context
);
if (publishResult.success.length === 0) {
// Clean up repo if announcement failed
logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: publishResult.failed }, 'Fork announcement failed after all retries. Cleaning up repository.');
await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {});
const errorDetails = `All relays failed: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`;
return json({
success: false,
error: 'Failed to publish fork announcement to relays after 3 attempts',
details: errorDetails,
eventName: 'fork announcement'
}, { status: 500 });
}
// Create and publish initial ownership proof (self-transfer event)
// This MUST succeed for the fork to be valid - without it, there's no proof of ownership on Nostr
const ownershipService = new OwnershipTransferService(combinedRelays);
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName);
signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent);
ownershipPublishResult = await publishEventWithRetry(
signedOwnershipEvent,
combinedRelays,
'ownership transfer event',
3,
context
);
if (ownershipPublishResult.success.length === 0) {
// Clean up repo if ownership proof failed
logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: ownershipPublishResult.failed }, 'Ownership transfer event failed after all retries. Cleaning up repository and publishing deletion request.');
await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {});
// Publish deletion request (NIP-09) for the announcement since it's invalid without ownership proof
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Publishing deletion request for invalid fork announcement...');
const deletionRequest = {
kind: KIND.DELETION_REQUEST, // NIP-09: Event Deletion Request
pubkey: userPubkeyHex,
created_at: Math.floor(Date.now() / 1000),
content: 'Fork failed: ownership transfer event could not be published after 3 attempts. This announcement is invalid.',
tags: [
['a', `${KIND.REPO_ANNOUNCEMENT}:${userPubkeyHex}:${forkRepoName}`], // Reference to the repo announcement
['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted
]
};
const signedDeletionRequest = await signEventWithNIP07(deletionRequest);
const deletionResult = await publishEventWithRetry(
signedDeletionRequest,
combinedRelays,
'deletion request',
3,
context
);
if (deletionResult.success.length > 0) {
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Deletion request published successfully');
} else {
logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: deletionResult.failed }, 'Failed to publish deletion request');
}
const errorDetails = `Fork is invalid without ownership proof. All relays failed: ${ownershipPublishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}. Deletion request ${deletionResult.success.length > 0 ? 'published' : 'failed to publish'}.`;
return json({
success: false,
error: 'Failed to publish ownership transfer event to relays after 3 attempts',
details: errorDetails,
eventName: 'ownership transfer event'
}, { status: 500 });
}
}
// Provision the fork repo (this will create verification file and include self-transfer)
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, localOnly: isLocalOnly }, 'Provisioning fork repository...');
await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent || undefined, false);
// Save fork announcement to repo (offline papertrail) in nostr/repo-events.jsonl
try {
const { fileManager } = await import('$lib/services/service-registry.js');
// Save to repo if it exists locally (should exist after provisioning)
if (fileManager.repoExists(userNpub, forkRepoName)) {
// Get worktree to save to repo-events.jsonl
const defaultBranch = await fileManager.getDefaultBranch(userNpub, forkRepoName).catch(() => 'main');
const repoPath = fileManager.getRepoPath(userNpub, forkRepoName);
const workDir = await fileManager.getWorktree(repoPath, defaultBranch, userNpub, forkRepoName);
// Save to repo-events.jsonl
await fileManager.saveRepoEventToWorktree(workDir, signedForkAnnouncement as NostrEvent, 'announcement').catch(err => {
logger.debug({ error: err }, 'Failed to save fork announcement to repo-events.jsonl');
});
// Stage and commit the file
const workGit = simpleGit(workDir);
await workGit.add(['nostr/repo-events.jsonl']);
await workGit.commit(
`Add fork repository announcement: ${signedForkAnnouncement.id.slice(0, 16)}...`,
['nostr/repo-events.jsonl'],
{
'--author': `Nostr <${userPubkeyHex}@nostr>`
}
);
// Clean up worktree
await fileManager.removeWorktree(repoPath, workDir).catch(err => {
logger.debug({ error: err }, 'Failed to remove worktree after saving fork announcement');
});
}
} catch (err) {
// Log but don't fail - publishing to relays is more important
logger.warn({ error: err, npub: userNpub, repo: forkRepoName }, 'Failed to save fork announcement to repo');
}
logger.info({
operation: 'fork',
originalRepo: `${npub}/${repo}`,
forkRepo: `${userNpub}/${forkRepoName}`,
localOnly: isLocalOnly,
announcementId: signedForkAnnouncement.id,
ownershipTransferId: signedOwnershipEvent?.id,
announcementRelays: publishResult?.success.length || 0,
ownershipRelays: ownershipPublishResult?.success.length || 0
}, 'Fork completed successfully');
const message = isLocalOnly
? 'Local-only fork created successfully! This fork is private and only exists on this server.'
: `Repository forked successfully! Published to ${publishResult?.success.length || 0} relay(s) for announcement and ${ownershipPublishResult?.success.length || 0} relay(s) for ownership proof.`;
return json({
success: true,
fork: {
npub: userNpub,
repo: forkRepoName,
url: forkGitUrl,
localOnly: isLocalOnly,
announcementId: signedForkAnnouncement.id,
ownershipTransferId: signedOwnershipEvent?.id,
publishedTo: isLocalOnly ? null : {
announcement: publishResult?.success.length || 0,
ownershipTransfer: ownershipPublishResult?.success.length || 0
}
},
message
});
} catch (err) {
return handleApiError(err, { operation: 'fork', npub, repo }, 'Failed to fork repository');
}
};
/**
* GET - Get fork information
* Returns whether this is a fork and what it's forked from
*/
export const GET: RequestHandler = async ({ params }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
// Decode repo owner npub
let ownerPubkey: string;
try {
ownerPubkey = requireNpubHex(npub);
} catch {
return error(400, 'Invalid npub format');
}
// Get repo announcement (case-insensitive) with caching
const allAnnouncements = await fetchRepoAnnouncementsWithCache(nostrClient, ownerPubkey, eventCache);
const announcement = findRepoAnnouncement(allAnnouncements, repo);
if (!announcement) {
return error(404, 'Repository announcement not found');
}
// announcement is already set above
const isFork = announcement.tags.some(t => t[0] === 't' && t[1] === 'fork');
// Get original repo reference
const originalRepoTag = announcement.tags.find(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`));
const originalOwnerTag = announcement.tags.find(t => t[0] === 'p' && t[1] !== ownerPubkey);
let originalRepo: { npub: string; repo: string } | null = null;
if (originalRepoTag && originalRepoTag[1]) {
const match = originalRepoTag[1].match(new RegExp(`^${KIND.REPO_ANNOUNCEMENT}:([a-f0-9]{64}):(.+)$`));
if (match) {
const [, originalOwnerPubkey, originalRepoName] = match;
try {
const originalNpub = nip19.npubEncode(originalOwnerPubkey);
originalRepo = { npub: originalNpub, repo: originalRepoName };
} catch {
// Invalid pubkey
}
}
}
// Get fork count for this repo
let forkCount = 0;
if (!isFork && ownerPubkey && repo) {
try {
forkCount = await forkCountService.getForkCount(ownerPubkey, repo);
} catch (err) {
// Log but don't fail the request
const context = npub && repo ? `[${npub}/${repo}]` : '[unknown]';
logger.warn({ error: err, npub, repo }, `[Fork] ${context} Failed to get fork count`);
}
}
return json({
isFork,
originalRepo,
forkCount
});
} catch (err) {
return handleApiError(err, { operation: 'getForkInfo', npub, repo }, 'Failed to get fork information');
}
};

181
src/routes/api/repos/[npub]/[repo]/transfer/+server.ts

@ -1,181 +0,0 @@ @@ -1,181 +0,0 @@
/**
* API endpoint for transferring repository ownership
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { ownershipTransferService, nostrClient, fileManager } from '$lib/services/service-registry.js';
import { combineRelays } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { verifyEvent, nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js';
import type { RequestEvent } from '@sveltejs/kit';
import { handleApiError, handleValidationError, handleAuthorizationError } from '$lib/utils/error-handler.js';
import logger from '$lib/services/logger.js';
/**
* GET - Get current owner and transfer history
*/
export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => {
// Get current owner (may be different if transferred)
const currentOwner = await ownershipTransferService.getCurrentOwner(context.repoOwnerPubkey, context.repo);
// Fetch transfer events for history
const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${context.repoOwnerPubkey}:${context.repo}`;
const transferEvents = await nostrClient.fetchEvents([
{
kinds: [KIND.OWNERSHIP_TRANSFER],
'#a': [repoTag],
limit: 100
}
]);
// Sort by created_at descending
transferEvents.sort((a, b) => b.created_at - a.created_at);
return json({
originalOwner: context.repoOwnerPubkey,
currentOwner,
transferred: currentOwner !== context.repoOwnerPubkey,
transfers: transferEvents.map(event => {
const pTag = event.tags.find(t => t[0] === 'p');
return {
eventId: event.id,
from: event.pubkey,
to: pTag?.[1] || 'unknown',
timestamp: event.created_at,
createdAt: new Date(event.created_at * 1000).toISOString()
};
})
});
},
{ operation: 'getOwnership', requireRepoAccess: false } // Ownership info is public
);
/**
* POST - Initiate ownership transfer
* Requires a pre-signed NIP-98 authenticated event from the current owner
*/
export const POST: RequestHandler = withRepoValidation(
async ({ repoContext, requestContext, event }) => {
if (!requestContext.userPubkeyHex) {
throw handleApiError(new Error('Authentication required'), { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }, 'Authentication required');
}
const body = await event.request.json();
const { transferEvent } = body;
if (!transferEvent) {
return handleValidationError('Missing transferEvent in request body', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
}
// Verify the event is properly signed
if (!transferEvent.sig || !transferEvent.id) {
throw handleValidationError('Invalid event: missing signature or ID', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
}
if (!verifyEvent(transferEvent)) {
throw handleValidationError('Invalid event signature', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
}
// Verify user is the current owner
const canTransfer = await ownershipTransferService.canTransfer(
requestContext.userPubkeyHex,
repoContext.repoOwnerPubkey,
repoContext.repo
);
if (!canTransfer) {
throw handleAuthorizationError('Only the current repository owner can transfer ownership', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
}
// Verify the transfer event is from the current owner
if (transferEvent.pubkey !== requestContext.userPubkeyHex) {
throw handleAuthorizationError('Transfer event must be signed by the current owner', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
}
// Verify it's an ownership transfer event
if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) {
throw handleValidationError(`Event must be kind ${KIND.OWNERSHIP_TRANSFER} (ownership transfer)`, { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
}
// Verify the 'a' tag references this repo
const aTag = transferEvent.tags.find(t => t[0] === 'a');
const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${repoContext.repoOwnerPubkey}:${repoContext.repo}`;
if (!aTag || aTag[1] !== expectedRepoTag) {
throw handleValidationError("Transfer event 'a' tag does not match this repository", { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
}
// Get user's relays and publish
const { outbox } = await getUserRelays(requestContext.userPubkeyHex, nostrClient);
const combinedRelays = combineRelays(outbox);
const result = await nostrClient.publishEvent(transferEvent as NostrEvent, combinedRelays);
if (result.success.length === 0) {
throw handleApiError(new Error('Failed to publish transfer event to any relays'), { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish transfer event to any relays');
}
// Save transfer event to repo (offline papertrail - step 1 requirement)
try {
const transferEventContent = JSON.stringify(transferEvent, null, 2) + '\n';
// Use consistent filename pattern: .nostr-ownership-transfer-{eventId}.json
const transferFileName = `.nostr-ownership-transfer-${transferEvent.id}.json`;
// Save to repo if it exists locally
if (fileManager.repoExists(repoContext.npub, repoContext.repo)) {
// Get worktree to save to repo-events.jsonl
const defaultBranch = await fileManager.getDefaultBranch(repoContext.npub, repoContext.repo).catch(() => 'main');
const repoPath = fileManager.getRepoPath(repoContext.npub, repoContext.repo);
const workDir = await fileManager.getWorktree(repoPath, defaultBranch, repoContext.npub, repoContext.repo);
// Save to repo-events.jsonl (standard file for easy analysis)
await fileManager.saveRepoEventToWorktree(workDir, transferEvent as NostrEvent, 'transfer').catch(err => {
logger.debug({ error: err }, 'Failed to save transfer event to repo-events.jsonl');
});
// Also save individual transfer file
await fileManager.writeFile(
repoContext.npub,
repoContext.repo,
transferFileName,
transferEventContent,
`Add ownership transfer event: ${transferEvent.id.slice(0, 16)}...`,
'Nostr',
`${requestContext.userPubkeyHex}@nostr`,
defaultBranch
).catch(err => {
// Log but don't fail - publishing to relays is more important
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save transfer event to repo');
});
// Clean up worktree
await fileManager.removeWorktree(repoPath, workDir).catch(err => {
logger.debug({ error: err }, 'Failed to remove worktree after saving transfer event');
});
} else {
logger.debug({ npub: repoContext.npub, repo: repoContext.repo }, 'Repo does not exist locally, skipping transfer event save to repo');
}
} catch (err) {
// Log but don't fail - publishing to relays is more important
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save transfer event to repo');
}
// Clear cache so new owner is recognized immediately
ownershipTransferService.clearCache(repoContext.repoOwnerPubkey, repoContext.repo);
return json({
success: true,
event: transferEvent,
published: result,
message: 'Ownership transfer initiated successfully',
// Signal to client that page should refresh
refresh: true
});
},
{ operation: 'transferOwnership', requireRepoAccess: false } // Override to check owner instead
);

343
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -1,343 +0,0 @@ @@ -1,343 +0,0 @@
/**
* API endpoint for verifying repository ownership
*/
import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types';
import { fileManager } from '$lib/services/service-registry.js';
import { verifyRepositoryOwnership } from '$lib/services/nostr/repo-verification.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { nostrClient } from '$lib/services/service-registry.js';
import { KIND } from '$lib/types/nostr.js';
import { existsSync } from 'fs';
import { join } from 'path';
import { decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js';
import { eventCache } from '$lib/services/nostr/event-cache.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { AnnouncementManager } from '$lib/services/git/announcement-manager.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js';
import simpleGit from 'simple-git';
import logger from '$lib/services/logger.js';
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT
: '/repos';
export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => {
// Check if repository exists - verification doesn't require the repo to be cloned locally
// We can verify ownership from Nostr events alone
// Fetch the repository announcement (case-insensitive) with caching
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache);
const announcement = findRepoAnnouncement(allEvents, context.repo);
if (!announcement) {
return json({
verified: false,
error: 'Repository announcement not found',
message: 'Could not find a NIP-34 repository announcement for this repository.'
});
}
// Extract clone URLs from announcement
const cloneUrls: string[] = [];
for (const tag of announcement.tags) {
if (tag[0] === 'clone') {
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string') {
cloneUrls.push(url);
}
}
}
}
// Verify ownership for each clone separately
// Ownership is determined by the most recent announcement file checked into each clone
const cloneVerifications: Array<{ url: string; verified: boolean; ownerPubkey: string | null; error?: string }> = [];
// First, verify the local GitRepublic clone (if it exists)
let localVerified = false;
let localOwner: string | null = null;
let localError: string | undefined;
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
const repoExists = existsSync(repoPath);
if (repoExists) {
// Repo is cloned - verify the announcement file matches
try {
// Get current owner from the most recent announcement file in the repo
localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo);
if (localOwner) {
// Verify the announcement in nostr/repo-events.jsonl matches the announcement event
try {
const repoEventsFile = await fileManager.getFileContent(context.npub, context.repo, 'nostr/repo-events.jsonl', 'HEAD');
// Parse repo-events.jsonl and find the most recent announcement
const lines = repoEventsFile.content.trim().split('\n').filter(Boolean);
let repoAnnouncement: NostrEvent | null = null;
let latestTimestamp = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'announcement' && entry.event && entry.timestamp) {
if (entry.timestamp > latestTimestamp) {
latestTimestamp = entry.timestamp;
repoAnnouncement = entry.event;
}
}
} catch {
continue;
}
}
if (repoAnnouncement) {
const verification = verifyRepositoryOwnership(announcement, JSON.stringify(repoAnnouncement));
localVerified = verification.valid;
if (!verification.valid) {
localError = verification.error;
}
} else {
localVerified = false;
localError = 'No announcement found in nostr/repo-events.jsonl';
}
} catch (err) {
localVerified = false;
localError = 'Announcement file not found in repository';
}
} else {
localVerified = false;
localError = 'No announcement found in repository';
}
} catch (err) {
localVerified = false;
localError = err instanceof Error ? err.message : 'Failed to verify local clone';
}
} else {
// Repo is not cloned yet - verify from Nostr announcement alone
// The announcement pubkey must match the repo owner
if (announcement.pubkey === context.repoOwnerPubkey) {
localVerified = true;
localOwner = context.repoOwnerPubkey;
localError = undefined;
} else {
localVerified = false;
localOwner = announcement.pubkey;
localError = 'Announcement pubkey does not match repository owner';
}
}
// Add local clone verification
const localUrl = cloneUrls.find(url => url.includes(context.npub) || url.includes(context.repoOwnerPubkey));
if (localUrl) {
cloneVerifications.push({
url: localUrl,
verified: localVerified,
ownerPubkey: localOwner,
error: localError
});
}
// For other clones (GitHub, GitLab, etc.), we'd need to fetch them first to check their announcement files
// This is a future enhancement - for now we only verify the local GitRepublic clone
// Overall verification: at least one clone must be verified
const overallVerified = cloneVerifications.some(cv => cv.verified);
const verifiedClones = cloneVerifications.filter(cv => cv.verified);
const currentOwner = localOwner || context.repoOwnerPubkey;
if (overallVerified) {
return json({
verified: true,
announcementId: announcement.id,
ownerPubkey: currentOwner,
verificationMethod: 'announcement-file',
cloneVerifications: cloneVerifications.map(cv => ({
url: cv.url,
verified: cv.verified,
ownerPubkey: cv.ownerPubkey,
error: cv.error
})),
message: `Repository ownership verified successfully for ${verifiedClones.length} clone(s)`
});
} else {
return json({
verified: false,
error: localError || 'Repository ownership verification failed',
announcementId: announcement.id,
verificationMethod: 'announcement-file',
cloneVerifications: cloneVerifications.map(cv => ({
url: cv.url,
verified: cv.verified,
ownerPubkey: cv.ownerPubkey,
error: cv.error
})),
message: 'Repository ownership verification failed for all clones'
});
}
},
{ operation: 'verifyRepo', requireRepoExists: false, requireRepoAccess: false } // Verification is public, doesn't need repo to exist
);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
const announcementManager = new AnnouncementManager(repoRoot);
export const POST: RequestHandler = createRepoPostHandler(
async (context: RepoRequestContext, event: RequestEvent) => {
const requestContext = extractRequestContext(event);
const userPubkeyHex = requestContext.userPubkeyHex;
if (!userPubkeyHex) {
return error(401, 'Authentication required. Please provide userPubkey.');
}
// Check if user is a maintainer or the repository owner
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, context.repoOwnerPubkey, context.repo);
const isOwner = userPubkeyHex === context.repoOwnerPubkey;
if (!isMaintainer && !isOwner) {
return error(403, 'Only repository owners and maintainers can save announcements.');
}
// Check if repository is cloned
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
if (!existsSync(repoPath)) {
return error(404, 'Repository is not cloned locally. Please clone the repository first.');
}
// Fetch the repository announcement
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache);
const announcement = findRepoAnnouncement(allEvents, context.repo);
if (!announcement) {
return error(404, 'Repository announcement not found');
}
try {
// Check if repository has any commits
const git = simpleGit(repoPath);
let hasCommits = false;
// Use same default branch logic as repo-manager (master, or from env)
let defaultBranch = process.env.DEFAULT_BRANCH || 'master';
try {
const commitCount = await git.raw(['rev-list', '--count', '--all']);
hasCommits = parseInt(commitCount.trim(), 10) > 0;
} catch {
// If we can't check, assume no commits
hasCommits = false;
}
// If repository has commits, get the default branch
if (hasCommits) {
try {
defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo);
} catch {
// Fallback to default if getDefaultBranch fails
defaultBranch = process.env.DEFAULT_BRANCH || 'master';
}
}
// Get worktree for the default branch (worktree manager will create branch if needed)
logger.info({ npub: context.npub, repo: context.repo, branch: defaultBranch, hasCommits }, 'Getting worktree for announcement commit');
const worktreePath = await fileManager.getWorktree(repoPath, defaultBranch, context.npub, context.repo);
// Check if announcement already exists
const hasAnnouncement = await announcementManager.hasAnnouncementInRepo(worktreePath, announcement.id);
if (hasAnnouncement) {
// Announcement already exists, but we'll update it anyway to ensure it's the latest
logger.debug({ npub: context.npub, repo: context.repo, eventId: announcement.id }, 'Announcement already exists, updating anyway');
}
// Save announcement to worktree
const saved = await announcementManager.saveRepoEventToWorktree(worktreePath, announcement, 'announcement', false);
if (!saved) {
return error(500, 'Failed to save announcement to repository');
}
// Stage the file
const workGit = simpleGit(worktreePath);
await workGit.add('nostr/repo-events.jsonl');
// Get author info
let authorName = await fetchUserName(userPubkeyHex, requestContext.userPubkey || '', DEFAULT_NOSTR_RELAYS);
let authorEmail = await fetchUserEmail(userPubkeyHex, requestContext.userPubkey || '', DEFAULT_NOSTR_RELAYS);
if (!authorName) {
const { nip19 } = await import('nostr-tools');
const npub = requestContext.userPubkey || nip19.npubEncode(userPubkeyHex);
authorName = npub.substring(0, 20);
}
if (!authorEmail) {
const { nip19 } = await import('nostr-tools');
const npub = requestContext.userPubkey || nip19.npubEncode(userPubkeyHex);
authorEmail = `${npub.substring(0, 20)}@gitrepublic.web`;
}
// Commit the announcement
const commitMessage = `Verify repository ownership by committing repo announcement event\n\nEvent ID: ${announcement.id}`;
// For empty repositories, ensure the branch is set up in the worktree
if (!hasCommits) {
try {
// Check if branch exists in worktree
const currentBranch = await workGit.revparse(['--abbrev-ref', 'HEAD']).catch(() => null);
if (!currentBranch || currentBranch === 'HEAD') {
// Branch doesn't exist, create orphan branch in worktree
logger.debug({ npub: context.npub, repo: context.repo, branch: defaultBranch }, 'Creating orphan branch in worktree');
await workGit.raw(['checkout', '--orphan', defaultBranch]);
} else if (currentBranch !== defaultBranch) {
// Switch to the correct branch
logger.debug({ npub: context.npub, repo: context.repo, currentBranch, targetBranch: defaultBranch }, 'Switching to target branch in worktree');
await workGit.checkout(defaultBranch);
}
} catch (branchErr) {
logger.warn({ error: branchErr, npub: context.npub, repo: context.repo, branch: defaultBranch }, 'Branch setup in worktree failed, attempting commit anyway');
}
}
logger.info({ npub: context.npub, repo: context.repo, branch: defaultBranch, hasCommits }, 'Committing announcement file');
await workGit.commit(commitMessage, ['nostr/repo-events.jsonl'], {
'--author': `${authorName} <${authorEmail}>`
});
// Verify commit was created
const commitHash = await workGit.revparse(['HEAD']).catch(() => null);
if (!commitHash) {
throw new Error('Commit was created but HEAD is not pointing to a valid commit');
}
logger.info({ npub: context.npub, repo: context.repo, commitHash, branch: defaultBranch }, 'Announcement committed successfully');
// Push to default branch (if there's a remote)
try {
await workGit.push('origin', defaultBranch);
} catch (pushErr) {
// Push might fail if there's no remote, that's okay
logger.debug({ error: pushErr, npub: context.npub, repo: context.repo }, 'Push failed (may not have remote)');
}
// Clean up worktree
await fileManager.removeWorktree(repoPath, worktreePath);
return json({
success: true,
message: 'Repository announcement committed successfully. Verification should update shortly.',
announcementId: announcement.id
});
} catch (err) {
logger.error({ error: err, npub: context.npub, repo: context.repo }, 'Failed to commit announcement for verification');
return handleApiError(err, { operation: 'verifyRepoCommit', npub: context.npub, repo: context.repo }, 'Failed to commit announcement');
}
},
{ operation: 'verifyRepoCommit', requireRepoExists: true, requireRepoAccess: true }
);

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

@ -407,7 +407,7 @@ @@ -407,7 +407,7 @@
const loadCloneUrlReachability = (forceRefresh = false) => loadCloneUrlReachabilityService(forceRefresh, state, repoCloneUrls);
const loadForkInfo = async () => {
try {
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/fork`, { headers: buildApiHeaders() });
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/forks`, { headers: buildApiHeaders() });
if (response.ok) state.fork.info = await response.json();
} catch (err) {
console.error('Error loading fork info:', err);
@ -1603,9 +1603,9 @@ @@ -1603,9 +1603,9 @@
// Pre-fill download URL with full URL
if (typeof window !== 'undefined') {
const origin = window.location.origin;
state.forms.release.downloadUrl = `${origin}/api/repos/${state.npub}/${state.repo}/download?ref=${encodeURIComponent(tagName)}&format=zip`;
state.forms.release.downloadUrl = `${origin}/api/repos/${state.npub}/${state.repo}/archive?ref=${encodeURIComponent(tagName)}&format=zip`;
} else {
state.forms.release.downloadUrl = `/api/repos/${state.npub}/${state.repo}/download?ref=${encodeURIComponent(tagName)}&format=zip`;
state.forms.release.downloadUrl = `/api/repos/${state.npub}/${state.repo}/archive?ref=${encodeURIComponent(tagName)}&format=zip`;
}
state.openDialog = 'createRelease';
}}
@ -1704,11 +1704,10 @@ @@ -1704,11 +1704,10 @@
}
try {
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/prs`, {
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/pull-requests/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prId: id,
prAuthor: pr.author,
status
})

4
src/routes/repos/[npub]/[repo]/components/DocsTab.svelte

@ -98,7 +98,7 @@ @@ -98,7 +98,7 @@
// Now check for docs folder in the background
try {
const response = await fetch(`/api/repos/${npub}/${repo}/tree?ref=${currentBranch || 'HEAD'}&path=docs`);
const response = await fetch(`/api/repos/${npub}/${repo}/files?action=tree&ref=${currentBranch || 'HEAD'}&path=docs`);
if (response.ok) {
const data = await response.json();
const docsFiles = Array.isArray(data) ? data : (data.files || []);
@ -141,7 +141,7 @@ @@ -141,7 +141,7 @@
documentationTitle = null;
indexEvent = null;
const response = await fetch(`/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(path)}&ref=${currentBranch || 'HEAD'}`);
const response = await fetch(`/api/repos/${npub}/${repo}/files?path=${encodeURIComponent(path)}&format=raw&ref=${currentBranch || 'HEAD'}`);
if (response.ok) {
const content = await response.text();
documentationContent = content;

2
src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte

@ -54,7 +54,7 @@ @@ -54,7 +54,7 @@
imagePath = normalizedPath.join('/');
// Build API URL
const apiUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(branch)}`;
const apiUrl = `/api/repos/${npub}/${repo}/files?path=${encodeURIComponent(imagePath)}&format=raw&ref=${encodeURIComponent(branch)}`;
return `<img${before} src="${apiUrl}"${after}>`;
});

2
src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte

@ -27,7 +27,7 @@ @@ -27,7 +27,7 @@
</label>
<label>
Download URL (optional):
<input type="url" bind:value={state.forms.release.downloadUrl} placeholder="/api/repos/.../download?ref=..." />
<input type="url" bind:value={state.forms.release.downloadUrl} placeholder="/api/repos/.../archive?ref=..." />
<small class="field-hint">Pre-filled with the ZIP download URL for this tag. You can change it if needed.</small>
</label>
<label>

6
src/routes/repos/[npub]/[repo]/hooks/use-repo-api.ts

@ -29,7 +29,7 @@ export async function loadFiles(options: LoadFilesOptions): Promise<Array<{ name @@ -29,7 +29,7 @@ export async function loadFiles(options: LoadFilesOptions): Promise<Array<{ name
try {
logger.operation('Loading files', { npub, repo, branch, path });
const url = `/api/repos/${npub}/${repo}/tree?ref=${branch}${path ? `&path=${encodeURIComponent(path)}` : ''}`;
const url = `/api/repos/${npub}/${repo}/files?action=tree&ref=${branch}${path ? `&path=${encodeURIComponent(path)}` : ''}`;
const response = await fetch(url, {
headers: buildApiHeaders()
});
@ -56,7 +56,7 @@ export async function loadFile(options: LoadFileOptions): Promise<{ content: str @@ -56,7 +56,7 @@ export async function loadFile(options: LoadFileOptions): Promise<{ content: str
try {
logger.operation('Loading file', { npub, repo, branch, filePath });
const url = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(filePath)}&ref=${branch}`;
const url = `/api/repos/${npub}/${repo}/files?path=${encodeURIComponent(filePath)}&format=raw&ref=${branch}`;
const response = await fetch(url, {
headers: buildApiHeaders()
});
@ -162,7 +162,7 @@ export async function loadPRs(npub: string, repo: string): Promise<Array<any>> { @@ -162,7 +162,7 @@ export async function loadPRs(npub: string, repo: string): Promise<Array<any>> {
try {
logger.operation('Loading PRs', { npub, repo });
const response = await fetch(`/api/repos/${npub}/${repo}/prs`, {
const response = await fetch(`/api/repos/${npub}/${repo}/pull-requests`, {
headers: buildApiHeaders()
});

2
src/routes/repos/[npub]/[repo]/services/branch-operations.ts

@ -131,7 +131,7 @@ export async function loadBranches( @@ -131,7 +131,7 @@ export async function loadBranches(
// Fetch the actual default branch from the API
try {
const defaultBranchData = await apiRequest<{ defaultBranch?: string; branch?: string }>(
`/api/repos/${state.npub}/${state.repo}/default-branch`
`/api/repos/${state.npub}/${state.repo}/branches/default`
);
state.git.defaultBranch = defaultBranchData.defaultBranch || defaultBranchData.branch || null;
} catch (err) {

5
src/routes/repos/[npub]/[repo]/services/code-search-operations.ts

@ -27,9 +27,8 @@ export async function performCodeSearch( @@ -27,9 +27,8 @@ export async function performCodeSearch(
: '';
// For "All Repositories", don't pass repo filter - let it search all repos
const url = state.codeSearch.scope === 'repo'
? `/api/repos/${state.npub}/${state.repo}/code-search?q=${encodeURIComponent(state.codeSearch.query.trim())}${branchParam}`
: `/api/code-search?q=${encodeURIComponent(state.codeSearch.query.trim())}`;
const repoParam = state.codeSearch.scope === 'repo' ? `&repo=${encodeURIComponent(`${state.npub}/${state.repo}`)}` : '';
const url = `/api/search?type=code&q=${encodeURIComponent(state.codeSearch.query.trim())}${repoParam}${branchParam}`;
const data = await apiRequest<Array<any>>(url);
state.codeSearch.results = Array.isArray(data) ? data : [];

4
src/routes/repos/[npub]/[repo]/services/commit-operations.ts

@ -85,7 +85,7 @@ export async function verifyCommit( @@ -85,7 +85,7 @@ export async function verifyCommit(
authorEmail?: string;
timestamp?: number;
eventId?: string;
}>(`/api/repos/${state.npub}/${state.repo}/commits/${commitHash}/verify`);
}>(`/api/repos/${state.npub}/${state.repo}/commits/${commitHash}/verification`);
// Only update verification if there's actually a signature
// If hasSignature is false or undefined, don't set verification at all
@ -127,7 +127,7 @@ export async function viewDiff( @@ -127,7 +127,7 @@ export async function viewDiff(
additions: number;
deletions: number;
diff: string;
}>>(`/api/repos/${state.npub}/${state.repo}/diff?from=${parentHash}&to=${commitHash}`);
}>>(`/api/repos/${state.npub}/${state.repo}/diffs?from=${parentHash}&to=${commitHash}`);
state.git.diffData = diffData;
state.git.showDiff = true;

37
src/routes/repos/[npub]/[repo]/services/file-operations.ts

@ -72,8 +72,7 @@ export async function saveFile( @@ -72,8 +72,7 @@ export async function saveFile(
}
}
await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, {
path: state.files.currentFile,
await apiPost(`/api/repos/${state.npub}/${state.repo}/files?path=${encodeURIComponent(state.files.currentFile)}`, {
content: state.files.editedContent,
commitMessage: state.forms.commit.message.trim(),
authorName: authorName,
@ -150,14 +149,12 @@ export async function createFile( @@ -150,14 +149,12 @@ export async function createFile(
}
}
await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, {
path: filePath,
await apiPost(`/api/repos/${state.npub}/${state.repo}/files?path=${encodeURIComponent(filePath)}`, {
content: state.forms.file.content,
commitMessage: commitMsg,
authorName: authorName,
authorEmail: authorEmail,
branch: state.git.currentBranch,
action: 'create',
userPubkey: state.user.pubkey,
commitSignatureEvent: commitSignatureEvent
});
@ -232,15 +229,16 @@ export async function deleteFile( @@ -232,15 +229,16 @@ export async function deleteFile(
}
}
await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, {
path: filePath,
commitMessage: commitMsg,
authorName: authorName,
authorEmail: authorEmail,
branch: state.git.currentBranch,
action: 'delete',
userPubkey: state.user.pubkey,
commitSignatureEvent: commitSignatureEvent
await apiRequest(`/api/repos/${state.npub}/${state.repo}/files?path=${encodeURIComponent(filePath)}`, {
method: 'DELETE',
body: JSON.stringify({
commitMessage: commitMsg,
authorName: authorName,
authorEmail: authorEmail,
branch: state.git.currentBranch,
userPubkey: state.user.pubkey,
commitSignatureEvent: commitSignatureEvent
})
});
// Clear current file if it was deleted
@ -423,7 +421,7 @@ export async function loadFiles( @@ -423,7 +421,7 @@ export async function loadFiles(
}
const data = await apiRequest<Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>>(
`/api/repos/${state.npub}/${state.repo}/tree?ref=${encodeURIComponent(branchName)}&path=${encodeURIComponent(path)}`
`/api/repos/${state.npub}/${state.repo}/files?action=tree&ref=${encodeURIComponent(branchName)}&path=${encodeURIComponent(path)}`
);
state.files.list = data;
@ -555,7 +553,7 @@ export async function loadFile( @@ -555,7 +553,7 @@ export async function loadFile(
if (state.preview.file.isImage) {
// For image files, construct the raw file URL and skip loading text content
state.preview.file.imageUrl = `/api/repos/${state.npub}/${state.repo}/raw?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`;
state.preview.file.imageUrl = `/api/repos/${state.npub}/${state.repo}/files?path=${encodeURIComponent(filePath)}&format=raw&ref=${encodeURIComponent(branchName)}`;
state.files.content = ''; // Clear content for images
state.files.editedContent = ''; // Clear edited content for images
state.preview.file.html = ''; // Clear HTML for images
@ -568,7 +566,7 @@ export async function loadFile( @@ -568,7 +566,7 @@ export async function loadFile(
state.preview.file.imageUrl = null;
const data = await apiRequest<{ content: string }>(
`/api/repos/${state.npub}/${state.repo}/file?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`
`/api/repos/${state.npub}/${state.repo}/files?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`
);
state.files.content = data.content;
@ -712,10 +710,9 @@ export async function autoSaveFile( @@ -712,10 +710,9 @@ export async function autoSaveFile(
}
}
await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, {
path: state.files.currentFile,
await apiPost(`/api/repos/${state.npub}/${state.repo}/files?path=${encodeURIComponent(state.files.currentFile)}`, {
content: state.files.editedContent,
message: autoCommitMessage,
commitMessage: autoCommitMessage,
authorName: authorName,
authorEmail: authorEmail,
branch: state.git.currentBranch,

2
src/routes/repos/[npub]/[repo]/services/pr-operations.ts

@ -34,7 +34,7 @@ export async function loadPRs( @@ -34,7 +34,7 @@ export async function loadPRs(
created_at: number;
commitId?: string;
kind?: number;
}>>(`/api/repos/${state.npub}/${state.repo}/prs`);
}>>(`/api/repos/${state.npub}/${state.repo}/pull-requests`);
state.prs = data.map((pr) => ({
id: pr.id,

12
src/routes/repos/[npub]/[repo]/services/repo-operations.ts

@ -263,7 +263,7 @@ export async function forkRepository( @@ -263,7 +263,7 @@ export async function forkRepository(
error?: string;
details?: string;
eventName?: string;
}>(`/api/repos/${state.npub}/${state.repo}/fork`, {
}>(`/api/repos/${state.npub}/${state.repo}/forks`, {
userPubkey: state.user.pubkey,
localOnly
});
@ -463,7 +463,7 @@ export async function checkVerification( @@ -463,7 +463,7 @@ export async function checkVerification(
error?: string;
message?: string;
cloneVerifications?: Array<{ url: string; verified: boolean; ownerPubkey: string | null; error?: string }>;
}>(`/api/repos/${state.npub}/${state.repo}/verify`);
}>(`/api/repos/${state.npub}/${state.repo}/verification`);
console.log('[Verification] Response:', data);
state.verification.status = {
@ -576,7 +576,7 @@ export async function loadForkInfo( @@ -576,7 +576,7 @@ export async function loadForkInfo(
npub: string;
repo: string;
};
}>(`/api/repos/${state.npub}/${state.repo}/fork`);
}>(`/api/repos/${state.npub}/${state.repo}/forks`);
if (data.isFork && data.originalRepo) {
state.fork.info = {
@ -787,8 +787,8 @@ export async function saveAnnouncementToRepo( @@ -787,8 +787,8 @@ export async function saveAnnouncementToRepo(
state.creating.announcement = true;
state.error = null;
// Use the existing verify endpoint which saves and commits the announcement
const data = await apiRequest<{ message?: string; announcementId?: string }>(`/api/repos/${state.npub}/${state.repo}/verify`, {
// Use the existing verification endpoint which saves and commits the announcement
const data = await apiRequest<{ message?: string; announcementId?: string }>(`/api/repos/${state.npub}/${state.repo}/verification`, {
method: 'POST'
} as RequestInit);
@ -845,7 +845,7 @@ export async function verifyCloneUrl( @@ -845,7 +845,7 @@ export async function verifyCloneUrl(
state.error = null;
try {
const data = await apiRequest<{ message?: string }>(`/api/repos/${state.npub}/${state.repo}/verify`, {
const data = await apiRequest<{ message?: string }>(`/api/repos/${state.npub}/${state.repo}/verification`, {
method: 'POST'
} as RequestInit);

2
src/routes/repos/[npub]/[repo]/utils/download.ts

@ -71,7 +71,7 @@ export async function downloadRepository(options: DownloadOptions): Promise<void @@ -71,7 +71,7 @@ export async function downloadRepository(options: DownloadOptions): Promise<void
params.set('ref', ref);
}
params.set('format', 'zip');
const downloadUrl = `/api/repos/${npub}/${repo}/download?${params.toString()}`;
const downloadUrl = `/api/repos/${npub}/${repo}/archive?${params.toString()}`;
logger.info({ url: downloadUrl, ref }, '[Download] Starting download');

2
src/routes/repos/[npub]/[repo]/utils/file-processing.ts

@ -181,7 +181,7 @@ export function rewriteImagePaths( @@ -181,7 +181,7 @@ export function rewriteImagePaths(
// Build API URL if npub, repo, and branch are provided
if (npub && repo) {
const ref = branch || 'HEAD';
const apiUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(ref)}`;
const apiUrl = `/api/repos/${npub}/${repo}/files?path=${encodeURIComponent(imagePath)}&format=raw&ref=${encodeURIComponent(ref)}`;
const before = beforeAttrs ? beforeAttrs.trim() : '';
return `<img${before ? ' ' + before : ''} src="${apiUrl}"${afterAttrs}>`;
}

Loading…
Cancel
Save