security sections are fully functional
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
822b85a78b17deb9373a3f5a802e9db2a9893846 eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 devtest <dev@test.com> 1778145720 +0200 commit: Update README via editor
|
822b85a78b17deb9373a3f5a802e9db2a9893846 eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 devtest <dev@test.com> 1778145720 +0200 commit: Update README via editor
|
||||||
eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd testuser <testuser@http.[::1]:51142> 1778158078 +0200 push
|
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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
|||||||
b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd
|
c5c74f826a772ffa4eb0a7de315d66be01f797f7
|
||||||
|
|||||||
@@ -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'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||||||
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
|
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos'
|
import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos'
|
||||||
import { useRepoMembers, useAddMember, useUpdateMember, useRemoveMember } from '../api/queries/members'
|
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 { useRecentRepos } from '../hooks/useRecentRepos'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { Skeleton } from '../ui/Skeleton'
|
import { Skeleton } from '../ui/Skeleton'
|
||||||
@@ -132,7 +133,9 @@ export default function RepoSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{section === 'repository-details' && <RepositoryDetailsSection owner={owner} repo={repoName} />}
|
{section === 'repository-details' && <RepositoryDetailsSection owner={owner} repo={repoName} />}
|
||||||
{section === 'repository-permissions' && <RepositoryPermissionsSection 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>
|
</main>
|
||||||
</div>
|
</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:<TOKEN>@{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 <TOKEN></code></p>
|
||||||
|
<p>Git: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-token:<TOKEN>@{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 ──────────────────────────────────────────────────────────────
|
// ─── Coming soon ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ComingSoon({ sectionId }: { sectionId: SectionId }) {
|
function ComingSoon({ sectionId }: { sectionId: SectionId }) {
|
||||||
|
|||||||
@@ -92,6 +92,23 @@ export interface SSHKey {
|
|||||||
createdAt: string
|
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 {
|
export interface RepoMember {
|
||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
@@ -60,18 +60,41 @@ func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate and enforce permission checks.
|
// Authenticate and enforce permission checks.
|
||||||
|
// Priority: user account → deploy key → anonymous (public repos only).
|
||||||
var authedUser string
|
var authedUser string
|
||||||
user, authed := h.basicAuth(r)
|
var authedReadOnly bool
|
||||||
if authed {
|
|
||||||
authedUser = user
|
if _, p, hasAuth := r.BasicAuth(); hasAuth {
|
||||||
// Push requires write or admin permission.
|
if user, ok := h.basicAuth(r); ok {
|
||||||
if service == "git-receive-pack" && !HasPermission(h.db, &repo, user, "write") {
|
authedUser = user
|
||||||
http.Error(w, "forbidden: you do not have write access to this repository", http.StatusForbidden)
|
// User account: enforce member permissions.
|
||||||
return
|
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)
|
||||||
// Pull on a private repo requires at least read permission.
|
return
|
||||||
if repo.IsPrivate && !HasPermission(h.db, &repo, user, "read") {
|
}
|
||||||
http.Error(w, "forbidden: you do not have read access to this repository", http.StatusForbidden)
|
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
|
return
|
||||||
}
|
}
|
||||||
} else if service == "git-receive-pack" || repo.IsPrivate {
|
} else if service == "git-receive-pack" || repo.IsPrivate {
|
||||||
@@ -79,6 +102,7 @@ func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_ = authedReadOnly
|
||||||
|
|
||||||
// Build PATH_INFO: /{reponame}.git/{suffix}
|
// Build PATH_INFO: /{reponame}.git/{suffix}
|
||||||
// Strip the /{owner}/{repoGit} prefix from the raw URL path to get the suffix.
|
// Strip the /{owner}/{repoGit} prefix from the raw URL path to get the suffix.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -15,48 +17,82 @@ const (
|
|||||||
ContextKeyIsAdmin contextKey = "isAdmin"
|
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 {
|
type AuthMiddleware struct {
|
||||||
store sessions.Store
|
store sessions.Store
|
||||||
|
db *xorm.Engine
|
||||||
|
lookupToken TokenLookupFn
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuth(store sessions.Store) *AuthMiddleware {
|
func NewAuth(store sessions.Store, db *xorm.Engine, lookupToken TokenLookupFn) *AuthMiddleware {
|
||||||
return &AuthMiddleware{store: store}
|
return &AuthMiddleware{store: store, db: db, lookupToken: lookupToken}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return r.Context(), false
|
||||||
|
}
|
||||||
|
userID, ok := session.Values["userID"].(int64)
|
||||||
|
if !ok || userID == 0 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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 {
|
func (a *AuthMiddleware) Require(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session, err := a.store.Get(r, "fb_session")
|
if ctx, ok := a.trySession(r); ok {
|
||||||
if err != nil || session.IsNew {
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ctx, ok := a.tryBearer(r); ok {
|
||||||
userID, ok := session.Values["userID"].(int64)
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
if !ok || userID == 0 {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
ctx := context.WithValue(r.Context(), ContextKeyUserID, userID)
|
|
||||||
if username, ok := session.Values["username"].(string); ok {
|
|
||||||
ctx = context.WithValue(ctx, ContextKeyUsername, username)
|
|
||||||
}
|
|
||||||
if isAdmin, ok := session.Values["isAdmin"].(bool); ok {
|
|
||||||
ctx = context.WithValue(ctx, ContextKeyIsAdmin, isAdmin)
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthMiddleware) Optional(next http.Handler) http.Handler {
|
func (a *AuthMiddleware) Optional(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session, err := a.store.Get(r, "fb_session")
|
if ctx, ok := a.trySession(r); ok {
|
||||||
if err == nil && !session.IsNew {
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
if userID, ok := session.Values["userID"].(int64); ok && userID != 0 {
|
return
|
||||||
ctx := context.WithValue(r.Context(), ContextKeyUserID, userID)
|
}
|
||||||
r = r.WithContext(ctx)
|
if ctx, ok := a.tryBearer(r); ok {
|
||||||
}
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
+13
-1
@@ -33,7 +33,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
csrf := middleware.CSRF(!cfg.Debug)
|
csrf := middleware.CSRF(!cfg.Debug)
|
||||||
auth := middleware.NewAuth(store)
|
auth := middleware.NewAuth(store, engine, handlers.LookupAccessToken)
|
||||||
|
|
||||||
repoH := handlers.NewRepoHandler(engine, cfg)
|
repoH := handlers.NewRepoHandler(engine, cfg)
|
||||||
userH := handlers.NewUserHandler(engine, store)
|
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)
|
issueH := handlers.NewIssueHandler(engine)
|
||||||
sshKeyH := handlers.NewSSHKeyHandler(engine)
|
sshKeyH := handlers.NewSSHKeyHandler(engine)
|
||||||
memberH := handlers.NewMemberHandler(engine)
|
memberH := handlers.NewMemberHandler(engine)
|
||||||
|
keyH := handlers.NewDeployKeyHandler(engine)
|
||||||
|
tokenH := handlers.NewAccessTokenHandler(engine)
|
||||||
|
|
||||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||||
// These routes MUST be registered before the SPA catch-all and outside CSRF.
|
// 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).Patch("/{username}", memberH.UpdatePermission)
|
||||||
r.With(csrf).Delete("/{username}", memberH.Remove)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -19,5 +19,8 @@ func Run(engine *xorm.Engine) error {
|
|||||||
if err := Run002(engine); err != nil {
|
if err := Run002(engine); err != nil {
|
||||||
return err
|
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{},
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user