3 Commits

23 changed files with 6754 additions and 219 deletions
+9
View File
@@ -41,6 +41,15 @@ NATS_URL=nats://localhost:4222
# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem
# ARTIFACT_SIGNING_KEY=
# ─── SSH Server ────────────────────────────────────────────────────────────────
# Hostname shown in SSH clone URLs. Auto-detected from INSTANCE_URL or request
# Host header when empty.
# SSH_HOST=ssh.example.com
# SSH_PORT=2222
# Path to PEM-encoded SSH host key. If empty, an ephemeral RSA-4096 key is
# generated at startup (host key changes on restart — warns clients).
# SSH_HOST_KEY_PATH=
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
# Root directory for the OCI Distribution Spec blob and upload storage.
OCI_ROOT=/var/lib/forgebucket/oci
-2
View File
@@ -13,6 +13,4 @@ uploads
# Database
*.db
ai_agent_master_prompt_for_building_modern_git_platform.md
html docs/
+4
View File
@@ -21,6 +21,7 @@ import (
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/domain/gitops"
"github.com/forgeo/forgebucket/internal/domain/oci"
"github.com/forgeo/forgebucket/internal/domain/sshserver"
"github.com/forgeo/forgebucket/internal/domain/scanning"
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
"github.com/forgeo/forgebucket/internal/domain/sbom"
@@ -94,6 +95,9 @@ func main() {
go observability.StartNATSWatcher(ciCtx, bus)
sshSrv := sshserver.New(engine, cfg)
go sshSrv.ListenAndServe(ciCtx) //nolint:errcheck
// Initialise artifact signing key store.
var keyStore *signing.KeyStore
if cfg.ArtifactSigningKey != "" {
+1
View File
@@ -40,6 +40,7 @@ services:
DATABASE_URL: postgres://forgebucket:forgebucket@postgres:5432/forgebucket?sslmode=disable
ports:
- "8080:8080"
- "2222:22"
volumes:
- fb_repo_data:/tmp/forgebucket/repos
- fb_oci_data:/tmp/forgebucket/oci
+5156
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -10,8 +10,25 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-cpp": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-java": "^6.0.0",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/lang-rust": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.0.0",
"@codemirror/merge": "^6.12.1",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/theme-one-dark": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@tanstack/react-query": "^5.100.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+34
View File
@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
const langStatSchema = z.object({
name: z.string(),
color: z.string(),
count: z.number(),
pct: z.number(),
})
const contributorSchema = z.object({
name: z.string(),
commits: z.number(),
})
const insightsSchema = z.object({
languages: z.array(langStatSchema),
contributors: z.array(contributorSchema),
totalCommits: z.number(),
})
export type LangStat = z.infer<typeof langStatSchema>
export type Contributor = z.infer<typeof contributorSchema>
export type RepoInsights = z.infer<typeof insightsSchema>
export function useRepoInsights(owner: string, repo: string) {
return useQuery<RepoInsights>({
queryKey: ['repos', owner, repo, 'insights'],
queryFn: () => api.get<RepoInsights>(`/api/v1/repos/${owner}/${repo}/insights`, insightsSchema),
enabled: !!owner && !!repo,
staleTime: 5 * 60 * 1000, // 5 min — git stats don't change on every page load
})
}
+23
View File
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
export interface InstanceConfig {
sshHost: string
sshPort: string
instanceName: string
}
const instanceSchema = z.object({
sshHost: z.string(),
sshPort: z.string(),
instanceName: z.string(),
})
export function useInstance() {
return useQuery<InstanceConfig>({
queryKey: ['instance'],
queryFn: () => api.get<InstanceConfig>('/api/v1/instance', instanceSchema),
staleTime: Infinity,
})
}
+36
View File
@@ -194,6 +194,42 @@ export function useCreateRepo() {
})
}
const latestDeploymentSchema = z.object({
envName: z.string(),
status: z.string(),
sha: z.string(),
finishedAt: z.string().nullable().optional(),
})
const pipelineRunSchema = z.object({
id: z.number(),
status: z.string(),
triggerRef: z.string().optional(),
startedAt: z.string().nullable().optional(),
finishedAt: z.string().nullable().optional(),
}).nullable()
const repoHealthSchema = z.object({
ciPassRate7d: z.number(),
totalRuns7d: z.number(),
latestRun: pipelineRunSchema.optional(),
latestDeployments: z.array(latestDeploymentSchema),
openDriftCount: z.number(),
openPRCount: z.number(),
})
export type RepoHealth = z.infer<typeof repoHealthSchema>
export function useRepoHealth(owner: string, repo: string) {
return useQuery<RepoHealth>({
queryKey: ['repos', owner, repo, 'health'],
queryFn: () =>
api.get<RepoHealth>(`/api/v1/repos/${owner}/${repo}/health`, repoHealthSchema),
enabled: Boolean(owner && repo),
staleTime: 60 * 1000, // 1 min
})
}
export function useImportRepo() {
const queryClient = useQueryClient()
return useMutation({
+181 -60
View File
@@ -1,5 +1,8 @@
import { Link } from 'react-router-dom'
import { useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { useRepoTree } from '../../api/queries/repos'
import { getCSRFToken } from '../../api/client'
import { Skeleton } from '../../ui/Skeleton'
interface TreeBrowserProps {
@@ -32,23 +35,141 @@ function formatSize(bytes: number): string {
}
export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: entries, isLoading, isError } = useRepoTree(owner, repo, ref, path)
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
const zipInputRef = useRef<HTMLInputElement>(null)
const [uploadStatus, setUploadStatus] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
async function handleUpload(files: FileList | null, isZip = false) {
if (!files || files.length === 0) return
setUploading(true)
setUploadStatus(`Uploading ${isZip ? 'archive' : `${files.length} file${files.length > 1 ? 's' : ''}`}`)
try {
const csrfToken = await getCSRFToken()
const form = new FormData()
form.append('branch', ref || 'main')
form.append('message', isZip ? 'Upload archive' : `Upload ${files.length} file${files.length > 1 ? 's' : ''}`)
if (isZip) {
form.append('zip', files[0])
} else {
for (const file of Array.from(files)) {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
const f = new File([file], relativePath, { type: file.type })
form.append('file[]', f, relativePath)
}
}
const res = await fetch(`/api/v1/repos/${owner}/${repo}/upload`, {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': csrfToken },
body: form,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || 'Upload failed')
}
const result = await res.json()
setUploadStatus(`Committed ${result.committed} file${result.committed !== 1 ? 's' : ''}`)
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'tree'] })
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'files'] })
setTimeout(() => setUploadStatus(null), 3000)
} catch (err) {
setUploadStatus(`Error: ${(err as Error).message}`)
setTimeout(() => setUploadStatus(null), 5000)
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
if (folderInputRef.current) folderInputRef.current.value = ''
if (zipInputRef.current) zipInputRef.current.value = ''
}
}
if (isLoading) return <TreeSkeleton />
if (isError) return <p className="text-xs text-[var(--c-danger)] p-4">Failed to load file tree.</p>
if (!entries?.length) return (
<div className="border border-dashed border-[var(--c-border)] rounded p-6 text-center text-xs text-[var(--c-muted)]">
No files yet push your first commit to see them here.
</div>
)
const dirs = entries.filter(e => e.type === 'tree').sort((a, b) => a.name.localeCompare(b.name))
const files = entries.filter(e => e.type === 'blob').sort((a, b) => a.name.localeCompare(b.name))
const dirs = (entries ?? []).filter(e => e.type === 'tree').sort((a, b) => a.name.localeCompare(b.name))
const files = (entries ?? []).filter(e => e.type === 'blob').sort((a, b) => a.name.localeCompare(b.name))
const sorted = [...dirs, ...files]
return (
<div className="border border-[var(--c-border)] rounded overflow-hidden bg-[var(--c-surface)]">
{/* Path breadcrumb inside tree */}
{/* Upload toolbar */}
<div className="flex items-center gap-2 px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] flex-wrap">
<button
onClick={() => navigate(`/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref || 'main')}&new=true`)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)]"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New file
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
Upload files
</button>
<button
onClick={() => folderInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25" />
</svg>
Upload folder
</button>
<button
onClick={() => zipInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" />
</svg>
Upload ZIP
</button>
{uploadStatus && (
<span className={`text-xs ml-1 ${uploadStatus.startsWith('Error') ? 'text-[var(--c-danger)]' : 'text-[var(--c-success)]'}`}>
{uploadStatus}
</span>
)}
{/* Hidden file inputs */}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={e => handleUpload(e.target.files)} />
<input
ref={folderInputRef}
type="file"
className="hidden"
// @ts-expect-error webkitdirectory is not in React types
webkitdirectory=""
multiple
onChange={e => handleUpload(e.target.files)}
/>
<input ref={zipInputRef} type="file" accept=".zip" className="hidden" onChange={e => handleUpload(e.target.files, true)} />
</div>
{/* Path breadcrumb */}
{path && (
<div className="flex items-center gap-1 px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] text-xs text-[var(--c-muted)]">
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{repo}</Link>
@@ -67,58 +188,58 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
</div>
)}
<table className="w-full text-sm border-collapse">
<colgroup>
<col className="w-auto" />
<col className="w-48 hidden sm:table-column" />
<col className="w-28" />
</colgroup>
<tbody>
{sorted.map(entry => {
const entryPath = path ? `${path}/${entry.name}` : entry.name
const isDir = entry.type === 'tree'
const href = isDir
? `/repos/${owner}/${repo}?path=${encodeURIComponent(entryPath)}&ref=${ref}`
: `/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(entryPath)}`
{sorted.length === 0 ? (
<div className="p-6 text-center text-xs text-[var(--c-muted)]">
No files yet push your first commit or upload files above.
</div>
) : (
<table className="w-full text-sm border-collapse">
<colgroup>
<col className="w-auto" />
<col className="w-48 hidden sm:table-column" />
<col className="w-28" />
</colgroup>
<tbody>
{sorted.map(entry => {
const entryPath = path ? `${path}/${entry.name}` : entry.name
const isDir = entry.type === 'tree'
const href = isDir
? `/repos/${owner}/${repo}?path=${encodeURIComponent(entryPath)}&ref=${ref}`
: `/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(entryPath)}`
return (
<tr key={entry.hash} className="border-b border-[var(--c-border)] last:border-b-0 hover:bg-[var(--c-surface-raised)]">
{/* Name */}
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{isDir ? (
<svg width="16" height="16" fill="var(--c-brand)" viewBox="0 0 24 24" className="shrink-0">
<path d="M19.5 21h-15A2.25 2.25 0 0 1 2.25 18.75V6.75A2.25 2.25 0 0 1 4.5 4.5h4.086c.398 0 .779.158 1.06.44l1.415 1.414c.28.281.661.44 1.06.44H19.5A2.25 2.25 0 0 1 21.75 9v9.75A2.25 2.25 0 0 1 19.5 21Z" />
</svg>
) : (
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
)}
<Link
to={href}
className={isDir ? 'text-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}
>
{entry.name}
</Link>
{!isDir && entry.size > 0 && (
<span className="text-[10px] text-[var(--c-muted)] hidden sm:inline">{formatSize(entry.size)}</span>
)}
</div>
</td>
{/* Commit message */}
<td className="px-3 py-2 text-xs text-[var(--c-muted)] truncate max-w-0 hidden sm:table-cell">
<span className="truncate block" title={entry.commitMsg}>{entry.commitMsg}</span>
</td>
{/* Date */}
<td className="px-3 py-2 text-xs text-[var(--c-muted)] whitespace-nowrap text-right">
{relativeTime(entry.commitDate)}
</td>
</tr>
)
})}
</tbody>
</table>
return (
<tr key={entry.hash} className="border-b border-[var(--c-border)] last:border-b-0 hover:bg-[var(--c-surface-raised)]">
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{isDir ? (
<svg width="16" height="16" fill="var(--c-brand)" viewBox="0 0 24 24" className="shrink-0">
<path d="M19.5 21h-15A2.25 2.25 0 0 1 2.25 18.75V6.75A2.25 2.25 0 0 1 4.5 4.5h4.086c.398 0 .779.158 1.06.44l1.415 1.414c.28.281.661.44 1.06.44H19.5A2.25 2.25 0 0 1 21.75 9v9.75A2.25 2.25 0 0 1 19.5 21Z" />
</svg>
) : (
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
)}
<Link to={href} className={isDir ? 'text-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}>
{entry.name}
</Link>
{!isDir && entry.size > 0 && (
<span className="text-[10px] text-[var(--c-muted)] hidden sm:inline">{formatSize(entry.size)}</span>
)}
</div>
</td>
<td className="px-3 py-2 text-xs text-[var(--c-muted)] truncate max-w-0 hidden sm:table-cell">
<span className="truncate block" title={entry.commitMsg}>{entry.commitMsg}</span>
</td>
<td className="px-3 py-2 text-xs text-[var(--c-muted)] whitespace-nowrap text-right">
{relativeTime(entry.commitDate)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
)
}
+4
View File
@@ -43,6 +43,10 @@
--c-warning: #FBBF24;
}
html {
font-size: 17px;
}
body {
margin: 0;
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
+180 -138
View File
@@ -4,6 +4,8 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useRepo, useRepoBlob, useUpdateBlob } from '../api/queries/repos'
import { RepoListSkeleton } from '../ui/Skeleton'
import { CodeEditor } from '../components/repos/CodeEditor'
import { FileSideTree } from '../components/repos/FileSideTree'
export default function BlobPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
@@ -14,12 +16,17 @@ export default function BlobPage() {
const [commitMsg, setCommitMsg] = useState('')
const [preview, setPreview] = useState(false)
// New-file mode: ?new=true&path=<desired-path>
const isNew = searchParams.get('new') === 'true'
const [newPath, setNewPath] = useState(searchParams.get('path') ?? '')
const ref = searchParams.get('ref') ?? ''
const filePath = searchParams.get('path') ?? ''
const filePath = isNew ? newPath : (searchParams.get('path') ?? '')
const fileName = filePath.split('/').pop() ?? filePath
const fileExt = fileName.includes('.') ? fileName.split('.').pop() ?? '' : ''
const { data: repo } = useRepo(owner, repoName)
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, filePath)
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, isNew ? '' : filePath)
const updateBlob = useUpdateBlob(owner, repoName)
const branch = ref || repo?.defaultBranch || 'main'
@@ -33,167 +40,202 @@ export default function BlobPage() {
}
function cancelEdit() {
setEditing(false)
setPreview(false)
if (isNew) {
navigate(-1)
} else {
setEditing(false)
setPreview(false)
}
}
async function handleCommit() {
if (!commitMsg.trim() || !filePath) return
const path = isNew ? newPath.trim() : filePath
if (!commitMsg.trim() || !path) return
await updateBlob.mutateAsync({
path: filePath,
path,
content: editContent,
message: commitMsg.trim(),
branch,
})
setEditing(false)
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(filePath)}`, { replace: true })
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}`, { replace: true })
}
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
if (isError || !blob) return <div className="p-6 text-sm text-[var(--c-danger)]">File not found.</div>
const isEditingState = editing || isNew
const lines = blob.content.split('\n')
const pathParts = filePath.split('/')
// For new file, start in edit mode with empty content.
if (isNew && !editing && editContent === '') {
setEditContent('')
setCommitMsg('Add new file')
setEditing(true)
}
const pathParts = filePath.split('/').filter(Boolean)
const content = blob?.content ?? ''
return (
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
<div className="flex h-full min-h-0" style={{ height: 'calc(100vh - 56px)' }}>
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm flex-wrap">
<Link to="/repos" className="text-[var(--c-brand)] hover:underline">Repositories</Link>
<span className="text-[var(--c-muted)]">/</span>
<Link to={`/repos/${owner}/${repoName}`} className="text-[var(--c-brand)] hover:underline">{repoName}</Link>
{pathParts.map((seg, i) => {
const partial = pathParts.slice(0, i + 1).join('/')
const isLast = i === pathParts.length - 1
return (
<span key={partial} className="flex items-center gap-1">
<span className="text-[var(--c-muted)]">/</span>
{isLast
? <span className="font-semibold text-[var(--c-text)]">{seg}</span>
: <Link to={`/repos/${owner}/${repoName}?path=${encodeURIComponent(partial)}&ref=${encodeURIComponent(branch)}`} className="text-[var(--c-brand)] hover:underline">{seg}</Link>
}
</span>
)
})}
{/* Left file tree — hidden on small screens */}
<div className="hidden md:flex">
<FileSideTree
owner={owner}
repo={repoName}
branch={branch}
activePath={filePath}
/>
</div>
{/* File card */}
<div className="border border-[var(--c-border)] rounded bg-[var(--c-surface)] overflow-hidden">
{/* Main content */}
<div className="flex-1 min-w-0 overflow-auto">
<div className="w-full px-4 md:px-6 py-6 space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm">
{/* Branch pill */}
<span className="flex items-center gap-1 px-2 py-0.5 border border-[var(--c-border)] rounded text-xs text-[var(--c-muted)] bg-[var(--c-surface)]">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
</svg>
{branch}
</span>
<span className="text-[var(--c-muted)]">{repoName}</span>
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm flex-wrap">
<Link to="/repos" className="text-[var(--c-brand)] hover:underline">Repositories</Link>
<span className="text-[var(--c-muted)]">/</span>
<span className="font-medium text-[var(--c-text)]">{fileName}</span>
<button
onClick={() => navigator.clipboard.writeText(filePath)}
className="text-[var(--c-muted)] hover:text-[var(--c-text)]"
title="Copy path"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
</button>
<Link to={`/repos/${owner}/${repoName}`} className="text-[var(--c-brand)] hover:underline">{repoName}</Link>
{pathParts.map((seg, i) => {
const partial = pathParts.slice(0, i + 1).join('/')
const isLast = i === pathParts.length - 1
return (
<span key={partial} className="flex items-center gap-1">
<span className="text-[var(--c-muted)]">/</span>
{isLast
? <span className="font-semibold text-[var(--c-text)]">{seg}</span>
: <Link to={`/repos/${owner}/${repoName}?path=${encodeURIComponent(partial)}&ref=${encodeURIComponent(branch)}`} className="text-[var(--c-brand)] hover:underline">{seg}</Link>
}
</span>
)
})}
{isNew && <span className="text-[var(--c-muted)] font-semibold">New file</span>}
</div>
{!editing && (
<div className="flex items-center gap-1">
{isMarkdown && (
<button
onClick={() => setPreview(p => !p)}
className={`px-3 py-1.5 text-xs font-medium rounded border ${preview ? 'border-[var(--c-brand)] text-[var(--c-brand)] bg-[var(--c-brand-tint)]' : 'border-[var(--c-border)] text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]'}`}
>
{preview ? 'Source' : 'Preview'}
</button>
{/* File card */}
{isLoading && !isNew && <RepoListSkeleton />}
{(isError && !isNew) && <div className="text-sm text-[var(--c-danger)] p-4">File not found.</div>}
{(!isLoading || isNew) && (
<div className="border border-[var(--c-border)] rounded bg-[var(--c-surface)] overflow-hidden">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm">
<span className="flex items-center gap-1 px-2 py-0.5 border border-[var(--c-border)] rounded text-xs text-[var(--c-muted)] bg-[var(--c-surface)]">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
</svg>
{branch}
</span>
{isNew ? (
<input
value={newPath}
onChange={e => setNewPath(e.target.value)}
placeholder="path/to/new-file.ts"
className="border border-[var(--c-border)] rounded px-2 py-0.5 text-xs focus:outline-none focus:border-[var(--c-brand-focus)] text-[var(--c-text)] bg-[var(--c-surface)]"
style={{ minWidth: 200 }}
/>
) : (
<>
<span className="text-[var(--c-muted)]">{repoName}</span>
<span className="text-[var(--c-muted)]">/</span>
<span className="font-medium text-[var(--c-text)]">{fileName}</span>
<button
onClick={() => navigator.clipboard.writeText(filePath)}
className="text-[var(--c-muted)] hover:text-[var(--c-text)]"
title="Copy path"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
</button>
</>
)}
</div>
{!isEditingState && (
<div className="flex items-center gap-1">
{isMarkdown && (
<button
onClick={() => setPreview(p => !p)}
className={`px-3 py-1.5 text-xs font-medium rounded border ${preview ? 'border-[var(--c-brand)] text-[var(--c-brand)] bg-[var(--c-brand-tint)]' : 'border-[var(--c-border)] text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]'}`}
>
{preview ? 'Source' : 'Preview'}
</button>
)}
<button
onClick={startEdit}
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] flex items-center gap-1.5"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
</svg>
Edit
</button>
<button
onClick={() => navigator.clipboard.writeText(content)}
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]"
>
Copy
</button>
</div>
)}
</div>
{/* Content area */}
{isEditingState ? (
<div className="flex flex-col">
<CodeEditor
value={editContent}
onChange={setEditContent}
language={isNew ? (newPath.split('.').pop() ?? '') : fileExt}
minHeight="400px"
/>
<div className="p-4 bg-[var(--c-surface-raised)] border-t border-[var(--c-border)] space-y-3">
<div>
<label className="block text-xs font-semibold text-[var(--c-text)] mb-1">Commit message</label>
<input
value={commitMsg}
onChange={e => setCommitMsg(e.target.value)}
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-brand-focus)]"
placeholder="Describe your changes…"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCommit}
disabled={updateBlob.isPending || !commitMsg.trim() || (isNew && !newPath.trim())}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{updateBlob.isPending ? 'Committing…' : isNew ? 'Create file' : 'Commit changes'}
</button>
<button onClick={cancelEdit} className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel
</button>
{updateBlob.isError && (
<span className="text-xs text-[var(--c-danger)]">{(updateBlob.error as Error)?.message}</span>
)}
</div>
</div>
</div>
) : isMarkdown && preview ? (
<div className="px-6 py-5 prose prose-sm max-w-none text-[var(--c-text)]
prose-headings:text-[var(--c-text)] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[var(--c-border)] prose-headings:pb-1
prose-a:text-[var(--c-brand)] prose-code:bg-[var(--c-surface-muted)] prose-code:px-1 prose-code:rounded
prose-pre:bg-[var(--c-surface-muted)] prose-pre:border prose-pre:border-[var(--c-border)] prose-pre:rounded">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : (
<CodeEditor
value={content}
language={fileExt}
readOnly
minHeight="400px"
/>
)}
<button
onClick={startEdit}
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] flex items-center gap-1.5"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
</svg>
Edit
</button>
<button
onClick={() => navigator.clipboard.writeText(blob.content)}
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]"
>
Copy
</button>
</div>
)}
</div>
{/* Content */}
{editing ? (
<div className="flex flex-col">
<textarea
value={editContent}
onChange={e => setEditContent(e.target.value)}
className="w-full font-mono text-xs text-[var(--c-text)] bg-[var(--c-surface)] p-4 resize-none focus:outline-none border-b border-[var(--c-border)]"
style={{ minHeight: Math.max(300, lines.length * 20) }}
spellCheck={false}
/>
<div className="p-4 bg-[var(--c-surface-raised)] border-t border-[var(--c-border)] space-y-3">
<div>
<label className="block text-xs font-semibold text-[var(--c-text)] mb-1">Commit message</label>
<input
value={commitMsg}
onChange={e => setCommitMsg(e.target.value)}
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-brand-focus)]"
placeholder="Describe your changes…"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCommit}
disabled={updateBlob.isPending || !commitMsg.trim()}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{updateBlob.isPending ? 'Committing…' : 'Commit changes'}
</button>
<button onClick={cancelEdit} className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel
</button>
{updateBlob.isError && (
<span className="text-xs text-[var(--c-danger)]">{(updateBlob.error as Error)?.message}</span>
)}
</div>
</div>
</div>
) : isMarkdown && preview ? (
<div className="px-6 py-5 prose prose-sm max-w-none text-[var(--c-text)]
prose-headings:text-[var(--c-text)] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[var(--c-border)] prose-headings:pb-1
prose-a:text-[var(--c-brand)] prose-code:bg-[var(--c-surface-muted)] prose-code:px-1 prose-code:rounded
prose-pre:bg-[var(--c-surface-muted)] prose-pre:border prose-pre:border-[var(--c-border)] prose-pre:rounded">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse font-mono text-xs">
<tbody>
{lines.map((line, i) => (
<tr key={i} className="hover:bg-[#FFFBDD]">
<td className="select-none text-right text-[var(--c-muted)] px-4 py-0.5 w-12 border-r border-[var(--c-border)] bg-[var(--c-surface-raised)] sticky left-0">
{i + 1}
</td>
<td className="px-4 py-0.5 text-[var(--c-text)] whitespace-pre">{line || ' '}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
+104 -17
View File
@@ -4,7 +4,10 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
import { useEnvironments } from '../api/queries/environments'
import { useInstance } from '../api/queries/instance'
import { TreeBrowser } from '../components/repos/TreeBrowser'
import { RepoContextPanel } from '../components/repos/RepoContextPanel'
import { RepoFileSearch } from '../components/repos/RepoFileSearch'
import { RepoListSkeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar'
import { useRecentRepos } from '../hooks/useRecentRepos'
@@ -14,6 +17,8 @@ export default function RepoPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [showBranches, setShowBranches] = useState(false)
const [showClone, setShowClone] = useState(false)
const [cloneTab, setCloneTab] = useState<'https' | 'ssh'>('https')
const [cloneCopied, setCloneCopied] = useState(false)
const branchRef = useRef<HTMLDivElement>(null)
const cloneRef = useRef<HTMLDivElement>(null)
@@ -23,6 +28,7 @@ export default function RepoPage() {
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
const { data: branches } = useRepoBranches(owner, repoName)
const { data: environments } = useEnvironments(owner, repoName)
const { data: instance } = useInstance()
const { track } = useRecentRepos()
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
@@ -42,13 +48,23 @@ export default function RepoPage() {
const branch = ref || repo.defaultBranch
const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git`
const sshHost = instance?.sshHost || window.location.hostname
const sshPort = instance?.sshPort || '2222'
const sshUrl = sshPort === '22'
? `git@${sshHost}:${owner}/${repoName}.git`
: `ssh://git@${sshHost}:${sshPort}/${owner}/${repoName}.git`
const archiveBase = `/api/v1/repos/${owner}/${repoName}/archive?ref=${encodeURIComponent(branch)}`
function switchBranch(b: string) {
setSearchParams({ ref: b, ...(path ? { path } : {}) })
setShowBranches(false)
}
return (
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
<div className="max-w-[1400px] mx-auto px-4 md:px-6 py-6">
<div className="flex gap-6 items-start">
<div className="flex-1 min-w-0 space-y-4">
{/* Header row */}
<div className="flex items-start justify-between gap-4 flex-wrap">
@@ -123,17 +139,73 @@ export default function RepoPage() {
</svg>
</button>
{showClone && (
<div className="absolute right-0 top-full mt-1 w-80 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 p-4">
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p>
<div className="flex items-center gap-2 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2">
<code className="text-xs text-[var(--c-text)] flex-1 truncate">{cloneUrl}</code>
<button
onClick={() => navigator.clipboard.writeText(cloneUrl)}
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
>
Copy
</button>
<div className="absolute right-0 top-full mt-1 w-96 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 p-4 space-y-3">
{/* Clone URL tabs */}
<div>
<div className="flex gap-1 mb-2">
{(['https', 'ssh'] as const).map(tab => (
<button
key={tab}
onClick={() => setCloneTab(tab)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
cloneTab === tab
? 'bg-[var(--c-brand)] text-white'
: 'text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]'
}`}
>
{tab.toUpperCase()}
</button>
))}
</div>
<div className="flex items-center gap-2 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2">
<code className="text-xs text-[var(--c-text)] flex-1 truncate">
{cloneTab === 'https' ? cloneUrl : sshUrl}
</code>
<button
onClick={() => {
navigator.clipboard.writeText(cloneTab === 'https' ? cloneUrl : sshUrl)
setCloneCopied(true)
setTimeout(() => setCloneCopied(false), 1500)
}}
className={`text-[10px] font-medium shrink-0 transition-colors ${
cloneCopied
? 'text-[var(--c-success)]'
: 'text-[var(--c-brand)] hover:underline'
}`}
>
{cloneCopied ? 'Copied!' : 'Copy'}
</button>
</div>
{cloneTab === 'ssh' && (
<p className="text-[10px] text-[var(--c-muted)] mt-1.5">
Requires an SSH key added to your{' '}
<Link to="/settings" className="text-[var(--c-brand)] hover:underline">account settings</Link>.
</p>
)}
</div>
{/* Archive download */}
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-1.5">Download</p>
<div className="flex gap-2">
{[
{ label: 'ZIP', format: 'zip' },
{ label: 'tar.gz', format: 'tar.gz' },
{ label: 'Bundle', format: 'bundle' },
].map(({ label, format }) => (
<a
key={format}
href={`${archiveBase}&format=${format}`}
download
className="flex-1 text-center px-2 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded hover:bg-[var(--c-surface-muted)] text-[var(--c-text)] transition-colors"
>
{label}
</a>
))}
</div>
</div>
</div>
)}
</div>
@@ -141,7 +213,7 @@ export default function RepoPage() {
</div>
{repo.isEmpty ? (
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} />
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} sshUrl={sshUrl} />
) : (
<>
{/* Branch selector */}
@@ -203,6 +275,15 @@ export default function RepoPage() {
</>
)}
</div>
{/* Right sidebar — hidden below lg breakpoint */}
<div className="w-72 shrink-0 hidden lg:block space-y-3">
<RepoFileSearch owner={owner} repo={repoName} branch={branch} />
<RepoContextPanel owner={owner} repo={repoName} />
</div>
</div>
</div>
)
}
@@ -231,8 +312,8 @@ function ReadmePreview({ owner, repo, ref }: { owner: string; repo: string; ref:
)
}
function GettingStarted({ repoName, branch, cloneUrl }: {
repoName: string; branch: string; cloneUrl: string
function GettingStarted({ repoName, branch, cloneUrl, sshUrl }: {
repoName: string; branch: string; cloneUrl: string; sshUrl: string
}) {
return (
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
@@ -241,9 +322,15 @@ function GettingStarted({ repoName, branch, cloneUrl }: {
<p className="text-xs text-[var(--c-muted)] mt-0.5">Push your first commit to get started.</p>
</div>
<div className="px-5 py-5 space-y-6 text-sm">
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p>
<CopyBlock value={cloneUrl} />
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTPS</p>
<CopyBlock value={cloneUrl} />
</div>
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over SSH</p>
<CopyBlock value={sshUrl} />
</div>
</div>
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">or push an existing repository</p>
+61
View File
@@ -0,0 +1,61 @@
package handlers
import (
"fmt"
"net/http"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
)
type ArchiveHandler struct {
db *xorm.Engine
}
func NewArchiveHandler(db *xorm.Engine) *ArchiveHandler {
return &ArchiveHandler{db: db}
}
var archiveFormats = map[string]struct {
contentType string
ext string
}{
"zip": {"application/zip", "zip"},
"tar.gz": {"application/x-tar", "tar.gz"},
"bundle": {"application/octet-stream", "bundle"},
}
func (h *ArchiveHandler) Download(w http.ResponseWriter, r *http.Request) {
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return
}
format := r.URL.Query().Get("format")
if format == "" {
format = "zip"
}
meta, allowed := archiveFormats[format]
if !allowed {
jsonError(w, "format must be zip, tar.gz, or bundle", http.StatusBadRequest)
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
if ref == "" {
ref = "HEAD"
}
filename := fmt.Sprintf("%s-%s.%s", repo.Name, ref, meta.ext)
w.Header().Set("Content-Type", meta.contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
if err := gitdomain.ArchiveStream(repo.DiskPath, ref, format, w); err != nil {
// Headers already written — can't change status code; just log and close.
_ = err
}
}
+49
View File
@@ -0,0 +1,49 @@
package handlers
import (
"net/http"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
)
type InsightsHandler struct {
db *xorm.Engine
}
func NewInsightsHandler(db *xorm.Engine) *InsightsHandler {
return &InsightsHandler{db: db}
}
type insightsResponse struct {
Languages []gitdomain.LangStat `json:"languages"`
Contributors []gitdomain.Contributor `json:"contributors"`
TotalCommits int `json:"totalCommits"`
}
// Get returns language breakdown, top contributors, and total commit count
// for a repository. All data is read directly from git — no DB writes.
func (h *InsightsHandler) Get(w http.ResponseWriter, r *http.Request) {
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return
}
langs, _ := gitdomain.LanguageStats(repo.DiskPath, repo.DefaultBranch)
contribs, _ := gitdomain.Contributors(repo.DiskPath, 8)
count, _ := gitdomain.CommitCount(repo.DiskPath)
if langs == nil {
langs = []gitdomain.LangStat{}
}
if contribs == nil {
contribs = []gitdomain.Contributor{}
}
jsonOK(w, insightsResponse{
Languages: langs,
Contributors: contribs,
TotalCommits: count,
})
}
+48
View File
@@ -0,0 +1,48 @@
package handlers
import (
"encoding/json"
"net/http"
"net/url"
"github.com/forgeo/forgebucket/internal/config"
)
type InstanceHandler struct {
cfg *config.Config
}
func NewInstanceHandler(cfg *config.Config) *InstanceHandler {
return &InstanceHandler{cfg: cfg}
}
// Get returns the public instance configuration needed by the frontend to
// construct clone URLs (SSH host, SSH port, instance name).
func (h *InstanceHandler) Get(w http.ResponseWriter, r *http.Request) {
sshHost := h.sshHost(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck
"sshHost": sshHost,
"sshPort": h.cfg.SSHPort,
"instanceName": h.cfg.InstanceName,
})
}
// sshHost resolves the SSH hostname to display in clone URLs.
// Priority: SSH_HOST env var > InstanceURL hostname > request Host header > localhost.
func (h *InstanceHandler) sshHost(r *http.Request) string {
if h.cfg.SSHHost != "" {
return h.cfg.SSHHost
}
if h.cfg.InstanceURL != "" {
if u, err := url.Parse(h.cfg.InstanceURL); err == nil && u.Hostname() != "" {
return u.Hostname()
}
}
// Strip port from Host header if present.
host := r.Host
if u, err := url.Parse("http://" + host); err == nil {
return u.Hostname()
}
return "localhost"
}
+148
View File
@@ -1,7 +1,10 @@
package handlers
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@@ -650,3 +653,148 @@ func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*model
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
// SearchFiles handles GET /repos/{owner}/{repo}/files?q=...&ref=...
// Returns up to 20 matching file paths (case-insensitive substring match).
// When q is empty, returns all file paths up to 500 (used by the sidebar tree).
func (h *RepoHandler) SearchFiles(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
limit := 20
if query == "" {
limit = 500
}
files, err := gitdomain.SearchFiles(repo.DiskPath, ref, query, limit)
if err != nil {
jsonError(w, "search failed", http.StatusInternalServerError)
return
}
if files == nil {
files = []string{}
}
jsonOK(w, files)
}
// UploadFiles handles POST /repos/{owner}/{repo}/upload — multipart upload.
// Accepts multiple regular files (field "file[]") and/or a ZIP archive (field "zip").
// All files are committed in a single git commit.
func (h *RepoHandler) UploadFiles(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
if !HasPermission(h.db, repo, username, "write") {
jsonError(w, "you do not have write access to this repository", http.StatusForbidden)
return
}
const maxUpload = 50 << 20 // 50 MB
if err := r.ParseMultipartForm(maxUpload); err != nil {
jsonError(w, "could not parse upload: "+err.Error(), http.StatusBadRequest)
return
}
branch := r.FormValue("branch")
if branch == "" {
branch = repo.DefaultBranch
}
message := r.FormValue("message")
if message == "" {
message = "Upload files"
}
var uploads []gitdomain.FileUpload
// Regular files (field "file[]" or "file"). Browser sends webkitRelativePath
// via the custom header X-File-Path; fall back to the bare filename.
for _, fhs := range r.MultipartForm.File {
for _, fh := range fhs {
if fh.Size == 0 {
continue
}
f, err := fh.Open()
if err != nil {
continue
}
data, err := io.ReadAll(io.LimitReader(f, 10<<20)) // 10 MB per file
f.Close()
if err != nil {
continue
}
// Prefer the relative path sent by the browser (folder upload),
// otherwise use the bare filename.
relPath := fh.Filename
if rp := fh.Header.Get("X-File-Path"); rp != "" {
relPath = rp
}
if strings.EqualFold(fh.Header.Get("Content-Disposition"), "") {
// Skip the "zip" field — handled separately below.
}
clean := filepath.Clean(filepath.FromSlash(relPath))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
jsonError(w, fmt.Sprintf("invalid path: %s", relPath), http.StatusBadRequest)
return
}
uploads = append(uploads, gitdomain.FileUpload{Path: clean, Content: data})
}
}
// ZIP archive (field "zip").
if zipFHs, ok := r.MultipartForm.File["zip"]; ok && len(zipFHs) > 0 {
fh := zipFHs[0]
f, err := fh.Open()
if err == nil {
zipData, err := io.ReadAll(io.LimitReader(f, maxUpload))
f.Close()
if err == nil {
zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err == nil {
for _, zf := range zr.File {
if zf.FileInfo().IsDir() {
continue
}
clean := filepath.Clean(filepath.FromSlash(zf.Name))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
continue
}
rc, err := zf.Open()
if err != nil {
continue
}
data, err := io.ReadAll(io.LimitReader(rc, 10<<20))
rc.Close()
if err != nil {
continue
}
uploads = append(uploads, gitdomain.FileUpload{Path: clean, Content: data})
}
}
}
}
}
if len(uploads) == 0 {
jsonError(w, "no files found in upload", http.StatusBadRequest)
return
}
if err := gitdomain.WriteManyFiles(repo.DiskPath, branch, message, username, username+"@forgebucket", uploads); err != nil {
jsonError(w, "commit failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"committed": len(uploads)}) //nolint:errcheck
}
+8
View File
@@ -79,6 +79,9 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
ociH := handlers.NewOCIRegistryHandler(engine, ociRegistry)
scanH := handlers.NewScanningHandler(engine, scanner)
vulnH := handlers.NewVulnScanHandler(engine, vulnScanner)
archiveH := handlers.NewArchiveHandler(engine)
instanceH := handlers.NewInstanceHandler(cfg)
insightsH := handlers.NewInsightsHandler(engine)
// ── Git smart-HTTP transport ───────────────────────────────────────────────
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
@@ -99,6 +102,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
// ── Public ────────────────────────────────────────────────────────────
r.Get("/explore/repos", exploreH.Repos)
r.Get("/explore/users", exploreH.Users)
r.Get("/instance", instanceH.Get)
// Generates a CSRF token + cookie. SPA calls this once on load.
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
@@ -177,6 +181,10 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.With(csrf).Put("/blob", repoH.UpdateBlob)
r.Get("/commits", repoH.Commits)
r.Get("/branches", repoH.Branches)
r.Get("/archive", archiveH.Download)
r.Get("/insights", insightsH.Get)
r.Get("/files", repoH.SearchFiles)
r.With(csrf).Post("/upload", repoH.UploadFiles)
r.Get("/diff", repoH.Diff)
r.Route("/pulls", func(r chi.Router) {
r.Get("/", prH.List)
+9
View File
@@ -44,6 +44,11 @@ type Config struct {
// OCI Registry
OCIRoot string
// SSH server
SSHHost string // env: SSH_HOST, empty = auto-detect from request/instance URL
SSHPort string // env: SSH_PORT, default "2222"
SSHHostKeyPath string // env: SSH_HOST_KEY_PATH, empty = generate ephemeral
// Dev
Debug bool
}
@@ -68,6 +73,10 @@ func Load() (*Config, error) {
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
cfg.SSHHost = os.Getenv("SSH_HOST")
cfg.SSHPort = getEnv("SSH_PORT", "2222")
cfg.SSHHostKeyPath = os.Getenv("SSH_HOST_KEY_PATH")
// Optional signing key
cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY")
cfg.OCIRoot = getEnv("OCI_ROOT", filepath.Join(filepath.Dir(cfg.RepoRoot), "oci"))
+315
View File
@@ -3,9 +3,12 @@ package git
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
@@ -250,6 +253,84 @@ func WriteFile(repoPath, branch, filePath, content, authorName, authorEmail, mes
return nil
}
// FileUpload holds a file path and its content for a batch commit.
type FileUpload struct {
Path string // repo-relative path, e.g. "src/main.go"
Content []byte
}
// WriteManyFiles commits all files in a single commit to branch. Each file path
// must be a clean relative path — no ".." or absolute paths allowed.
func WriteManyFiles(repoPath, branch, message, authorName, authorEmail string, files []FileUpload) error {
if len(files) == 0 {
return errors.New("no files to commit")
}
// Validate all paths before touching the filesystem.
for _, f := range files {
clean := filepath.Clean(filepath.FromSlash(f.Path))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("invalid file path: %s", f.Path)
}
}
tmpDir, err := os.MkdirTemp("", "fb-upload-*")
if err != nil {
return fmt.Errorf("mktemp: %w", err)
}
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
authorEnv := append(baseEnv,
"GIT_AUTHOR_NAME="+authorName,
"GIT_AUTHOR_EMAIL="+authorEmail,
"GIT_COMMITTER_NAME="+authorName,
"GIT_COMMITTER_EMAIL="+authorEmail,
)
addWt := exec.Command("git", "worktree", "add", "--force", tmpDir, branch)
addWt.Dir = filepath.Clean(repoPath)
addWt.Env = baseEnv
if out, err := addWt.CombinedOutput(); err != nil {
os.RemoveAll(tmpDir)
return fmt.Errorf("worktree add: %w: %s", err, out)
}
defer func() {
rmWt := exec.Command("git", "worktree", "remove", "--force", tmpDir)
rmWt.Dir = filepath.Clean(repoPath)
rmWt.Env = baseEnv
rmWt.Run()
os.RemoveAll(tmpDir)
}()
for _, f := range files {
clean := filepath.Clean(filepath.FromSlash(f.Path))
fullPath := filepath.Join(tmpDir, clean)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return fmt.Errorf("mkdirall %s: %w", clean, err)
}
if err := os.WriteFile(fullPath, f.Content, 0644); err != nil {
return fmt.Errorf("writefile %s: %w", clean, err)
}
}
addC := exec.Command("git", "add", ".")
addC.Dir = tmpDir
addC.Env = authorEnv
if out, err := addC.CombinedOutput(); err != nil {
return fmt.Errorf("git add: %w: %s", err, out)
}
commitC := exec.Command("git", "commit", "-m", message)
commitC.Dir = tmpDir
commitC.Env = authorEnv
if out, err := commitC.CombinedOutput(); err != nil {
return fmt.Errorf("git commit: %w: %s", err, out)
}
return nil
}
type Branch struct {
Name string `json:"name"`
}
@@ -467,3 +548,237 @@ func parseUnifiedDiff(raw string) []FileDiff {
commit()
return files
}
// ArchiveStream writes a git archive of ref in the requested format to w.
// format must be one of "zip", "tar.gz", or "bundle".
// Output is streamed directly to w without buffering.
func ArchiveStream(repoPath string, ref string, format string, w io.Writer) error {
clean := filepath.Clean(repoPath)
if repoRoot != "" {
root := repoRoot + string(filepath.Separator)
if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
return ErrPathTraversal
}
}
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
var cmd *exec.Cmd
switch format {
case "zip", "tar.gz":
cmd = exec.Command("git", "archive", "--format="+format, ref)
case "bundle":
cmd = exec.Command("git", "bundle", "create", "-", "--all")
default:
return fmt.Errorf("git archive: unsupported format %q", format)
}
cmd.Dir = clean
cmd.Env = baseEnv
cmd.Stdout = w
var errBuf strings.Builder
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
if errBuf.Len() > 0 {
return fmt.Errorf("git archive: %w: %s", err, errBuf.String())
}
return fmt.Errorf("git archive: %w", err)
}
return nil
}
// ── Language stats ─────────────────────────────────────────────────────────────
// LangStat holds the aggregate file-count and percentage for one language.
type LangStat struct {
Name string `json:"name"`
Color string `json:"color"`
Count int `json:"count"`
Pct float64 `json:"pct"`
}
// extLang maps file extensions to (display name, hex color).
var extLang = map[string][2]string{
".go": {"Go", "#00ADD8"},
".ts": {"TypeScript", "#3178C6"},
".tsx": {"TypeScript", "#3178C6"},
".js": {"JavaScript", "#F7DF1E"},
".jsx": {"JavaScript", "#F7DF1E"},
".mjs": {"JavaScript", "#F7DF1E"},
".py": {"Python", "#3572A5"},
".rb": {"Ruby", "#CC342D"},
".rs": {"Rust", "#DEA584"},
".java": {"Java", "#B07219"},
".cs": {"C#", "#178600"},
".cpp": {"C++", "#F34B7D"},
".cc": {"C++", "#F34B7D"},
".c": {"C", "#555555"},
".h": {"C", "#555555"},
".swift": {"Swift", "#F05138"},
".kt": {"Kotlin", "#A97BFF"},
".php": {"PHP", "#4F5D95"},
".html": {"HTML", "#E34C26"},
".css": {"CSS", "#563D7C"},
".scss": {"SCSS", "#C6538C"},
".sql": {"SQL", "#e38c00"},
".sh": {"Shell", "#89E051"},
".bash": {"Shell", "#89E051"},
".yaml": {"YAML", "#CB171E"},
".yml": {"YAML", "#CB171E"},
".json": {"JSON", "#292929"},
".md": {"Markdown", "#083FA1"},
".tf": {"HCL", "#844FBA"},
".proto": {"Protobuf", "#5F5CE9"},
".dart": {"Dart", "#00B4AB"},
".lua": {"Lua", "#000080"},
".r": {"R", "#198CE7"},
}
// LanguageStats analyses the file tree at ref and returns language breakdown
// sorted by file count descending. Languages under 3% are collapsed into "Other".
func LanguageStats(repoPath, ref string) ([]LangStat, error) {
if ref == "" {
ref = "HEAD"
}
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
if err != nil {
return nil, err
}
counts := map[string]int{}
total := 0
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
ext := strings.ToLower(filepath.Ext(line))
lang, ok := extLang[ext]
if ok {
counts[lang[0]]++
} else {
counts["Other"]++
}
total++
}
if total == 0 {
return []LangStat{}, nil
}
// Aggregate by name with color lookup.
colorOf := map[string]string{"Other": "#8B8B8B"}
for _, v := range extLang {
colorOf[v[0]] = v[1]
}
var stats []LangStat
otherCount := 0
for name, count := range counts {
pct := float64(count) / float64(total) * 100
if name == "Other" || pct < 3.0 {
otherCount += count
continue
}
stats = append(stats, LangStat{Name: name, Color: colorOf[name], Count: count, Pct: pct})
}
sort.Slice(stats, func(i, j int) bool { return stats[i].Count > stats[j].Count })
if len(stats) > 10 {
for _, s := range stats[10:] {
otherCount += s.Count
}
stats = stats[:10]
}
if otherCount > 0 {
stats = append(stats, LangStat{
Name: "Other",
Color: "#8B8B8B",
Count: otherCount,
Pct: float64(otherCount) / float64(total) * 100,
})
}
return stats, nil
}
// ── Contributor stats ─────────────────────────────────────────────────────────
// Contributor holds a commit author name and their commit count.
type Contributor struct {
Name string `json:"name"`
Commits int `json:"commits"`
}
// Contributors returns the top limit commit authors sorted by commit count.
// Uses git shortlog which is fast even on large repos.
func Contributors(repoPath string, limit int) ([]Contributor, error) {
out, err := run(repoPath, "shortlog", "-sn", "--no-merges", "HEAD")
if err != nil {
return []Contributor{}, nil // empty repo or detached HEAD — not an error
}
var result []Contributor
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Format: " 42\tAlice Wang"
idx := strings.Index(line, "\t")
if idx < 0 {
continue
}
countStr := strings.TrimSpace(line[:idx])
name := strings.TrimSpace(line[idx+1:])
n, err := strconv.Atoi(countStr)
if err != nil {
continue
}
result = append(result, Contributor{Name: name, Commits: n})
if len(result) >= limit {
break
}
}
return result, nil
}
// CommitCount returns the total number of commits reachable from HEAD.
func CommitCount(repoPath string) (int, error) {
out, err := run(repoPath, "rev-list", "--count", "HEAD")
if err != nil {
return 0, nil // empty repo
}
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return 0, nil
}
return n, nil
}
// SearchFiles returns file paths matching query (case-insensitive substring)
// in the repository tree at ref, capped at limit results.
func SearchFiles(repoPath, ref, query string, limit int) ([]string, error) {
if ref == "" {
ref = "HEAD"
}
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
if err != nil {
return []string{}, nil // empty repo
}
lower := strings.ToLower(query)
var results []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.Contains(strings.ToLower(line), lower) {
results = append(results, line)
if len(results) >= limit {
break
}
}
}
return results, nil
}
+45
View File
@@ -0,0 +1,45 @@
package sshserver
import (
"crypto/md5"
"fmt"
"strings"
"golang.org/x/crypto/ssh"
"github.com/forgeo/forgebucket/internal/models"
)
// lookupKey is the SSH PublicKeyCallback. It computes the MD5 fingerprint of
// the presented key (matching the format stored by the SSH key registration
// handler) and looks it up in the database.
func (s *Server) lookupKey(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
fp := fingerprintMD5(key)
var sshKey models.SSHKey
if found, _ := s.db.Where("fingerprint = ?", fp).Get(&sshKey); !found {
return nil, fmt.Errorf("unknown key")
}
// Resolve the username so the session handler can use it for permission checks.
var user models.User
if found, _ := s.db.ID(sshKey.UserID).Get(&user); !found {
return nil, fmt.Errorf("user not found")
}
return &ssh.Permissions{
Extensions: map[string]string{
"username": user.Username,
"user_id": fmt.Sprintf("%d", user.ID),
},
}, nil
}
func fingerprintMD5(pub ssh.PublicKey) string {
hash := md5.Sum(pub.Marshal())
parts := make([]string, len(hash))
for i, b := range hash {
parts[i] = fmt.Sprintf("%02x", b)
}
return strings.Join(parts, ":")
}
+121
View File
@@ -0,0 +1,121 @@
// Package sshserver implements an SSH server for git clone/push/pull operations.
// It authenticates users via their stored SSH public keys and executes
// git-upload-pack / git-receive-pack as subprocesses.
package sshserver
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"net"
"os"
"golang.org/x/crypto/ssh"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
)
// Server is the SSH git server.
type Server struct {
db *xorm.Engine
cfg *config.Config
}
func New(db *xorm.Engine, cfg *config.Config) *Server {
return &Server{db: db, cfg: cfg}
}
// ListenAndServe binds to cfg.SSHPort, loads or generates a host key, and accepts
// connections until ctx is cancelled. Returns nil when the context is done.
func (s *Server) ListenAndServe(ctx context.Context) error {
hostKey, err := s.loadOrGenerateHostKey()
if err != nil {
return fmt.Errorf("sshserver: host key: %w", err)
}
srvConfig := &ssh.ServerConfig{
PublicKeyCallback: s.lookupKey,
}
srvConfig.AddHostKey(hostKey)
addr := ":" + s.cfg.SSHPort
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Printf("sshserver: cannot bind %s — SSH transport disabled: %v", addr, err)
return nil
}
log.Printf("sshserver: listening on %s", addr)
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
log.Printf("sshserver: accept: %v", err)
continue
}
}
go s.handleConn(conn, srvConfig)
}
}
func (s *Server) handleConn(netConn net.Conn, srvConfig *ssh.ServerConfig) {
defer netConn.Close()
sshConn, chans, reqs, err := ssh.NewServerConn(netConn, srvConfig)
if err != nil {
return
}
defer sshConn.Close()
go ssh.DiscardRequests(reqs)
username, _ := sshConn.Permissions.Extensions["username"]
for newChan := range chans {
if newChan.ChannelType() != "session" {
newChan.Reject(ssh.UnknownChannelType, "unknown channel type") //nolint:errcheck
continue
}
ch, requests, err := newChan.Accept()
if err != nil {
return
}
go s.handleSession(ch, requests, username)
}
}
// loadOrGenerateHostKey loads the host key from SSHHostKeyPath if set,
// otherwise generates an ephemeral RSA-4096 key (lost on restart).
func (s *Server) loadOrGenerateHostKey() (ssh.Signer, error) {
if s.cfg.SSHHostKeyPath != "" {
data, err := os.ReadFile(s.cfg.SSHHostKeyPath)
if err != nil {
return nil, fmt.Errorf("read host key %s: %w", s.cfg.SSHHostKeyPath, err)
}
return ssh.ParsePrivateKey(data)
}
log.Printf("sshserver: SSH_HOST_KEY_PATH not set — generating ephemeral host key (host key changes on restart)")
privKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, fmt.Errorf("generate host key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
})
return ssh.ParsePrivateKey(keyPEM)
}
+199
View File
@@ -0,0 +1,199 @@
package sshserver
import (
"encoding/binary"
"fmt"
"io"
"log"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/crypto/ssh"
"github.com/forgeo/forgebucket/internal/models"
)
// handleSession processes a single SSH session channel: waits for an exec
// request, dispatches to the appropriate git subcommand, then exits.
func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, username string) {
defer ch.Close()
for req := range reqs {
if req.Type != "exec" {
if req.WantReply {
req.Reply(false, nil) //nolint:errcheck
}
continue
}
cmdStr, err := parseExecPayload(req.Payload)
if err != nil {
req.Reply(false, nil) //nolint:errcheck
return
}
req.Reply(true, nil) //nolint:errcheck
exitCode := s.runGitCommand(ch, username, cmdStr)
sendExitStatus(ch, uint32(exitCode))
return
}
}
// runGitCommand parses the SSH exec command string, validates it, resolves the
// repo, checks permissions, and runs the git subprocess.
func (s *Server) runGitCommand(ch ssh.Channel, username, cmdStr string) int {
gitCmd, repoArg, err := parseGitCommand(cmdStr)
if err != nil {
fmt.Fprintf(ch.Stderr(), "error: %v\n", err)
return 1
}
// Resolve owner/repo from the path argument (e.g. "/alice/myrepo.git" or "alice/myrepo.git")
path := strings.TrimPrefix(strings.TrimSuffix(repoArg, ".git"), "/")
parts := strings.SplitN(path, "/", 2)
if len(parts) != 2 {
fmt.Fprintf(ch.Stderr(), "error: invalid repository path\n")
return 1
}
ownerName, repoName := parts[0], parts[1]
repo, err := s.resolveRepo(ownerName, repoName)
if err != nil {
fmt.Fprintf(ch.Stderr(), "error: repository not found\n")
return 1
}
// Check permissions.
if gitCmd == "receive-pack" {
if !s.hasPermission(repo, username, "write") {
fmt.Fprintf(ch.Stderr(), "error: you do not have write access to this repository\n")
return 1
}
} else {
// upload-pack: public repos are accessible to all; private repos require read.
if repo.IsPrivate && !s.hasPermission(repo, username, "read") {
fmt.Fprintf(ch.Stderr(), "error: you do not have read access to this repository\n")
return 1
}
}
// Exec the git subcommand against the bare repo path on disk.
// The disk path comes from the DB — never from user input.
cmd := exec.Command("git", gitCmd, repo.DiskPath)
cmd.Dir = filepath.Clean(repo.DiskPath)
cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
cmd.Stdin = ch
cmd.Stdout = ch
cmd.Stderr = ch.Stderr()
if err := cmd.Run(); err != nil {
log.Printf("sshserver: git %s for %s/%s: %v", gitCmd, ownerName, repoName, err)
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
return 1
}
return 0
}
// resolveRepo looks up a repository by owner name (user or workspace) and repo name.
func (s *Server) resolveRepo(ownerName, repoName string) (*models.Repository, error) {
var u models.User
if found, _ := s.db.Where("username = ?", ownerName).Get(&u); found {
var repo models.Repository
if found2, _ := s.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); found2 {
return &repo, nil
}
}
var ws models.Workspace
if found, _ := s.db.Where("handle = ?", ownerName).Get(&ws); found {
var repo models.Repository
if found2, _ := s.db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); found2 {
return &repo, nil
}
}
return nil, fmt.Errorf("not found")
}
// hasPermission checks whether username has at least the required permission on repo.
func (s *Server) hasPermission(repo *models.Repository, username, required string) bool {
var u models.User
if found, _ := s.db.Where("username = ?", username).Get(&u); !found {
return false
}
if u.ID == repo.OwnerID {
return true
}
var m models.RepoMember
if found, _ := s.db.Where("repo_id = ? AND user_id = ?", repo.ID, u.ID).Get(&m); !found {
return false
}
rank := map[string]int{"read": 1, "write": 2, "admin": 3}
return rank[m.Permission] >= rank[required]
}
// parseGitCommand splits the SSH exec command string into the git subcommand
// and the repo path argument. Only upload-pack and receive-pack are permitted.
//
// Accepts both "git-upload-pack '/path'" and "git upload-pack /path" forms.
func parseGitCommand(cmdStr string) (gitCmd string, repoPath string, err error) {
cmdStr = strings.TrimSpace(cmdStr)
var candidate string
var rest string
if strings.HasPrefix(cmdStr, "git-upload-pack") {
candidate = "upload-pack"
rest = strings.TrimPrefix(cmdStr, "git-upload-pack")
} else if strings.HasPrefix(cmdStr, "git-receive-pack") {
candidate = "receive-pack"
rest = strings.TrimPrefix(cmdStr, "git-receive-pack")
} else if strings.HasPrefix(cmdStr, "git upload-pack") {
candidate = "upload-pack"
rest = strings.TrimPrefix(cmdStr, "git upload-pack")
} else if strings.HasPrefix(cmdStr, "git receive-pack") {
candidate = "receive-pack"
rest = strings.TrimPrefix(cmdStr, "git receive-pack")
} else {
return "", "", fmt.Errorf("unsupported command: only git-upload-pack and git-receive-pack are allowed")
}
// Strip surrounding whitespace and single quotes from the path argument.
rest = strings.TrimSpace(rest)
rest = strings.Trim(rest, "'\"")
if rest == "" {
return "", "", fmt.Errorf("missing repository path argument")
}
return candidate, rest, nil
}
// parseExecPayload decodes the SSH exec request payload: 4-byte big-endian
// length followed by the command string.
func parseExecPayload(payload []byte) (string, error) {
if len(payload) < 4 {
return "", fmt.Errorf("exec payload too short")
}
length := binary.BigEndian.Uint32(payload[:4])
if int(length) > len(payload)-4 {
return "", fmt.Errorf("exec payload length mismatch")
}
return string(payload[4 : 4+length]), nil
}
// sendExitStatus sends an SSH exit-status channel request.
func sendExitStatus(ch ssh.Channel, code uint32) {
msg := struct{ Status uint32 }{code}
ch.SendRequest("exit-status", false, ssh.Marshal(msg)) //nolint:errcheck
}
// Stderr returns the stderr stream of an SSH channel.
// The ssh.Channel type embeds io.ReadWriteCloser for stdout/stdin;
// Stderr() is defined on *ssh.channel but not the interface — use a type assertion.
func init() {
// Compile-time interface check: ssh.Channel must have Stderr() method.
var _ interface{ Stderr() io.ReadWriter } = (ssh.Channel)(nil)
}