prime8dev

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

vfs.go (6713B)


      1 // Package vfs builds the read-only, in-memory filesystem behind the shell on
      2 // the about page (SSH TUI and web). Nothing here ever touches the host
      3 // filesystem: every node is generated from config/content values.
      4 package vfs
      5 
      6 import (
      7 	"fmt"
      8 	"path"
      9 	"sort"
     10 	"strings"
     11 	"time"
     12 
     13 	appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config"
     14 	"git.prime8.dev/moncheeta/prime8dev/internal/utils"
     15 )
     16 
     17 const (
     18 	// FallbackUser is the shell user for anonymous sessions (and the web).
     19 	FallbackUser  = "visitor"
     20 	maxUser       = 32
     21 	homeDirPrefix = "/home/"
     22 )
     23 
     24 // SanitizeUser turns a session user into a safe single path segment
     25 // ([a-z0-9._-], at most 32 chars) so it can name the home directory and the
     26 // prompt. Anything that sanitizes to nothing (or to . / ..) becomes visitor.
     27 func SanitizeUser(user string) string {
     28 	var b strings.Builder
     29 	for _, r := range strings.ToLower(strings.TrimSpace(user)) {
     30 		if b.Len() >= maxUser {
     31 			break
     32 		}
     33 		switch {
     34 		case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
     35 			b.WriteRune(r)
     36 		}
     37 	}
     38 	s := strings.Trim(b.String(), ".")
     39 	if s == "" {
     40 		return FallbackUser
     41 	}
     42 	return s
     43 }
     44 
     45 // HomeDir is the user's home directory in the virtual fs.
     46 func HomeDir(user string) string {
     47 	return homeDirPrefix + user
     48 }
     49 
     50 // Node is a file or directory. Directories have Children; files have Content.
     51 type Node struct {
     52 	Name     string           `json:"name"`
     53 	Dir      bool             `json:"dir,omitempty"`
     54 	Content  string           `json:"content,omitempty"`
     55 	Children map[string]*Node `json:"children,omitempty"`
     56 }
     57 
     58 // NewDir returns an empty directory node.
     59 func NewDir(name string) *Node {
     60 	return &Node{Name: name, Dir: true, Children: map[string]*Node{}}
     61 }
     62 
     63 // AddDir returns the child directory name, creating it if needed.
     64 func (n *Node) AddDir(name string) *Node {
     65 	if c, ok := n.Children[name]; ok && c.Dir {
     66 		return c
     67 	}
     68 	c := NewDir(name)
     69 	n.Children[name] = c
     70 	return c
     71 }
     72 
     73 // AddFile adds (or replaces) a file.
     74 func (n *Node) AddFile(name, content string) {
     75 	n.Children[name] = &Node{Name: name, Content: content}
     76 }
     77 
     78 // SortedChildren lists a directory by name.
     79 func (n *Node) SortedChildren() []*Node {
     80 	out := make([]*Node, 0, len(n.Children))
     81 	for _, c := range n.Children {
     82 		out = append(out, c)
     83 	}
     84 	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
     85 	return out
     86 }
     87 
     88 // Lookup walks an absolute virtual path; nil when it does not exist.
     89 func (n *Node) Lookup(abs string) *Node {
     90 	cur := n
     91 	for _, part := range strings.Split(strings.Trim(abs, "/"), "/") {
     92 		if part == "" {
     93 			continue
     94 		}
     95 		if !cur.Dir {
     96 			return nil
     97 		}
     98 		c, ok := cur.Children[part]
     99 		if !ok {
    100 			return nil
    101 		}
    102 		cur = c
    103 	}
    104 	return cur
    105 }
    106 
    107 // Source is everything the filesystem is generated from.
    108 type Source struct {
    109 	User       string
    110 	About      appconfig.About
    111 	CurrentJob *appconfig.Experience
    112 	Experience []appconfig.Experience
    113 	Projects   []appconfig.Project
    114 	// Readmes overrides project READMEs by project id (falls back to
    115 	// Project.Markdown).
    116 	Readmes map[string]string
    117 	Now     time.Time
    118 }
    119 
    120 // Build lays out:
    121 //
    122 //	/home/<user>/{about,bio,contact}
    123 //	/home/<user>/{interests,hobbies,languages}/<item>
    124 //	/home/<user>/experience/<id>
    125 //	/home/<user>/projects/<id>/{README.md,info}
    126 func Build(src Source) *Node {
    127 	root := NewDir("/")
    128 	home := root.AddDir("home").AddDir(src.User)
    129 	root.AddDir("tmp")
    130 
    131 	a := src.About
    132 	home.AddFile("bio", a.Bio)
    133 	home.AddFile("about", aboutFile(src))
    134 	home.AddFile("contact", strings.Join([]string{
    135 		"email:    damian@prime8.dev",
    136 		"web:      " + appconfig.SiteURL,
    137 		"git:      " + appconfig.SiteURL + "/projects",
    138 		"linkedin: https://linkedin.com/in/damian-myrda",
    139 		"ssh:      ssh prime8.dev",
    140 	}, "\n"))
    141 
    142 	for dir, items := range map[string][]string{
    143 		"interests": a.Interests,
    144 		"hobbies":   a.Hobbies,
    145 		"languages": a.Languages,
    146 	} {
    147 		d := home.AddDir(dir)
    148 		for _, item := range items {
    149 			if name := Name(item); name != "" {
    150 				d.AddFile(name, item)
    151 			}
    152 		}
    153 	}
    154 
    155 	expDir := home.AddDir("experience")
    156 	for _, e := range src.Experience {
    157 		if name := Name(e.ID); name != "" {
    158 			expDir.AddFile(name, experienceFile(e))
    159 		}
    160 	}
    161 
    162 	projDir := home.AddDir("projects")
    163 	for _, p := range src.Projects {
    164 		name := Name(p.ID)
    165 		if name == "" {
    166 			continue
    167 		}
    168 		d := projDir.AddDir(name)
    169 		readme := strings.TrimSpace(src.Readmes[p.ID])
    170 		if readme == "" {
    171 			readme = strings.TrimSpace(p.Markdown)
    172 		}
    173 		if readme != "" {
    174 			d.AddFile("README.md", readme)
    175 		}
    176 		info := []string{"name:  " + p.Name, "about: " + p.Subtitle}
    177 		if len(p.Tech) > 0 {
    178 			info = append(info, "tech:  "+strings.Join(p.Tech, ", "))
    179 		}
    180 		if p.Repo != "" {
    181 			info = append(info,
    182 				"clone: git clone "+appconfig.GitCloneURL(p.Repo),
    183 				"       git clone "+appconfig.GitSSHCloneURL(p.Repo),
    184 			)
    185 		}
    186 		d.AddFile("info", strings.Join(info, "\n"))
    187 	}
    188 
    189 	return root
    190 }
    191 
    192 func aboutFile(src Source) string {
    193 	a := src.About
    194 	role := a.Profession
    195 	if src.CurrentJob != nil {
    196 		role = src.CurrentJob.Role + " @ " + src.CurrentJob.Company
    197 	}
    198 	now := src.Now
    199 	if now.IsZero() {
    200 		now = time.Now()
    201 	}
    202 	lines := []string{
    203 		"name:     " + a.Name,
    204 		"role:     " + role,
    205 		"location: " + a.Location,
    206 		"uptime:   " + utils.Uptime(appconfig.Birthday, now),
    207 	}
    208 	for _, kv := range a.Setup {
    209 		lines = append(lines, fmt.Sprintf("%-9s %s", kv.Key+":", kv.Value))
    210 	}
    211 	lines = append(lines, "langs:    "+strings.Join(a.Languages, ", "))
    212 	return strings.Join(lines, "\n")
    213 }
    214 
    215 func experienceFile(e appconfig.Experience) string {
    216 	lines := []string{
    217 		e.Role + " @ " + e.Company,
    218 		utils.FormatRange(e.StartDate, e.EndDate, e.Current) + " (" + utils.FormatDurationShort(e.StartDate, e.EndDate, e.Current) + ")",
    219 		"",
    220 	}
    221 	lines = append(lines, e.Description...)
    222 	if len(e.Tech) > 0 {
    223 		lines = append(lines, "", "tech: "+strings.Join(e.Tech, ", "))
    224 	}
    225 	if e.URL != "" {
    226 		lines = append(lines, "url:  "+e.URL)
    227 	}
    228 	return strings.Join(lines, "\n")
    229 }
    230 
    231 // Name turns a label into a single path component.
    232 func Name(s string) string {
    233 	s = strings.TrimSpace(strings.ToLower(s))
    234 	s = strings.NewReplacer("/", "-", " ", "-").Replace(s)
    235 	if s == "." || s == ".." {
    236 		return ""
    237 	}
    238 	return s
    239 }
    240 
    241 // Resolve turns p (absolute, ~-relative, or relative to cwd) into a clean
    242 // absolute virtual path.
    243 func Resolve(home, cwd, p string) string {
    244 	switch {
    245 	case p == "" || p == "~":
    246 		return home
    247 	case strings.HasPrefix(p, "~/"):
    248 		p = home + p[1:]
    249 	case !strings.HasPrefix(p, "/"):
    250 		p = cwd + "/" + p
    251 	}
    252 	return path.Clean(p)
    253 }
    254 
    255 // DisplayPath abbreviates the home directory to ~.
    256 func DisplayPath(home, abs string) string {
    257 	if abs == home {
    258 		return "~"
    259 	}
    260 	if strings.HasPrefix(abs, home+"/") {
    261 		return "~" + abs[len(home):]
    262 	}
    263 	return abs
    264 }