security sections are fully functional

This commit is contained in:
2026-05-07 15:06:45 +02:00
parent 5e60b814ed
commit 53aa5cbbf5
20 changed files with 946 additions and 41 deletions
@@ -1,2 +1,4 @@
822b85a78b17deb9373a3f5a802e9db2a9893846 eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 devtest <dev@test.com> 1778145720 +0200 commit: Update README via editor
eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd testuser <testuser@http.[::1]:51142> 1778158078 +0200 push
b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd dbbf02422886140077541fff3dd8874def5d0b07 deploy-key <deploy-key@http.[::1]:51386> 1778158842 +0200 push
dbbf02422886140077541fff3dd8874def5d0b07 c5c74f826a772ffa4eb0a7de315d66be01f797f7 access-token <access-token@http.[::1]:51430> 1778158967 +0200 push
+1 -1
View File
@@ -1 +1 @@
b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd
c5c74f826a772ffa4eb0a7de315d66be01f797f7
+79
View File
@@ -0,0 +1,79 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { DeployKey, AccessToken } from '../../types/api'
// ─── Deploy keys ──────────────────────────────────────────────────────────────
const deployKeySchema = z.object({
id: z.number(),
title: z.string(),
readOnly: z.boolean(),
createdAt: z.string(),
token: z.string().optional(),
})
const deployKeysSchema = z.array(deployKeySchema)
export function useDeployKeys(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'keys'],
queryFn: () => api.get<DeployKey[]>(`/api/v1/repos/${owner}/${repo}/keys`, deployKeysSchema),
enabled: Boolean(owner && repo),
})
}
export function useCreateDeployKey(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: { title: string; readOnly: boolean }) =>
api.post<DeployKey>(`/api/v1/repos/${owner}/${repo}/keys`, deployKeySchema, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'keys'] }),
})
}
export function useDeleteDeployKey(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (keyId: number) =>
api.delete(`/api/v1/repos/${owner}/${repo}/keys/${keyId}`, z.any()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'keys'] }),
})
}
// ─── Access tokens ────────────────────────────────────────────────────────────
const accessTokenSchema = z.object({
id: z.number(),
title: z.string(),
scopes: z.string(),
expiresAt: z.string().nullable().optional(),
createdAt: z.string(),
token: z.string().optional(),
})
const accessTokensSchema = z.array(accessTokenSchema)
export function useAccessTokens(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'tokens'],
queryFn: () => api.get<AccessToken[]>(`/api/v1/repos/${owner}/${repo}/tokens`, accessTokensSchema),
enabled: Boolean(owner && repo),
})
}
export function useCreateAccessToken(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: { title: string; scopes: string; expiresAt?: string }) =>
api.post<AccessToken>(`/api/v1/repos/${owner}/${repo}/tokens`, accessTokenSchema, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'tokens'] }),
})
}
export function useDeleteAccessToken(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (tokenId: number) =>
api.delete(`/api/v1/repos/${owner}/${repo}/tokens/${tokenId}`, z.any()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'tokens'] }),
})
}
+316 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos'
import { useRepoMembers, useAddMember, useUpdateMember, useRemoveMember } from '../api/queries/members'
import { useDeployKeys, useCreateDeployKey, useDeleteDeployKey, useAccessTokens, useCreateAccessToken, useDeleteAccessToken } from '../api/queries/keys'
import { useRecentRepos } from '../hooks/useRecentRepos'
import { useAuth } from '../contexts/AuthContext'
import { Skeleton } from '../ui/Skeleton'
@@ -132,7 +133,9 @@ export default function RepoSettingsPage() {
</div>
{section === 'repository-details' && <RepositoryDetailsSection owner={owner} repo={repoName} />}
{section === 'repository-permissions' && <RepositoryPermissionsSection owner={owner} repo={repoName} />}
{section !== 'repository-details' && section !== 'repository-permissions' && <ComingSoon sectionId={section} />}
{section === 'access-keys' && <AccessKeysSection owner={owner} repo={repoName} />}
{section === 'access-tokens' && <AccessTokensSection owner={owner} repo={repoName} />}
{!['repository-details','repository-permissions','access-keys','access-tokens'].includes(section) && <ComingSoon sectionId={section} />}
</main>
</div>
)
@@ -709,6 +712,318 @@ function RepositoryPermissionsSection({ owner, repo }: { owner: string; repo: st
)
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
function TokenReveal({ token, onDismiss }: { token: string; onDismiss: () => void }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(token)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="border border-[var(--c-success)] rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-[var(--c-surface-raised)] border-b border-[var(--c-success)]/30">
<svg width="14" height="14" fill="none" stroke="var(--c-success)" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span className="text-sm font-semibold text-[var(--c-success)]">Token created copy it now, it won't be shown again</span>
</div>
<div className="p-4 space-y-3 bg-[var(--c-surface)]">
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2 text-[var(--c-text)] break-all">
{token}
</code>
<button onClick={copy}
className="shrink-0 px-3 py-2 rounded border border-[var(--c-border)] text-xs font-medium text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors">
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<button onClick={onDismiss} className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] underline">
I've copied it, dismiss
</button>
</div>
</div>
)
}
// ─── Access keys (deploy keys) section ───────────────────────────────────────
function AccessKeysSection({ owner, repo }: { owner: string; repo: string }) {
const { data: keys, isLoading } = useDeployKeys(owner, repo)
const createKey = useCreateDeployKey(owner, repo)
const deleteKey = useDeleteDeployKey(owner, repo)
const [title, setTitle] = useState('')
const [readOnly, setReadOnly] = useState(true)
const [newToken, setNewToken] = useState<string | null>(null)
const [formError, setFormError] = useState('')
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setFormError('')
if (!title.trim()) return
try {
const key = await createKey.mutateAsync({ title: title.trim(), readOnly })
if (key.token) setNewToken(key.token)
setTitle('')
setReadOnly(true)
} catch (err) {
setFormError((err as Error).message)
}
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Access keys</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Deploy keys grant git clone and push access to this repository over HTTP. Each key is shown only once.
</p>
<div className="mt-3 p-3 rounded-lg bg-[var(--c-surface-raised)] border border-[var(--c-border)] text-xs text-[var(--c-muted)]">
Use as git credentials: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-deploy-key:&lt;TOKEN&gt;@{window.location.hostname}/{owner}/{repo}.git</code>
</div>
</div>
<div className="border-t border-[var(--c-border)]" />
{/* Token reveal banner */}
{newToken && <TokenReveal token={newToken} onDismiss={() => setNewToken(null)} />}
{/* Existing keys */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Active keys</h2>
<span className="text-xs text-[var(--c-muted)]">{keys?.length ?? 0} key{keys?.length !== 1 ? 's' : ''}</span>
</div>
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <div key={i} className="flex gap-3"><Skeleton className="h-4 w-32" /><Skeleton className="h-4 w-16 ml-auto" /></div>)}
</div>
) : !keys?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No deploy keys yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{keys.map(key => (
<li key={key.id} className="flex items-center gap-3 px-4 py-3">
<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="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 0 1 21.75 8.25Z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--c-text)]">{key.title}</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
{key.readOnly ? 'Read-only' : 'Read & write'} · Added {new Date(key.createdAt).toLocaleDateString()}
</p>
</div>
<span className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full ${key.readOnly ? 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'}`}>
{key.readOnly ? 'Read' : 'Write'}
</span>
<button
onClick={() => deleteKey.mutate(key.id)}
disabled={deleteKey.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors disabled:opacity-40"
title="Revoke key"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
)}
</div>
{/* Create form */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Add a deploy key</h2>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Label</label>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. CI/CD pipeline"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
</div>
<label className="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" checked={readOnly} onChange={e => setReadOnly(e.target.checked)}
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
<div>
<span className="text-sm font-medium text-[var(--c-text)]">Read-only</span>
<p className="text-xs text-[var(--c-muted)] mt-0.5">Uncheck to allow pushes with this key.</p>
</div>
</label>
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
<button type="submit" disabled={createKey.isPending || !title.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">
{createKey.isPending ? 'Generating…' : 'Generate key'}
</button>
</form>
</div>
</div>
)
}
// ─── Access tokens section ────────────────────────────────────────────────────
function AccessTokensSection({ owner, repo }: { owner: string; repo: string }) {
const { data: tokens, isLoading } = useAccessTokens(owner, repo)
const createToken = useCreateAccessToken(owner, repo)
const deleteToken = useDeleteAccessToken(owner, repo)
const [title, setTitle] = useState('')
const [scopes, setScopes] = useState<string[]>(['read'])
const [expiresAt, setExpiresAt] = useState('')
const [newToken, setNewToken] = useState<string | null>(null)
const [formError, setFormError] = useState('')
function toggleScope(scope: string) {
setScopes(prev =>
prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]
)
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setFormError('')
if (!title.trim() || scopes.length === 0) return
try {
const tok = await createToken.mutateAsync({
title: title.trim(),
scopes: scopes.join(','),
expiresAt: expiresAt || undefined,
})
if (tok.token) setNewToken(tok.token)
setTitle('')
setScopes(['read'])
setExpiresAt('')
} catch (err) {
setFormError((err as Error).message)
}
}
const scopeLabels: Record<string, { label: string; description: string }> = {
read: { label: 'Read', description: 'Read repo contents, branches, commits, PRs, issues' },
write: { label: 'Write', description: 'Push code, create PRs and issues' },
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Access tokens</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Tokens grant programmatic access to this repository's API and git operations. Each token is shown only once.
</p>
<div className="mt-3 p-3 rounded-lg bg-[var(--c-surface-raised)] border border-[var(--c-border)] text-xs text-[var(--c-muted)] space-y-1">
<p>API: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">Authorization: Bearer &lt;TOKEN&gt;</code></p>
<p>Git: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-token:&lt;TOKEN&gt;@{window.location.hostname}/{owner}/{repo}.git</code></p>
</div>
</div>
<div className="border-t border-[var(--c-border)]" />
{newToken && <TokenReveal token={newToken} onDismiss={() => setNewToken(null)} />}
{/* Token list */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Active tokens</h2>
<span className="text-xs text-[var(--c-muted)]">{tokens?.length ?? 0} token{tokens?.length !== 1 ? 's' : ''}</span>
</div>
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <div key={i} className="flex gap-3"><Skeleton className="h-4 w-40" /><Skeleton className="h-4 w-20 ml-auto" /></div>)}
</div>
) : !tokens?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No access tokens yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{tokens.map(tok => {
const expired = tok.expiresAt && new Date(tok.expiresAt) < new Date()
return (
<li key={tok.id} className="flex items-center gap-3 px-4 py-3">
<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="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--c-text)]">{tok.title}</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Scopes: {tok.scopes}
{tok.expiresAt && (
<span className={expired ? ' · Expired' : ` · Expires ${new Date(tok.expiresAt).toLocaleDateString()}`} />
)}
{!tok.expiresAt && ' · No expiry'}
</p>
</div>
{expired && (
<span className="text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">Expired</span>
)}
<button
onClick={() => deleteToken.mutate(tok.id)}
disabled={deleteToken.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors disabled:opacity-40"
title="Revoke token"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</li>
)
})}
</ul>
)}
</div>
{/* Create form */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Create an access token</h2>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Token name</label>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. GitHub Actions"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-2">Scopes</label>
<div className="space-y-2">
{Object.entries(scopeLabels).map(([key, val]) => (
<label key={key} className="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" checked={scopes.includes(key)} onChange={() => toggleScope(key)}
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
<div>
<span className="text-sm font-medium text-[var(--c-text)]">{val.label}</span>
<p className="text-xs text-[var(--c-muted)]">{val.description}</p>
</div>
</label>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Expiry date <span className="text-[var(--c-subtle)]">(optional)</span></label>
<input type="date" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
</div>
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
<button type="submit" disabled={createToken.isPending || !title.trim() || scopes.length === 0}
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">
{createToken.isPending ? 'Generating' : 'Generate token'}
</button>
</form>
</div>
</div>
)
}
// ─── Coming soon ──────────────────────────────────────────────────────────────
function ComingSoon({ sectionId }: { sectionId: SectionId }) {
+17
View File
@@ -92,6 +92,23 @@ export interface SSHKey {
createdAt: string
}
export interface DeployKey {
id: number
title: string
readOnly: boolean
createdAt: string
token?: string
}
export interface AccessToken {
id: number
title: string
scopes: string
expiresAt?: string | null
createdAt: string
token?: string
}
export interface RepoMember {
userId: number
username: string
+28 -4
View File
@@ -60,25 +60,49 @@ func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) {
}
// Authenticate and enforce permission checks.
// Priority: user account → deploy key → anonymous (public repos only).
var authedUser string
user, authed := h.basicAuth(r)
if authed {
var authedReadOnly bool
if _, p, hasAuth := r.BasicAuth(); hasAuth {
if user, ok := h.basicAuth(r); ok {
authedUser = user
// Push requires write or admin permission.
// User account: enforce member permissions.
if service == "git-receive-pack" && !HasPermission(h.db, &repo, user, "write") {
http.Error(w, "forbidden: you do not have write access to this repository", http.StatusForbidden)
return
}
// Pull on a private repo requires at least read permission.
if repo.IsPrivate && !HasPermission(h.db, &repo, user, "read") {
http.Error(w, "forbidden: you do not have read access to this repository", http.StatusForbidden)
return
}
} else if rdOnly, ok := AuthenticateDeployKey(h.db, repo.ID, p); ok {
// Deploy key: the password field carries the raw token; username is ignored.
authedUser = "deploy-key"
authedReadOnly = rdOnly
if service == "git-receive-pack" && rdOnly {
http.Error(w, "forbidden: this deploy key is read-only", http.StatusForbidden)
return
}
} else if _, repoID, hasWrite, ok := LookupAccessToken(h.db, p); ok && repoID == repo.ID {
// Access token used as git credential (username ignored, password = token).
authedUser = "access-token"
if service == "git-receive-pack" && !hasWrite {
http.Error(w, "forbidden: this access token has read-only scope", http.StatusForbidden)
return
}
} else {
// Credentials provided but invalid.
w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`)
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
} else if service == "git-receive-pack" || repo.IsPrivate {
w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
_ = authedReadOnly
// Build PATH_INFO: /{reponame}.git/{suffix}
// Strip the /{owner}/{repoGit} prefix from the raw URL path to get the suffix.
+179
View File
@@ -0,0 +1,179 @@
package handlers
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type DeployKeyHandler struct{ db *xorm.Engine }
func NewDeployKeyHandler(db *xorm.Engine) *DeployKeyHandler { return &DeployKeyHandler{db: db} }
// generateToken produces a prefixed random token and its SHA-256 hex hash.
func generateToken(prefix string) (raw, hash string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
raw = prefix + base64.RawURLEncoding.EncodeToString(b)
sum := sha256.Sum256([]byte(raw))
hash = hex.EncodeToString(sum[:])
return
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
type deployKeyResponse struct {
ID int64 `json:"id"`
Title string `json:"title"`
ReadOnly bool `json:"readOnly"`
CreatedAt string `json:"createdAt"`
// Token is only populated on creation; empty on subsequent list calls.
Token string `json:"token,omitempty"`
}
func (h *DeployKeyHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
return &repo, &owner, true
}
func (h *DeployKeyHandler) canManage(repo *models.Repository, callerID int64) bool {
if callerID == repo.OwnerID {
return true
}
var m models.RepoMember
found, _ := h.db.Where("repo_id = ? AND user_id = ? AND permission = 'admin'", repo.ID, callerID).Get(&m)
return found
}
func (h *DeployKeyHandler) List(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.lookupRepo(w, r)
if !ok {
return
}
var keys []models.RepoDeployKey
h.db.Where("repo_id = ?", repo.ID).OrderBy("created_at desc").Find(&keys)
resp := make([]deployKeyResponse, 0, len(keys))
for _, k := range keys {
resp = append(resp, deployKeyResponse{
ID: k.ID,
Title: k.Title,
ReadOnly: k.ReadOnly,
CreatedAt: k.CreatedAt.Format(time.RFC3339),
})
}
jsonOK(w, resp)
}
func (h *DeployKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.lookupRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "only the owner or an admin can manage access keys", http.StatusForbidden)
return
}
var body struct {
Title string `json:"title"`
ReadOnly bool `json:"readOnly"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Title == "" {
jsonError(w, "title is required", http.StatusBadRequest)
return
}
raw, hash, err := generateToken("fbdk_")
if err != nil {
jsonError(w, "could not generate token", http.StatusInternalServerError)
return
}
key := &models.RepoDeployKey{
RepoID: repo.ID,
Title: body.Title,
TokenHash: hash,
ReadOnly: body.ReadOnly,
}
if _, err := h.db.Insert(key); err != nil {
jsonError(w, "could not save key", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(deployKeyResponse{
ID: key.ID,
Title: key.Title,
ReadOnly: key.ReadOnly,
CreatedAt: key.CreatedAt.Format(time.RFC3339),
Token: raw,
})
}
func (h *DeployKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.lookupRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "only the owner or an admin can manage access keys", http.StatusForbidden)
return
}
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
if err != nil {
jsonError(w, "invalid key ID", http.StatusBadRequest)
return
}
h.db.Where("id = ? AND repo_id = ?", keyID, repo.ID).Delete(&models.RepoDeployKey{})
w.WriteHeader(http.StatusNoContent)
}
// AuthenticateDeployKey checks if the given raw token is a valid deploy key for the repo.
// Returns (readOnly, ok).
func AuthenticateDeployKey(db *xorm.Engine, repoID int64, rawToken string) (readOnly bool, ok bool) {
if len(rawToken) < 5 {
return false, false
}
hash := sha256Hex(rawToken)
var key models.RepoDeployKey
found, _ := db.Where("repo_id = ? AND token_hash = ?", repoID, hash).Get(&key)
if !found {
return false, false
}
// Update last_used
now := time.Now()
key.LastUsed = &now
db.ID(key.ID).Cols("last_used_at").Update(&key)
return key.ReadOnly, true
}
+193
View File
@@ -0,0 +1,193 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type AccessTokenHandler struct{ db *xorm.Engine }
func NewAccessTokenHandler(db *xorm.Engine) *AccessTokenHandler {
return &AccessTokenHandler{db: db}
}
type accessTokenResponse struct {
ID int64 `json:"id"`
Title string `json:"title"`
Scopes string `json:"scopes"`
ExpiresAt *string `json:"expiresAt"`
CreatedAt string `json:"createdAt"`
Token string `json:"token,omitempty"` // only on creation
}
func (h *AccessTokenHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
}
func (h *AccessTokenHandler) canManage(repo *models.Repository, callerID int64) bool {
if callerID == repo.OwnerID {
return true
}
var m models.RepoMember
found, _ := h.db.Where("repo_id = ? AND user_id = ? AND permission = 'admin'", repo.ID, callerID).Get(&m)
return found
}
func (h *AccessTokenHandler) List(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
var tokens []models.RepoAccessToken
h.db.Where("repo_id = ?", repo.ID).OrderBy("created_at desc").Find(&tokens)
resp := make([]accessTokenResponse, 0, len(tokens))
for _, t := range tokens {
var exp *string
if t.ExpiresAt != nil {
s := t.ExpiresAt.Format("2006-01-02")
exp = &s
}
resp = append(resp, accessTokenResponse{
ID: t.ID,
Title: t.Title,
Scopes: t.Scopes,
ExpiresAt: exp,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
})
}
jsonOK(w, resp)
}
func (h *AccessTokenHandler) Create(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "only the owner or an admin can manage access tokens", http.StatusForbidden)
return
}
var body struct {
Title string `json:"title"`
Scopes string `json:"scopes"` // "read" | "read,write"
ExpiresAt string `json:"expiresAt"` // "2026-12-31" or ""
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Title == "" {
jsonError(w, "title is required", http.StatusBadRequest)
return
}
if body.Scopes == "" {
body.Scopes = "read"
}
var expiresAt *time.Time
if body.ExpiresAt != "" {
t, err := time.Parse("2006-01-02", body.ExpiresAt)
if err != nil {
jsonError(w, "invalid expiresAt format; use YYYY-MM-DD", http.StatusBadRequest)
return
}
t = t.UTC()
expiresAt = &t
}
raw, hash, err := generateToken("fbat_")
if err != nil {
jsonError(w, "could not generate token", http.StatusInternalServerError)
return
}
token := &models.RepoAccessToken{
RepoID: repo.ID,
CreatorID: callerID,
Title: body.Title,
TokenHash: hash,
Scopes: body.Scopes,
ExpiresAt: expiresAt,
}
if _, err := h.db.Insert(token); err != nil {
jsonError(w, "could not save token", http.StatusInternalServerError)
return
}
var exp *string
if expiresAt != nil {
s := expiresAt.Format("2006-01-02")
exp = &s
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(accessTokenResponse{
ID: token.ID,
Title: token.Title,
Scopes: token.Scopes,
ExpiresAt: exp,
CreatedAt: token.CreatedAt.Format(time.RFC3339),
Token: raw,
})
}
func (h *AccessTokenHandler) Delete(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "only the owner or an admin can manage access tokens", http.StatusForbidden)
return
}
tokenID, err := strconv.ParseInt(chi.URLParam(r, "tokenID"), 10, 64)
if err != nil {
jsonError(w, "invalid token ID", http.StatusBadRequest)
return
}
h.db.Where("id = ? AND repo_id = ?", tokenID, repo.ID).Delete(&models.RepoAccessToken{})
w.WriteHeader(http.StatusNoContent)
}
// LookupAccessToken validates a Bearer token and returns the creator's userID.
// Returns (userID, repoID, hasWrite, ok).
func LookupAccessToken(db *xorm.Engine, rawToken string) (userID, repoID int64, hasWrite bool, ok bool) {
if !strings.HasPrefix(rawToken, "fbat_") {
return
}
hash := sha256Hex(rawToken)
var t models.RepoAccessToken
found, _ := db.Where("token_hash = ?", hash).Get(&t)
if !found {
return
}
if t.ExpiresAt != nil && t.ExpiresAt.Before(time.Now()) {
return
}
now := time.Now()
t.LastUsed = &now
db.ID(t.ID).Cols("last_used_at").Update(&t)
hasWrite = strings.Contains(t.Scopes, "write")
return t.CreatorID, t.RepoID, hasWrite, true
}
+51 -15
View File
@@ -3,8 +3,10 @@ package middleware
import (
"context"
"net/http"
"strings"
"github.com/gorilla/sessions"
"xorm.io/xorm"
)
type contextKey string
@@ -15,28 +17,36 @@ const (
ContextKeyIsAdmin contextKey = "isAdmin"
)
// TokenLookupFn is injected to avoid an import cycle with the handlers package.
type TokenLookupFn func(db *xorm.Engine, rawToken string) (userID, repoID int64, hasWrite bool, ok bool)
type AuthMiddleware struct {
store sessions.Store
db *xorm.Engine
lookupToken TokenLookupFn
}
func NewAuth(store sessions.Store) *AuthMiddleware {
return &AuthMiddleware{store: store}
func NewAuth(store sessions.Store, db *xorm.Engine, lookupToken TokenLookupFn) *AuthMiddleware {
return &AuthMiddleware{store: store, db: db, lookupToken: lookupToken}
}
func (a *AuthMiddleware) Require(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
func extractBearer(r *http.Request) string {
v := r.Header.Get("Authorization")
if strings.HasPrefix(v, "Bearer ") {
return strings.TrimPrefix(v, "Bearer ")
}
return ""
}
func (a *AuthMiddleware) trySession(r *http.Request) (context.Context, bool) {
session, err := a.store.Get(r, "fb_session")
if err != nil || session.IsNew {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
return r.Context(), false
}
userID, ok := session.Values["userID"].(int64)
if !ok || userID == 0 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
return r.Context(), false
}
ctx := context.WithValue(r.Context(), ContextKeyUserID, userID)
if username, ok := session.Values["username"].(string); ok {
ctx = context.WithValue(ctx, ContextKeyUsername, username)
@@ -44,19 +54,45 @@ func (a *AuthMiddleware) Require(next http.Handler) http.Handler {
if isAdmin, ok := session.Values["isAdmin"].(bool); ok {
ctx = context.WithValue(ctx, ContextKeyIsAdmin, isAdmin)
}
return ctx, true
}
func (a *AuthMiddleware) tryBearer(r *http.Request) (context.Context, bool) {
raw := extractBearer(r)
if raw == "" || a.lookupToken == nil {
return r.Context(), false
}
userID, _, _, ok := a.lookupToken(a.db, raw)
if !ok {
return r.Context(), false
}
ctx := context.WithValue(r.Context(), ContextKeyUserID, userID)
return ctx, true
}
func (a *AuthMiddleware) Require(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ctx, ok := a.trySession(r); ok {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
if ctx, ok := a.tryBearer(r); ok {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
})
}
func (a *AuthMiddleware) Optional(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := a.store.Get(r, "fb_session")
if err == nil && !session.IsNew {
if userID, ok := session.Values["userID"].(int64); ok && userID != 0 {
ctx := context.WithValue(r.Context(), ContextKeyUserID, userID)
r = r.WithContext(ctx)
if ctx, ok := a.trySession(r); ok {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
if ctx, ok := a.tryBearer(r); ok {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
next.ServeHTTP(w, r)
})
+13 -1
View File
@@ -33,7 +33,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
}))
csrf := middleware.CSRF(!cfg.Debug)
auth := middleware.NewAuth(store)
auth := middleware.NewAuth(store, engine, handlers.LookupAccessToken)
repoH := handlers.NewRepoHandler(engine, cfg)
userH := handlers.NewUserHandler(engine, store)
@@ -44,6 +44,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
issueH := handlers.NewIssueHandler(engine)
sshKeyH := handlers.NewSSHKeyHandler(engine)
memberH := handlers.NewMemberHandler(engine)
keyH := handlers.NewDeployKeyHandler(engine)
tokenH := handlers.NewAccessTokenHandler(engine)
// ── Git smart-HTTP transport ───────────────────────────────────────────────
// These routes MUST be registered before the SPA catch-all and outside CSRF.
@@ -141,6 +143,16 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
r.With(csrf).Patch("/{username}", memberH.UpdatePermission)
r.With(csrf).Delete("/{username}", memberH.Remove)
})
r.Route("/keys", func(r chi.Router) {
r.Get("/", keyH.List)
r.With(csrf).Post("/", keyH.Create)
r.With(csrf).Delete("/{keyID}", keyH.Delete)
})
r.Route("/tokens", func(r chi.Router) {
r.Get("/", tokenH.List)
r.With(csrf).Post("/", tokenH.Create)
r.With(csrf).Delete("/{tokenID}", tokenH.Delete)
})
})
})
})
+17
View File
@@ -0,0 +1,17 @@
package models
import "time"
// RepoAccessToken grants scoped API (and git) access to a specific repo.
// Stored as a SHA-256 hash; the raw token is shown once on creation.
type RepoAccessToken struct {
ID int64 `xorm:"'id' pk autoincr"`
RepoID int64 `xorm:"'repo_id' notnull index"`
CreatorID int64 `xorm:"'creator_id' notnull"`
Title string `xorm:"'title' notnull"`
TokenHash string `xorm:"'token_hash' notnull unique"`
Scopes string `xorm:"'scopes' notnull"` // "read" | "read,write"
ExpiresAt *time.Time `xorm:"'expires_at'"`
LastUsed *time.Time `xorm:"'last_used_at'"`
CreatedAt time.Time `xorm:"'created_at' created"`
}
+15
View File
@@ -0,0 +1,15 @@
package models
import "time"
// RepoDeployKey is an HTTP token that grants git access to a specific repo.
// Stored as a SHA-256 hash; the raw token is shown once on creation.
type RepoDeployKey struct {
ID int64 `xorm:"'id' pk autoincr"`
RepoID int64 `xorm:"'repo_id' notnull index"`
Title string `xorm:"'title' notnull"`
TokenHash string `xorm:"'token_hash' notnull unique"`
ReadOnly bool `xorm:"'read_only' default true"`
LastUsed *time.Time `xorm:"'last_used_at'"`
CreatedAt time.Time `xorm:"'created_at' created"`
}
+4 -1
View File
@@ -19,5 +19,8 @@ func Run(engine *xorm.Engine) error {
if err := Run002(engine); err != nil {
return err
}
return Run003(engine)
if err := Run003(engine); err != nil {
return err
}
return Run004(engine)
}
@@ -0,0 +1,13 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run004(engine *xorm.Engine) error {
return engine.Sync2(
&models.RepoDeployKey{},
&models.RepoAccessToken{},
)
}