Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec9a286d33 | |||
| 5147c6bddb | |||
| e7c64e583b | |||
| f658d754a8 | |||
| a7b1fd2ae3 |
@@ -17,7 +17,7 @@ PORT=8080
|
||||
REPO_ROOT=/tmp/forgebucket/repos
|
||||
|
||||
# NATS event bus (used for CI and real-time WebSocket push)
|
||||
NATS_URL=nats://72.60.83.46:4222
|
||||
NATS_URL=nats://localhost:4222
|
||||
|
||||
# ─── Federation (ActivityPub) ─────────────────────────────────────────────────
|
||||
# Public URL of this instance (no trailing slash)
|
||||
|
||||
+1
-2
@@ -13,5 +13,4 @@ uploads
|
||||
|
||||
# Database
|
||||
*.db
|
||||
|
||||
ai_agent_master_prompt_for_building_modern_git_platform.md
|
||||
html docs/
|
||||
|
||||
@@ -99,7 +99,7 @@ Logger → RealIP → Recoverer → Metrics → CORS → CSRF → SessionAuth
|
||||
| 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | **Complete** |
|
||||
| 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures, Follow/Accept) | **Complete** |
|
||||
| 4 | SBOM generation, secret scanning, vuln scanning, signed artifacts, OCI registry, security page | **Complete** |
|
||||
| 5 | AI diagnostics, deployment promotions, rollback visualization | Planned |
|
||||
| 5 | Deployment promotions, rollback visualization | Planned |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-2
@@ -9,8 +9,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Planned — Phase 5 (AI Diagnostics + Deployment Promotions + Rollback Visualization)
|
||||
- AI-powered pipeline failure diagnostics
|
||||
### Planned — Phase 5 (Deployment Promotions + Rollback Visualization)
|
||||
- Deployment promotion workflows (manual + automated)
|
||||
- Rollback visualization and timeline
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
|
||||
nats:
|
||||
image: mirror.gcr.io/nats:2-alpine
|
||||
restart: unless-stopped
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
|
||||
app:
|
||||
build: .
|
||||
container_name: fb-app
|
||||
@@ -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
|
||||
|
||||
@@ -32,5 +32,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
dbgate:
|
||||
image: dbgate/dbgate
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,7 @@ 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 branchRef = useRef<HTMLDivElement>(null)
|
||||
const cloneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -23,6 +27,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 +47,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 +138,64 @@ 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)}
|
||||
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{cloneTab === 'ssh' && (
|
||||
<p className="text-[10px] text-[var(--c-muted)] mt-1.5">
|
||||
Requires an SSH key added to your account settings.
|
||||
</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 +203,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 +265,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 +302,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 +312,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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 extracts the hostname from InstanceURL. Falls back to the request
|
||||
// host when InstanceURL is unset (common in local development).
|
||||
func (h *InstanceHandler) sshHost(r *http.Request) string {
|
||||
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 host
|
||||
}
|
||||
@@ -650,3 +650,33 @@ 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).
|
||||
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"))
|
||||
if query == "" {
|
||||
jsonOK(w, []string{})
|
||||
return
|
||||
}
|
||||
|
||||
ref := r.URL.Query().Get("ref")
|
||||
if ref == "" {
|
||||
ref = repo.DefaultBranch
|
||||
}
|
||||
|
||||
files, err := gitdomain.SearchFiles(repo.DiskPath, ref, query, 20)
|
||||
if err != nil {
|
||||
jsonError(w, "search failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if files == nil {
|
||||
files = []string{}
|
||||
}
|
||||
jsonOK(w, files)
|
||||
}
|
||||
|
||||
@@ -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,9 @@ 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.Get("/diff", repoH.Diff)
|
||||
r.Route("/pulls", func(r chi.Router) {
|
||||
r.Get("/", prH.List)
|
||||
|
||||
@@ -44,6 +44,10 @@ type Config struct {
|
||||
// OCI Registry
|
||||
OCIRoot string
|
||||
|
||||
// SSH server
|
||||
SSHPort string // env: SSH_PORT, default "2222"
|
||||
SSHHostKeyPath string // env: SSH_HOST_KEY_PATH, empty = generate ephemeral
|
||||
|
||||
// Dev
|
||||
Debug bool
|
||||
}
|
||||
@@ -68,6 +72,9 @@ func Load() (*Config, error) {
|
||||
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
|
||||
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
|
||||
|
||||
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"))
|
||||
|
||||
@@ -3,9 +3,12 @@ package git
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -467,3 +470,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
|
||||
}
|
||||
|
||||
@@ -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, ":")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user