prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

commit 0089ffef5f2efa4a09b440fb1d9067fba5531a8e
parent 8bea434e4e8b6458e06613e456464ea5a3a0b0fd
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:
MDockerfile | 2+-
MREADME.md | 13+++++++++----
Mcmd/server/handlers/projects.go | 51++++++++++++++++++++++++++++++++++++++-------------
Mcmd/server/main.go | 6+++++-
Menv.example | 2+-
Minternal/config/projects.go | 36++++++++++++++++++++++++------------
Minternal/config/site.go | 3+++
Minternal/external/git.go | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Minternal/external/git_test.go | 30++++++++++++++++++++----------
Ainternal/ssh/git.go | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/ssh/git_test.go | 32++++++++++++++++++++++++++++++++
Minternal/ssh/route.go | 13+++++++++++++
Minternal/tui/content.go | 6+++---
Minternal/tui/view.go | 3++-
Minternal/tui/waka_footer.go | 3++-
Minternal/web/renderer.go | 4+++-
Minternal/web/scripts/modules/layout.js | 5++++-
Minternal/web/scripts/modules/projects.js | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Minternal/web/styles/project_detail.css | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Minternal/web/templates/partials/about.gohtml | 2++
Minternal/web/templates/partials/footer.gohtml | 2++
Minternal/web/templates/partials/project_detail.gohtml | 17+++++++++++++----
Minternal/web/templates/partials/projects.gohtml | 2+-
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 // ([![badge](img.svg)](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("![%s](%s/raw/%s%s)", parts[1], repoBase, path, parts[3]) + match = fmt.Sprintf("![%s](%s%s)", 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", "![logo](./assets/logo.png)", "![logo](https://git.prime8.dev/janitor/raw/assets/logo.png)"}, - {"image with title", `![logo](logo.png "Logo")`, `![logo](https://git.prime8.dev/janitor/raw/logo.png "Logo")`}, + {"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", "![logo](./assets/logo.png)", "![logo](https://www.prime8.dev/projects/janitor/raw/assets/logo.png)"}, + {"image with title", `![logo](logo.png "Logo")`, `![logo](https://www.prime8.dev/projects/janitor/raw/logo.png "Logo")`}, {"absolute link", "[site](https://prime8.dev)", "[site](https://prime8.dev)"}, {"http image", "![b](http://example.com/b.svg)", "![b](http://example.com/b.svg)"}, {"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", "[![ci](./badge.svg)](./CONTRIBUTING.md)", - "[![ci](https://git.prime8.dev/janitor/raw/badge.svg)](https://git.prime8.dev/janitor/file/CONTRIBUTING.md.html)", + "[![ci](https://www.prime8.dev/projects/janitor/raw/badge.svg)](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![shot](https://git.example.com/demo/raw/shot.png)\n"; md.Readme != want { + if want := "# demo\n\n![shot](https://www.prime8.dev/projects/demo/raw/shot.png)\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>