commit 9960dd8b0a857b633e11ca0cdf51b1b952934ed2
parent dc7998b6d7658e8390628ab315e9fcee9dfda795
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Oct 2026 01:54:53 +0000
feat(projects): serve git repos from the site
Project pages no longer link out to stagit:
- README links and images point at /projects/<id>/raw/<path>, served
from HEAD of the bare repo
- the detail header gets an https/ssh clone bar with copy (enter copies)
- the public SSH server runs read-only git-upload-pack for repos marked
git-daemon-export-ok
GIT_WEB_BASE is replaced by GIT_CLONE_BASE. WakaTime is hidden behind
config.WakaTimeEnabled (off) on web and TUI.
Diffstat:
23 files changed, 486 insertions(+), 94 deletions(-)
diff --git a/Dockerfile b/Dockerfile
@@ -19,7 +19,7 @@ RUN apk add --no-cache git \
&& git config --system safe.directory '*'
ENV GIT_REPOS_DIR=/srv/git/repos
-ENV GIT_WEB_BASE=https://git.prime8.dev
+ENV GIT_CLONE_BASE=https://prime8.dev
COPY --from=build /server ./server
COPY --from=build /app/static ./static
diff --git a/README.md b/README.md
@@ -34,14 +34,19 @@ docker build -t prime8dev .
## Projects
-Project READMEs and commit dates are read from local bare git repos with
-`git` (no API). Project pages link to the [stagit](https://codemadness.org/stagit.html)
-site.
+Project READMEs are read from local bare git repos with
+`git` (no API). Files referenced from a README are served from `HEAD` at
+`/projects/<id>/raw/<path>`.
+
+Public repos (those with `git-daemon-export-ok`) can be cloned over:
+
+- HTTPS (dumb HTTP, served by the reverse proxy): `git clone https://prime8.dev/<repo>.git`
+- SSH (the public SSH server runs `git-upload-pack`, read-only): `git clone git@prime8.dev:<repo>.git`
| Env | Default | Purpose |
| --- | --- | --- |
| `GIT_REPOS_DIR` | `/srv/git/repos` | Bare repos live at `$GIT_REPOS_DIR/<repo>.git` |
-| `GIT_WEB_BASE` | `https://git.prime8.dev` | stagit base: `<base>/<repo>/`, `file/<path>.html`, `raw/<path>` |
+| `GIT_CLONE_BASE` | `https://prime8.dev` | HTTPS clone URL shown on project pages: `<base>/<repo>.git` |
In Docker, bind-mount the repos read-only, e.g.
`-v /srv/git/repos:/srv/git/repos:ro`. The image sets
diff --git a/cmd/server/handlers/projects.go b/cmd/server/handlers/projects.go
@@ -1,12 +1,16 @@
package handlers
import (
+ "errors"
"html/template"
+ "mime"
"net/http"
+ "path/filepath"
"strings"
"git.prime8.dev/moncheeta/prime8dev/internal/config"
"git.prime8.dev/moncheeta/prime8dev/internal/content"
+ "git.prime8.dev/moncheeta/prime8dev/internal/external"
"git.prime8.dev/moncheeta/prime8dev/internal/utils"
"git.prime8.dev/moncheeta/prime8dev/internal/web"
"github.com/go-chi/chi/v5"
@@ -60,7 +64,6 @@ func applyProjectDetailData(data *web.Data, project *config.Project) {
}
data.Project = project
- data.ProjectLinkLabel = projectLinkLabel(project.URL)
data.ProjectReadme = template.HTML(project.HTML)
}
@@ -77,22 +80,44 @@ func (h *Handler) ProjectDetailHandler(w http.ResponseWriter, r *http.Request) {
}
data := web.Data{
- Project: project,
- ProjectLinkLabel: projectLinkLabel(project.URL),
- ProjectReadme: template.HTML(project.HTML),
+ Project: project,
+ ProjectReadme: template.HTML(project.HTML),
}
h.renderTemplate(w, r, "project_detail", data, fragmentCacheMaxAgeSeconds)
}
-func projectLinkLabel(raw string) string {
- if strings.HasPrefix(raw, config.GitWebBase()+"/") || strings.Contains(raw, "git.prime8.dev") {
- return "Source"
- } else if strings.Contains(raw, "gitlab.com") {
- return "GitLab"
- } else if strings.Contains(raw, "github.com") {
- return "GitHub"
- } else {
- return "Outside"
+// ProjectRawHandler serves a file from HEAD of a public project's repo, so
+// README images and links resolve without a separate git web frontend.
+func (h *Handler) ProjectRawHandler(w http.ResponseWriter, r *http.Request) {
+ project := config.GetProjectByID(chi.URLParam(r, "id"))
+ if project == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ path := chi.URLParam(r, "*")
+ body, err := h.gitService.ReadFile(r.Context(), *project, path)
+ if errors.Is(err, external.ErrNotFound) {
+ http.NotFound(w, r)
+ return
}
+ if err != nil {
+ http.Error(w, "Failed to read file", http.StatusInternalServerError)
+ return
+ }
+
+ contentType := mime.TypeByExtension(filepath.Ext(path))
+ if contentType == "" || strings.HasPrefix(contentType, "text/") {
+ contentType = http.DetectContentType(body)
+ if strings.HasPrefix(contentType, "text/") {
+ contentType = "text/plain; charset=utf-8"
+ }
+ }
+
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox")
+ w.Header().Set("Cache-Control", "public, max-age=300")
+ _, _ = w.Write(body)
}
diff --git a/cmd/server/main.go b/cmd/server/main.go
@@ -9,6 +9,7 @@ import (
"time"
"git.prime8.dev/moncheeta/prime8dev/cmd/server/handlers"
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
sshserver "git.prime8.dev/moncheeta/prime8dev/internal/ssh"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -49,6 +50,7 @@ func run() error {
r.Get("/", h.HomeHandler)
r.Get("/projects", h.ProjectsPageHandler)
r.Get("/projects/{id}", h.ProjectsPageHandler)
+ r.Get("/projects/{id}/raw/*", h.ProjectRawHandler)
r.Get("/experience", h.ExperienceListHandler)
r.Get("/experience/{id}", h.ExperiencePageHandler)
r.Get("/blog", h.BlogListHandler)
@@ -57,7 +59,9 @@ func run() error {
r.Get("/api/projects/{id}", h.ProjectDetailHandler)
r.Get("/api/blog/{id}", h.BlogDetailHandler)
- r.Get("/api/wakatime", h.WakaTimeHandler)
+ if config.WakaTimeEnabled {
+ r.Get("/api/wakatime", h.WakaTimeHandler)
+ }
port := os.Getenv("WEB_PORT")
if port == "" {
diff --git a/env.example b/env.example
@@ -4,5 +4,5 @@ WEB_PORT=3000
TERM=xterm-256color
COLORTERM=truecolor
GIT_REPOS_DIR=/srv/git/repos
-GIT_WEB_BASE=https://git.prime8.dev
+GIT_CLONE_BASE=https://prime8.dev
WAKATIME_API_KEY=your_wakatime_api_key_here
diff --git a/internal/config/projects.go b/internal/config/projects.go
@@ -5,7 +5,7 @@ import (
"strings"
)
-const defaultGitWebBase = "https://git.prime8.dev"
+const defaultGitCloneBase = "https://prime8.dev"
type Project struct {
ID string
@@ -18,18 +18,30 @@ type Project struct {
HTML string
}
-// GitWebBase is the base URL of the stagit pages (env GIT_WEB_BASE).
-func GitWebBase() string {
- base := strings.TrimSpace(os.Getenv("GIT_WEB_BASE"))
+// GitCloneBase is the base URL repos are cloned from over HTTP (env
+// GIT_CLONE_BASE): <base>/<repo>.git.
+func GitCloneBase() string {
+ base := strings.TrimSpace(os.Getenv("GIT_CLONE_BASE"))
if base == "" {
- base = defaultGitWebBase
+ base = defaultGitCloneBase
}
return strings.TrimSuffix(base, "/")
}
-// GitProjectURL is the public stagit page for a repo: <base>/<repo>/.
-func GitProjectURL(repo string) string {
- return GitWebBase() + "/" + repo + "/"
+// GitCloneURL is the public clone URL for a repo: <base>/<repo>.git.
+func GitCloneURL(repo string) string {
+ return GitCloneBase() + "/" + repo + ".git"
+}
+
+// GitSSHCloneURL is the public SSH clone URL for a repo, served read-only by
+// the site's SSH server: git@<domain>:<repo>.git.
+func GitSSHCloneURL(repo string) string {
+ return "git@" + strings.TrimPrefix(SiteDomain, "www.") + ":" + repo + ".git"
+}
+
+// ProjectRawURL is where the site serves a file from a project's HEAD.
+func ProjectRawURL(id, path string) string {
+ return SiteURL + "/projects/" + id + "/raw/" + strings.TrimLeft(path, "/")
}
func GetProjects() []Project {
@@ -39,7 +51,7 @@ func GetProjects() []Project {
Name: "janitor",
Subtitle: "audio classification with ast",
Tech: []string{"rust", "cuda"},
- URL: GitProjectURL("janitor"),
+ URL: GitCloneURL("janitor"),
Repo: "janitor",
},
{
@@ -47,7 +59,7 @@ func GetProjects() []Project {
Name: "summarizer",
Subtitle: "audio transcription microservices",
Tech: []string{"python", "docker", "kafka"},
- URL: GitProjectURL("summarizer"),
+ URL: GitCloneURL("summarizer"),
Repo: "summarizer",
},
{
@@ -55,7 +67,7 @@ func GetProjects() []Project {
Name: "finger",
Subtitle: "mobile auth app",
Tech: []string{"typescript", "react native", "supabase"},
- URL: GitProjectURL("finger"),
+ URL: GitCloneURL("finger"),
Repo: "finger",
},
{
@@ -63,7 +75,7 @@ func GetProjects() []Project {
Name: "computer-science-club",
Subtitle: "cs club website",
Tech: []string{"python", "rest"},
- URL: GitProjectURL("computer-science-club"),
+ URL: GitCloneURL("computer-science-club"),
Repo: "computer-science-club",
},
}
diff --git a/internal/config/site.go b/internal/config/site.go
@@ -4,3 +4,6 @@ const (
SiteDomain = "www.prime8.dev"
SiteURL = "https://" + SiteDomain
)
+
+// WakaTimeEnabled toggles the WakaTime stats on the web and SSH frontends.
+const WakaTimeEnabled = false
diff --git a/internal/external/git.go b/internal/external/git.go
@@ -27,7 +27,6 @@ var readmeNames = []string{"README.md", "readme.md", "README", "README.markdown"
// Git reads project metadata from local bare repositories.
type Git struct {
reposDir string
- webBase string
}
func NewGit() *Git {
@@ -36,10 +35,7 @@ func NewGit() *Git {
reposDir = defaultGitReposDir
}
- return &Git{
- reposDir: reposDir,
- webBase: config.GitWebBase(),
- }
+ return &Git{reposDir: reposDir}
}
type GitMetadata struct {
@@ -65,24 +61,68 @@ func (s *Git) RefreshMetadata(ctx context.Context, project config.Project) error
return err
}
+// ErrNotFound is returned by ReadFile when the path is not a file at HEAD.
+var ErrNotFound = errors.New("not found")
+
func (s *Git) repoDir(repo string) string {
return filepath.Join(s.reposDir, repo+".git")
}
-func (s *Git) readMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
+// projectRepoDir validates a project's repo name and returns its bare repo
+// directory.
+func (s *Git) projectRepoDir(project config.Project) (string, error) {
repo := strings.TrimSpace(project.Repo)
if repo == "" {
- return nil, fmt.Errorf("project %q has no repo configured", project.ID)
+ return "", fmt.Errorf("project %q has no repo configured", project.ID)
}
if strings.Contains(repo, "/") || strings.Contains(repo, "..") {
- return nil, fmt.Errorf("invalid repo name %q", repo)
+ return "", fmt.Errorf("invalid repo name %q", repo)
}
dir := s.repoDir(repo)
if info, err := os.Stat(dir); err != nil {
- return nil, fmt.Errorf("repo %s: %w", repo, err)
+ return "", fmt.Errorf("repo %s: %w", repo, err)
} else if !info.IsDir() {
- return nil, fmt.Errorf("repo %s: %s is not a directory", repo, dir)
+ return "", fmt.Errorf("repo %s: %s is not a directory", repo, dir)
+ }
+
+ return dir, nil
+}
+
+// ReadFile returns the contents of a file at HEAD of a project's repo.
+func (s *Git) ReadFile(ctx context.Context, project config.Project, path string) ([]byte, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ path = strings.Trim(path, "/")
+ if path == "" || strings.HasPrefix(path, ".git/") {
+ return nil, ErrNotFound
+ }
+ for _, part := range strings.Split(path, "/") {
+ if part == "" || part == "." || part == ".." {
+ return nil, ErrNotFound
+ }
+ }
+
+ dir, err := s.projectRepoDir(project)
+ if err != nil {
+ return nil, err
+ }
+
+ kind, err := s.git(ctx, dir, "cat-file", "-t", "HEAD:"+path)
+ if err != nil || strings.TrimSpace(string(kind)) != "blob" {
+ return nil, ErrNotFound
+ }
+
+ return s.git(ctx, dir, "cat-file", "blob", "HEAD:"+path)
+}
+
+func (s *Git) readMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
+ repo := strings.TrimSpace(project.Repo)
+ dir, err := s.projectRepoDir(project)
+ if err != nil {
+ return nil, err
}
modified, err := s.commitTime(ctx, dir, "log", "-1", "--format=%ct", "HEAD")
@@ -100,7 +140,7 @@ func (s *Git) readMetadata(ctx context.Context, project config.Project) (*GitMet
if err != nil {
continue
}
- readme = fixRelativePaths(s.webBase, repo, string(out))
+ readme = fixRelativePaths(project.ID, string(out))
break
}
@@ -161,19 +201,18 @@ var (
const imagePlaceholder = "\x00img%d\x00"
-// fixRelativePaths rewrites repo-relative links and images in a README to the
-// stagit web layout: links to <base>/<repo>/file/<path>.html and images to
-// <base>/<repo>/raw/<path>. Absolute URLs and #anchors are left unchanged.
-func fixRelativePaths(webBase, repo, markdown string) string {
- repoBase := strings.TrimSuffix(webBase, "/") + "/" + repo
-
+// fixRelativePaths rewrites repo-relative links and images in a README to
+// files served from HEAD by the site (config.ProjectRawURL). Links to
+// directories point at the project page. Absolute URLs and #anchors are left
+// unchanged.
+func fixRelativePaths(projectID, markdown string) string {
// Images first, swapped for placeholders so that links wrapping an image
// ([](target)) are still matched as links afterwards.
images := []string{}
markdown = imageRegex.ReplaceAllStringFunc(markdown, func(match string) string {
parts := imageRegex.FindStringSubmatch(match)
if path, ok := repoRelativePath(parts[2]); ok {
- match = fmt.Sprintf("", parts[1], repoBase, path, parts[3])
+ match = fmt.Sprintf("", parts[1], config.ProjectRawURL(projectID, path), parts[3])
}
images = append(images, match)
return fmt.Sprintf(imagePlaceholder, len(images)-1)
@@ -181,20 +220,14 @@ func fixRelativePaths(webBase, repo, markdown string) string {
markdown = linkRegex.ReplaceAllStringFunc(markdown, func(match string) string {
parts := linkRegex.FindStringSubmatch(match)
- target := parts[2]
- path, ok := repoRelativePath(target)
+ path, ok := repoRelativePath(parts[2])
if !ok {
return match
}
- var rewritten string
+ rewritten := config.ProjectRawURL(projectID, path)
if strings.HasSuffix(path, "/") {
- rewritten = repoBase + "/files.html"
- } else {
- rewritten = fmt.Sprintf("%s/file/%s.html", repoBase, path)
- if _, fragment, found := strings.Cut(target, "#"); found && fragment != "" {
- rewritten += "#" + fragment
- }
+ rewritten = config.SiteURL + "/projects/" + projectID
}
return fmt.Sprintf("[%s](%s%s)", parts[1], rewritten, parts[3])
diff --git a/internal/external/git_test.go b/internal/external/git_test.go
@@ -2,6 +2,7 @@ package external
import (
"context"
+ "errors"
"os"
"os/exec"
"path/filepath"
@@ -12,29 +13,29 @@ import (
)
func TestFixRelativePaths(t *testing.T) {
- const base = "https://git.prime8.dev"
tests := []struct {
name, in, want string
}{
- {"relative link", "[docs](docs/setup.md)", "[docs](https://git.prime8.dev/janitor/file/docs/setup.md.html)"},
- {"dot slash link", "[lic](./LICENSE)", "[lic](https://git.prime8.dev/janitor/file/LICENSE.html)"},
- {"link with fragment", "[x](./a.md#usage)", "[x](https://git.prime8.dev/janitor/file/a.md.html#usage)"},
- {"relative image", "", ""},
- {"image with title", ``, ``},
+ {"relative link", "[docs](docs/setup.md)", "[docs](https://www.prime8.dev/projects/janitor/raw/docs/setup.md)"},
+ {"dot slash link", "[lic](./LICENSE)", "[lic](https://www.prime8.dev/projects/janitor/raw/LICENSE)"},
+ {"link with fragment", "[x](./a.md#usage)", "[x](https://www.prime8.dev/projects/janitor/raw/a.md)"},
+ {"relative image", "", ""},
+ {"image with title", ``, ``},
{"absolute link", "[site](https://prime8.dev)", "[site](https://prime8.dev)"},
{"http image", "", ""},
{"anchor", "[top](#top)", "[top](#top)"},
+ {"directory link", "[src](./src/)", "[src](https://www.prime8.dev/projects/janitor)"},
{"mailto", "[mail](mailto:damian@prime8.dev)", "[mail](mailto:damian@prime8.dev)"},
{
"linked image",
"[](./CONTRIBUTING.md)",
- "[](https://git.prime8.dev/janitor/file/CONTRIBUTING.md.html)",
+ "[](https://www.prime8.dev/projects/janitor/raw/CONTRIBUTING.md)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- if got := fixRelativePaths(base, "janitor", tt.in); got != tt.want {
+ if got := fixRelativePaths("janitor", tt.in); got != tt.want {
t.Errorf("fixRelativePaths(%q)\n got %q\n want %q", tt.in, got, tt.want)
}
})
@@ -85,19 +86,28 @@ func TestGitFetchMetadata(t *testing.T) {
run(nil, reposDir, "clone", "-q", "--bare", work, bare)
t.Setenv("GIT_REPOS_DIR", reposDir)
- t.Setenv("GIT_WEB_BASE", "https://git.example.com/")
svc := NewGit()
md, err := svc.FetchMetadata(context.Background(), config.Project{ID: "demo", Repo: "demo"})
if err != nil {
t.Fatalf("FetchMetadata: %v", err)
}
- if want := "# demo\n\n\n"; md.Readme != want {
+ if want := "# demo\n\n\n"; md.Readme != want {
t.Errorf("Readme = %q, want %q", md.Readme, want)
}
if !md.DateCreated.Equal(first) {
t.Errorf("DateCreated = %v, want %v", md.DateCreated, first)
}
+ project := config.Project{ID: "demo", Repo: "demo"}
+ if got, err := svc.ReadFile(context.Background(), project, "main.go"); err != nil || string(got) != "package main\n" {
+ t.Errorf("ReadFile(main.go) = %q, %v", got, err)
+ }
+ for _, bad := range []string{"", "missing.txt", "../demo.git/config", ".git/config", "a//b"} {
+ if _, err := svc.ReadFile(context.Background(), project, bad); !errors.Is(err, ErrNotFound) {
+ t.Errorf("ReadFile(%q) err = %v, want ErrNotFound", bad, err)
+ }
+ }
+
if !md.DateModified.Equal(last) {
t.Errorf("DateModified = %v, want %v", md.DateModified, last)
}
diff --git a/internal/ssh/git.go b/internal/ssh/git.go
@@ -0,0 +1,82 @@
+package ssh
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ charmssh "github.com/charmbracelet/ssh"
+)
+
+const defaultGitReposDir = "/srv/git/repos"
+
+var repoNameRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+
+// gitReposDir is where the bare repositories live (env GIT_REPOS_DIR).
+func gitReposDir() string {
+ if dir := strings.TrimSpace(os.Getenv("GIT_REPOS_DIR")); dir != "" {
+ return dir
+ }
+ return defaultGitReposDir
+}
+
+// publicRepoDir maps the path a git client asks for ("janitor.git",
+// "/janitor", "~/janitor.git") to a public bare repo directory. Only repos
+// marked with git-daemon-export-ok are served.
+func publicRepoDir(requested string) (string, bool) {
+ name := strings.TrimPrefix(strings.TrimSpace(requested), "~")
+ name = strings.Trim(name, "/")
+ name = strings.TrimSuffix(name, ".git")
+ if !repoNameRegex.MatchString(name) || strings.Contains(name, "..") {
+ return "", false
+ }
+
+ dir := filepath.Join(gitReposDir(), name+".git")
+ if _, err := os.Stat(filepath.Join(dir, "git-daemon-export-ok")); err != nil {
+ return "", false
+ }
+ return dir, true
+}
+
+// isGitUploadPack reports whether the session is a git fetch or clone.
+func isGitUploadPack(sess charmssh.Session) bool {
+ cmd := sess.Command()
+ return len(cmd) == 2 && cmd[0] == "git-upload-pack"
+}
+
+// serveGitUploadPack runs git-upload-pack for a read-only clone or fetch and
+// returns the exit code for the session.
+func serveGitUploadPack(sess charmssh.Session) int {
+ dir, ok := publicRepoDir(sess.Command()[1])
+ if !ok {
+ _, _ = sess.Stderr().Write([]byte("fatal: repository not found\n"))
+ return 128
+ }
+
+ cmd := exec.CommandContext(sess.Context(), "git-upload-pack", "--strict", "--timeout=60", dir)
+ cmd.Env = append(os.Environ(), "GIT_PROTOCOL="+sessionEnv(sess, "GIT_PROTOCOL"))
+ cmd.Stdin = sess
+ cmd.Stdout = sess
+ cmd.Stderr = sess.Stderr()
+
+ if err := cmd.Run(); err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ return exitErr.ExitCode()
+ }
+ return 128
+ }
+ return 0
+}
+
+func sessionEnv(sess charmssh.Session, key string) string {
+ for _, kv := range sess.Environ() {
+ if k, v, ok := strings.Cut(kv, "="); ok && k == key {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/internal/ssh/git_test.go b/internal/ssh/git_test.go
@@ -0,0 +1,32 @@
+package ssh
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPublicRepoDir(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("GIT_REPOS_DIR", dir)
+ for _, repo := range []string{"janitor", "mos"} {
+ if err := os.MkdirAll(filepath.Join(dir, repo+".git"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(dir, "janitor.git", "git-daemon-export-ok"), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ want := filepath.Join(dir, "janitor.git")
+ for _, in := range []string{"janitor.git", "janitor", "/janitor.git", "~/janitor.git"} {
+ if got, ok := publicRepoDir(in); !ok || got != want {
+ t.Errorf("publicRepoDir(%q) = %q, %v; want %q", in, got, ok, want)
+ }
+ }
+ for _, in := range []string{"mos.git", "missing.git", "../janitor.git", "a/janitor.git", "", ".git"} {
+ if got, ok := publicRepoDir(in); ok {
+ t.Errorf("publicRepoDir(%q) = %q, want rejected", in, got)
+ }
+ }
+}
diff --git a/internal/ssh/route.go b/internal/ssh/route.go
@@ -14,6 +14,19 @@ func routeMiddleware(logger *slog.Logger) wish.Middleware {
ip := remoteIP(sess.RemoteAddr())
user := sess.User()
+ if isGitUploadPack(sess) {
+ code := serveGitUploadPack(sess)
+ logger.Info("route decision",
+ "user", user,
+ "ip", ip,
+ "result", "git-upload-pack",
+ "command", sess.RawCommand(),
+ "exit", code,
+ )
+ _ = sess.Exit(code)
+ return
+ }
+
if len(sess.Command()) > 0 {
_, _ = io.WriteString(sess.Stderr(), "remote command execution is disabled on the public interface\n")
logger.Info("route decision",
diff --git a/internal/tui/content.go b/internal/tui/content.go
@@ -32,9 +32,9 @@ func (m portfolioModel) aboutDetail() string {
"",
renderPrompt(m.about.Handle, "ls languages/"),
renderTagList(m.about.Languages),
- "",
- renderPrompt(m.about.Handle, "wakatime --week"),
- m.aboutWeeklyStats(),
+ }
+ if appconfig.WakaTimeEnabled {
+ parts = append(parts, "", renderPrompt(m.about.Handle, "wakatime --week"), m.aboutWeeklyStats())
}
return strings.Join(parts, "\n")
diff --git a/internal/tui/view.go b/internal/tui/view.go
@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
"github.com/charmbracelet/lipgloss"
)
@@ -229,7 +230,7 @@ func (m portfolioModel) renderFooter(width int) string {
right := ""
if m.wakaHours != "" {
right = footerWakaStyle.Render(m.wakaHours)
- } else if !m.wakaLoaded {
+ } else if config.WakaTimeEnabled && !m.wakaLoaded {
right = footerDimStyle.Render("loading...")
}
diff --git a/internal/tui/waka_footer.go b/internal/tui/waka_footer.go
@@ -1,6 +1,7 @@
package tui
import (
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
"git.prime8.dev/moncheeta/prime8dev/internal/external"
tea "github.com/charmbracelet/bubbletea"
)
@@ -20,7 +21,7 @@ func initWakaFooterCmd() tea.Cmd {
}
func (m *portfolioModel) maybeLoadWakaFooterCmd() tea.Cmd {
- if m.wakaLoaded || m.wakaLoading || m.wakaTime == nil {
+ if !config.WakaTimeEnabled || m.wakaLoaded || m.wakaLoading || m.wakaTime == nil {
return nil
}
diff --git a/internal/web/renderer.go b/internal/web/renderer.go
@@ -30,7 +30,6 @@ type Data struct {
Projects []config.Project
SelectedProjectID string
Project *config.Project
- ProjectLinkLabel string
ProjectReadme template.HTML
Blogs []content.Blog
SelectedBlogID string
@@ -69,6 +68,9 @@ func New() (*Renderer, error) {
"durationHasYear": utils.DurationHasYear,
"timelineYear": utils.TimelineYear,
"toHTML": toHTML,
+ "gitCloneURL": config.GitCloneURL,
+ "gitSSHCloneURL": config.GitSSHCloneURL,
+ "wakaTimeEnabled": func() bool { return config.WakaTimeEnabled },
"inlineCSS": func(name string) template.CSS {
if css, ok := inlineStyles[name]; ok {
return css
diff --git a/internal/web/scripts/modules/layout.js b/internal/web/scripts/modules/layout.js
@@ -15,12 +15,15 @@ let statusTimer = 0;
let unsubscribeWakaFooter = null;
export async function initLayout() {
- const initialWakaDataPromise = getWakaTimeData();
+ const wakaEnabled = Boolean(document.getElementById("wakatime-link"));
+ const initialWakaDataPromise = wakaEnabled ? getWakaTimeData() : null;
await playBootAnimation();
startStatusClock();
+ if (!wakaEnabled) return;
+
await loadWakaFooter(await initialWakaDataPromise);
if (unsubscribeWakaFooter) {
unsubscribeWakaFooter();
diff --git a/internal/web/scripts/modules/projects.js b/internal/web/scripts/modules/projects.js
@@ -72,11 +72,8 @@ export function initProjects() {
if (loadDetail) loadDetailForItem(items[index]);
}
- function openExternalForIndex(index) {
- if (index < 0 || index >= items.length) return;
- const url = items[index]?.dataset.url || "";
- if (!url) return;
- window.open(url, "_blank", "noopener,noreferrer");
+ function copyCloneCommand() {
+ section.querySelector("[data-clone-copy]")?.click();
}
items.forEach((item, index) => {
@@ -118,7 +115,7 @@ export function initProjects() {
break;
case "Enter":
e.preventDefault();
- openExternalForIndex(selectedIndex);
+ copyCloneCommand();
break;
default:
break;
@@ -127,6 +124,8 @@ export function initProjects() {
document.addEventListener("keydown", projectsHandlers.keydown);
+ initCloneCopy(section);
+
const initialItem = section.querySelector(".list-item.selected") || items[0];
setSelected(initialItem);
@@ -136,3 +135,66 @@ export function initProjects() {
setTimeout(() => loadDetailForItem(initialItem), 0);
}
}
+
+const CLONE_PROTO_KEY = "clone-proto";
+
+function initCloneCopy(section) {
+ const bar = section.querySelector("[data-clone]");
+ if (!bar || bar.dataset.bound === "true") return;
+ bar.dataset.bound = "true";
+
+ const tabs = Array.from(bar.querySelectorAll(".clone-tab"));
+ const urlEl = bar.querySelector("[data-clone-url]");
+ const copyButton = bar.querySelector("[data-clone-copy]");
+ if (!tabs.length || !urlEl || !copyButton) return;
+
+ const select = (proto, persist) => {
+ const tab = tabs.find((t) => t.dataset.proto === proto) || tabs[0];
+ tabs.forEach((t) => {
+ const on = t === tab;
+ t.classList.toggle("active", on);
+ t.setAttribute("aria-checked", on ? "true" : "false");
+ });
+ urlEl.textContent = tab.dataset.addr || "";
+ if (persist) {
+ try {
+ window.localStorage.setItem(CLONE_PROTO_KEY, tab.dataset.proto || "");
+ } catch {
+ // storage unavailable
+ }
+ }
+ };
+
+ let saved = "";
+ try {
+ saved = window.localStorage.getItem(CLONE_PROTO_KEY) || "";
+ } catch {
+ // storage unavailable
+ }
+ select(saved || "https", false);
+
+ tabs.forEach((t) => t.addEventListener("click", () => select(t.dataset.proto, true)));
+
+ let resetTimer = 0;
+ copyButton.addEventListener("click", async () => {
+ const url = (urlEl.textContent || "").trim();
+ if (!url) return;
+
+ let copied = false;
+ try {
+ await navigator.clipboard.writeText(url);
+ copied = true;
+ } catch {
+ copied = false;
+ }
+
+ copyButton.textContent = copied ? "copied" : "failed";
+ copyButton.classList.toggle("copied", copied);
+ window.clearTimeout(resetTimer);
+ resetTimer = window.setTimeout(() => {
+ if (!copyButton.isConnected) return;
+ copyButton.textContent = "copy";
+ copyButton.classList.remove("copied");
+ }, 1200);
+ });
+}
diff --git a/internal/web/styles/project_detail.css b/internal/web/styles/project_detail.css
@@ -1,9 +1,9 @@
.detail-header {
display: flex;
- justify-content: space-between;
- align-items: baseline;
- gap: 12px;
- margin-bottom: 12px;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 14px;
+ margin-bottom: 16px;
}
.detail-path {
@@ -21,12 +21,103 @@
display: flex;
flex-wrap: wrap;
gap: 6px;
- margin-bottom: 16px;
}
-.external-link {
- color: var(--light-blue);
+.clone-bar {
+ display: flex;
+ align-items: stretch;
+ border: 1px solid var(--border);
+ background: var(--bg-alt);
font-size: var(--fs-12);
+ margin-left: auto;
+ min-width: 0;
+ max-width: 100%;
+ transition: border-color 0.15s;
+}
+
+.clone-bar:hover,
+.clone-bar:focus-within {
+ border-color: var(--accent-dim);
+}
+
+.clone-tabs {
+ display: flex;
+ flex-shrink: 0;
+ border-right: 1px solid var(--border);
+}
+
+.clone-tab {
+ border: none;
+ background: transparent;
+ color: var(--fg-dim);
+ padding: 3px 9px;
+ font-family: inherit;
+ font-size: inherit;
+ cursor: pointer;
+ transition: color 0.15s, background 0.15s;
+}
+
+.clone-tab:hover {
+ color: var(--fg);
+}
+
+.clone-tab.active {
+ background: var(--accent);
+ color: var(--bg);
+}
+
+.clone-tab:focus-visible,
+.clone-copy:focus-visible {
+ outline: 1px solid var(--accent);
+ outline-offset: -1px;
+}
+
+.clone-cmd {
+ flex: 1;
+ min-width: 0;
+ padding: 3px 9px;
+ color: var(--fg);
+ white-space: nowrap;
+ overflow-x: auto;
+ scrollbar-width: none;
+ background: transparent;
+ font-family: inherit;
+}
+
+.clone-cmd::-webkit-scrollbar {
+ display: none;
+}
+
+.clone-dollar {
+ color: var(--green);
+}
+
+.clone-url {
+ color: var(--light-blue);
+ user-select: all;
+}
+
+.clone-copy {
+ flex-shrink: 0;
+ min-width: 58px;
+ border: none;
+ border-left: 1px solid var(--border);
+ background: transparent;
+ color: var(--fg-dim);
+ padding: 3px 9px;
+ font-family: inherit;
+ font-size: inherit;
+ cursor: pointer;
+ transition: color 0.15s, background 0.15s;
+}
+
+.clone-copy:hover {
+ color: var(--accent);
+ background: var(--bg);
+}
+
+.clone-copy.copied {
+ color: var(--green);
}
.readme-content {
diff --git a/internal/web/templates/partials/about.gohtml b/internal/web/templates/partials/about.gohtml
@@ -62,12 +62,14 @@
</div>
</div>
+ {{if wakaTimeEnabled}}
<div class="section" id="activity-section">
<div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">wakatime --week</span></div>
<div class="weekly-stats" id="activity-stats">
<div class="loading-text">loading stats...</div>
</div>
</div>
+ {{end}}
</div>
</div>
</section>
diff --git a/internal/web/templates/partials/footer.gohtml b/internal/web/templates/partials/footer.gohtml
@@ -2,7 +2,9 @@
<footer id="statusbar">
<span class="time" id="status-time"></span>
<span class="spacer"></span>
+ {{if wakaTimeEnabled}}
<a class="wakatime" id="wakatime-link" href="https://wakatime.com/@{{.About.Handle}}" target="_blank" rel="noopener noreferrer">Loading...</a>
+ {{end}}
</footer>
<style>{{inlineCSS "footer.css"}}</style>
diff --git a/internal/web/templates/partials/project_detail.gohtml b/internal/web/templates/partials/project_detail.gohtml
@@ -3,10 +3,19 @@
<div class="detail-content" data-id="{{.Project.ID}}">
<div class="detail-header">
<h2 class="detail-path"><span class="detail-path-text">~/projects/{{.Project.Name}}</span></h2>
- <a class="external-link" href="{{.Project.URL}}" target="_blank" rel="noopener noreferrer">{{if .ProjectLinkLabel}}{{.ProjectLinkLabel}}{{else}}Outside{{end}} -></a>
- </div>
- <div class="tech-stack">
- {{range .Project.Tech}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}
+ <div class="tech-stack">
+ {{range .Project.Tech}}<span class="tag {{tagClass .}}">{{.}}</span>{{end}}
+ </div>
+ {{if .Project.Repo}}
+ <div class="clone-bar" data-clone>
+ <div class="clone-tabs" role="radiogroup" aria-label="Clone protocol">
+ <button type="button" class="clone-tab active" role="radio" aria-checked="true" data-proto="https" data-addr="{{gitCloneURL .Project.Repo}}">https</button>
+ <button type="button" class="clone-tab" role="radio" aria-checked="false" data-proto="ssh" data-addr="{{gitSSHCloneURL .Project.Repo}}">ssh</button>
+ </div>
+ <code class="clone-cmd"><span class="clone-dollar">$</span> git clone <span class="clone-url" data-clone-url>{{gitCloneURL .Project.Repo}}</span></code>
+ <button type="button" class="clone-copy" data-clone-copy aria-label="Copy clone URL">copy</button>
+ </div>
+ {{end}}
</div>
<div class="readme-content" data-readme="{{.Project.ID}}">
{{if .ProjectReadme}}{{.ProjectReadme}}{{else}}<p class="color-dim">README not available</p>{{end}}
diff --git a/internal/web/templates/partials/projects.gohtml b/internal/web/templates/partials/projects.gohtml
@@ -2,7 +2,7 @@
<section id="projects" class="page">
<aside class="list-view" aria-label="Project list" preload="preload:init" preload-images="true">
{{range $i, $p := .Projects}}
- <div class="list-item{{if $.SelectedProjectID}}{{if eq $p.ID $.SelectedProjectID}} selected{{end}}{{else}}{{if eq $i 0}} selected{{end}}{{end}}" data-index="{{$i}}" data-id="{{$p.ID}}" data-url="{{$p.URL}}" hx-get="/api/projects/{{$p.ID}}" hx-target="#project-detail" hx-swap="innerHTML">
+ <div class="list-item{{if $.SelectedProjectID}}{{if eq $p.ID $.SelectedProjectID}} selected{{end}}{{else}}{{if eq $i 0}} selected{{end}}{{end}}" data-index="{{$i}}" data-id="{{$p.ID}}" hx-get="/api/projects/{{$p.ID}}" hx-target="#project-detail" hx-swap="innerHTML">
<div class="list-header">
<span class="list-title">{{$p.Name}}</span>
<span class="list-meta">{{techMeta $p.Tech}}</span>