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 }