diff --git a/.repos/3/demo-repo.git/logs/refs/heads/main b/.repos/3/demo-repo.git/logs/refs/heads/main index bffca4d..0ae21f4 100644 --- a/.repos/3/demo-repo.git/logs/refs/heads/main +++ b/.repos/3/demo-repo.git/logs/refs/heads/main @@ -1,2 +1,4 @@ 822b85a78b17deb9373a3f5a802e9db2a9893846 eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 devtest 1778145720 +0200 commit: Update README via editor eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd testuser 1778158078 +0200 push +b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd dbbf02422886140077541fff3dd8874def5d0b07 deploy-key 1778158842 +0200 push +dbbf02422886140077541fff3dd8874def5d0b07 c5c74f826a772ffa4eb0a7de315d66be01f797f7 access-token 1778158967 +0200 push diff --git a/.repos/3/demo-repo.git/objects/17/17a8dd130ab9a8e14790a94a06fcf56018c061 b/.repos/3/demo-repo.git/objects/17/17a8dd130ab9a8e14790a94a06fcf56018c061 new file mode 100644 index 0000000..d074ac9 Binary files /dev/null and b/.repos/3/demo-repo.git/objects/17/17a8dd130ab9a8e14790a94a06fcf56018c061 differ diff --git a/.repos/3/demo-repo.git/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827 b/.repos/3/demo-repo.git/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827 new file mode 100644 index 0000000..f74bf23 Binary files /dev/null and b/.repos/3/demo-repo.git/objects/18/0cf8328022becee9aaa2577a8f84ea2b9f3827 differ diff --git a/.repos/3/demo-repo.git/objects/2a/f7a10f10625157495e11c480e38c7288f0b0cb b/.repos/3/demo-repo.git/objects/2a/f7a10f10625157495e11c480e38c7288f0b0cb new file mode 100644 index 0000000..83f0084 Binary files /dev/null and b/.repos/3/demo-repo.git/objects/2a/f7a10f10625157495e11c480e38c7288f0b0cb differ diff --git a/.repos/3/demo-repo.git/objects/3e/77cfa6061320c3eeeb57a0df4f43779a065112 b/.repos/3/demo-repo.git/objects/3e/77cfa6061320c3eeeb57a0df4f43779a065112 new file mode 100644 index 0000000..c92d8c3 Binary files /dev/null and b/.repos/3/demo-repo.git/objects/3e/77cfa6061320c3eeeb57a0df4f43779a065112 differ diff --git a/.repos/3/demo-repo.git/objects/c5/c74f826a772ffa4eb0a7de315d66be01f797f7 b/.repos/3/demo-repo.git/objects/c5/c74f826a772ffa4eb0a7de315d66be01f797f7 new file mode 100644 index 0000000..a49105e Binary files /dev/null and b/.repos/3/demo-repo.git/objects/c5/c74f826a772ffa4eb0a7de315d66be01f797f7 differ diff --git a/.repos/3/demo-repo.git/objects/db/bf02422886140077541fff3dd8874def5d0b07 b/.repos/3/demo-repo.git/objects/db/bf02422886140077541fff3dd8874def5d0b07 new file mode 100644 index 0000000..a86526f Binary files /dev/null and b/.repos/3/demo-repo.git/objects/db/bf02422886140077541fff3dd8874def5d0b07 differ diff --git a/.repos/3/demo-repo.git/refs/heads/main b/.repos/3/demo-repo.git/refs/heads/main index ae7c67e..c5460c1 100644 --- a/.repos/3/demo-repo.git/refs/heads/main +++ b/.repos/3/demo-repo.git/refs/heads/main @@ -1 +1 @@ -b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd +c5c74f826a772ffa4eb0a7de315d66be01f797f7 diff --git a/frontend/src/api/queries/keys.ts b/frontend/src/api/queries/keys.ts new file mode 100644 index 0000000..06aa2bb --- /dev/null +++ b/frontend/src/api/queries/keys.ts @@ -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(`/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(`/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(`/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(`/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'] }), + }) +} diff --git a/frontend/src/pages/RepoSettingsPage.tsx b/frontend/src/pages/RepoSettingsPage.tsx index 23af1a6..f3927e0 100644 --- a/frontend/src/pages/RepoSettingsPage.tsx +++ b/frontend/src/pages/RepoSettingsPage.tsx @@ -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() { {section === 'repository-details' && } {section === 'repository-permissions' && } - {section !== 'repository-details' && section !== 'repository-permissions' && } + {section === 'access-keys' && } + {section === 'access-tokens' && } + {!['repository-details','repository-permissions','access-keys','access-tokens'].includes(section) && } ) @@ -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 ( +
+
+ + + + Token created — copy it now, it won't be shown again +
+
+
+ + {token} + + +
+ +
+
+ ) +} + +// ─── 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(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 ( +
+
+

Access keys

+

+ Deploy keys grant git clone and push access to this repository over HTTP. Each key is shown only once. +

+
+ Use as git credentials: git clone http://x-deploy-key:<TOKEN>@{window.location.hostname}/{owner}/{repo}.git +
+
+ +
+ + {/* Token reveal banner */} + {newToken && setNewToken(null)} />} + + {/* Existing keys */} +
+
+

Active keys

+ {keys?.length ?? 0} key{keys?.length !== 1 ? 's' : ''} +
+ + {isLoading ? ( +
+ {[1, 2].map(i =>
)} +
+ ) : !keys?.length ? ( +
No deploy keys yet.
+ ) : ( +
    + {keys.map(key => ( +
  • + + + +
    +

    {key.title}

    +

    + {key.readOnly ? 'Read-only' : 'Read & write'} · Added {new Date(key.createdAt).toLocaleDateString()} +

    +
    + + {key.readOnly ? 'Read' : 'Write'} + + +
  • + ))} +
+ )} +
+ + {/* Create form */} +
+
+

Add a deploy key

+
+
+
+ + 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)]" /> +
+ + {formError &&

{formError}

} + +
+
+
+ ) +} + +// ─── 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(['read']) + const [expiresAt, setExpiresAt] = useState('') + const [newToken, setNewToken] = useState(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 = { + read: { label: 'Read', description: 'Read repo contents, branches, commits, PRs, issues' }, + write: { label: 'Write', description: 'Push code, create PRs and issues' }, + } + + return ( +
+
+

Access tokens

+

+ Tokens grant programmatic access to this repository's API and git operations. Each token is shown only once. +

+
+

API: Authorization: Bearer <TOKEN>

+

Git: git clone http://x-token:<TOKEN>@{window.location.hostname}/{owner}/{repo}.git

+
+
+ +
+ + {newToken && setNewToken(null)} />} + + {/* Token list */} +
+
+

Active tokens

+ {tokens?.length ?? 0} token{tokens?.length !== 1 ? 's' : ''} +
+ + {isLoading ? ( +
+ {[1, 2].map(i =>
)} +
+ ) : !tokens?.length ? ( +
No access tokens yet.
+ ) : ( +
    + {tokens.map(tok => { + const expired = tok.expiresAt && new Date(tok.expiresAt) < new Date() + return ( +
  • + + + +
    +

    {tok.title}

    +

    + Scopes: {tok.scopes} + {tok.expiresAt && ( + + )} + {!tok.expiresAt && ' · No expiry'} +

    +
    + {expired && ( + Expired + )} + +
  • + ) + })} +
+ )} +
+ + {/* Create form */} +
+
+

Create an access token

+
+
+
+ + 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)]" /> +
+ +
+ +
+ {Object.entries(scopeLabels).map(([key, val]) => ( + + ))} +
+
+ +
+ + 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)]" /> +
+ + {formError &&

{formError}

} + + +
+
+
+ ) +} + // ─── Coming soon ────────────────────────────────────────────────────────────── function ComingSoon({ sectionId }: { sectionId: SectionId }) { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 34e0188..c929717 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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 diff --git a/internal/api/handlers/githttp.go b/internal/api/handlers/githttp.go index ead8e35..0760446 100644 --- a/internal/api/handlers/githttp.go +++ b/internal/api/handlers/githttp.go @@ -60,18 +60,41 @@ 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 { - authedUser = user - // Push requires write or admin permission. - 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) + var authedReadOnly bool + + if _, p, hasAuth := r.BasicAuth(); hasAuth { + if user, ok := h.basicAuth(r); ok { + authedUser = user + // 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 + } + 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 { @@ -79,6 +102,7 @@ func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) { 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. diff --git a/internal/api/handlers/keys.go b/internal/api/handlers/keys.go new file mode 100644 index 0000000..16e4cb2 --- /dev/null +++ b/internal/api/handlers/keys.go @@ -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 +} diff --git a/internal/api/handlers/tokens.go b/internal/api/handlers/tokens.go new file mode 100644 index 0000000..9cfd097 --- /dev/null +++ b/internal/api/handlers/tokens.go @@ -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 +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 8fa262b..c4c5ebd 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -3,8 +3,10 @@ package middleware import ( "context" "net/http" + "strings" "github.com/gorilla/sessions" + "xorm.io/xorm" ) type contextKey string @@ -15,48 +17,82 @@ 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 + 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 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 { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session, err := a.store.Get(r, "fb_session") - if err != nil || session.IsNew { - http.Error(w, "unauthorized", http.StatusUnauthorized) + if ctx, ok := a.trySession(r); ok { + next.ServeHTTP(w, r.WithContext(ctx)) return } - - userID, ok := session.Values["userID"].(int64) - if !ok || userID == 0 { - http.Error(w, "unauthorized", http.StatusUnauthorized) + if ctx, ok := a.tryBearer(r); ok { + next.ServeHTTP(w, r.WithContext(ctx)) return } - - 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)) + 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) }) diff --git a/internal/api/router.go b/internal/api/router.go index f102254..422fb7d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) + }) }) }) }) diff --git a/internal/models/access_token.go b/internal/models/access_token.go new file mode 100644 index 0000000..09600cb --- /dev/null +++ b/internal/models/access_token.go @@ -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"` +} diff --git a/internal/models/deploy_key.go b/internal/models/deploy_key.go new file mode 100644 index 0000000..0be06f1 --- /dev/null +++ b/internal/models/deploy_key.go @@ -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"` +} diff --git a/internal/models/migrations/001_init.go b/internal/models/migrations/001_init.go index 3c8df80..6dd418d 100644 --- a/internal/models/migrations/001_init.go +++ b/internal/models/migrations/001_init.go @@ -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) } diff --git a/internal/models/migrations/004_keys_tokens.go b/internal/models/migrations/004_keys_tokens.go new file mode 100644 index 0000000..1eee109 --- /dev/null +++ b/internal/models/migrations/004_keys_tokens.go @@ -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{}, + ) +}