package main import ( "connectrpc.com/connect" "context" "crypto/sha256" "encoding/hex" "fmt" "log" "os" "path/filepath" homelabv1 "pwa-homelab-mon/gen/homelab/v1" "strings" ) // --- docs --- func docHash(b []byte) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } // docPath resolves a doc name safely: plain file name only, no separators, always inside docsDir. func (s *server) docPath(name string) (string, error) { if filepath.Base(name) != name || filepath.Ext(name) != ".md" { return "", fmt.Errorf("bad doc name %q", name) } p := filepath.Join(s.docsDir, name) if filepath.Dir(p) != s.docsDir { return "", fmt.Errorf("bad doc name %q", name) } return p, nil } func (s *server) ListDocs(context.Context, *connect.Request[homelabv1.ListDocsRequest]) (*connect.Response[homelabv1.ListDocsResponse], error) { resp := &homelabv1.ListDocsResponse{} entries, err := os.ReadDir(s.docsDir) if err != nil { return connect.NewResponse(resp), nil // no docs dir = no docs } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { continue } b, err := os.ReadFile(filepath.Join(s.docsDir, e.Name())) if err != nil { continue } resp.Docs = append(resp.Docs, &homelabv1.DocMeta{Name: e.Name(), Hash: docHash(b)}) } return connect.NewResponse(resp), nil } func (s *server) GetDoc(_ context.Context, req *connect.Request[homelabv1.GetDocRequest]) (*connect.Response[homelabv1.GetDocResponse], error) { p, err := s.docPath(req.Msg.GetName()) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } b, err := os.ReadFile(p) if err != nil { return nil, connect.NewError(connect.CodeNotFound, err) } return connect.NewResponse(&homelabv1.GetDocResponse{Content: string(b)}), nil } func (s *server) SaveDoc(_ context.Context, req *connect.Request[homelabv1.SaveDocRequest]) (*connect.Response[homelabv1.SaveDocResponse], error) { p, err := s.docPath(req.Msg.GetName()) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } content := []byte(req.Msg.GetContent()) // ponytail: client authority — last write wins, no base-hash check; add optimistic locking if concurrent edits appear if err := os.WriteFile(p, content, 0o644); err != nil { return nil, connect.NewError(connect.CodeInternal, err) } log.Printf("doc saved: %s (%d bytes)", filepath.Base(p), len(content)) return connect.NewResponse(&homelabv1.SaveDocResponse{Hash: docHash(content)}), nil }