package gophermap import ( "bufio" "bytes" "context" "errors" "fmt" "io" "net/url" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" sr "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gopher" "tildegit.org/tjp/sliderule/internal" "tildegit.org/tjp/sliderule/internal/types" ) // ExtendedMapDocument is a gophermap doc with gophernicus's extensions // // These are documented at: gopher://gopher.gophernicus.org/0/docs/README.Gophermap type ExtendedMapDocument struct { Lines []gopher.MapItem Location *url.URL } // ParseExtended parses a gophermap document including gophernicus extensions. func ParseExtended(input io.Reader, location *url.URL) (*ExtendedMapDocument, error) { rdr := bufio.NewReader(input) doc := &ExtendedMapDocument{Location: location} outer: for num := 1; ; num += 1 { var item gopher.MapItem var spl []string line, err := rdr.ReadString('\n') isEOF := errors.Is(err, io.EOF) if err != nil && !isEOF { return nil, err } line = strings.TrimRight(line, "\r\n") if len(line) > 0 { switch line[0] { case '#': doc.Lines = append(doc.Lines, gopher.MapItem{ Type: CommentType, Display: strings.TrimPrefix(line[1:], " "), }) goto next case '!': doc.Lines = append(doc.Lines, gopher.MapItem{ Type: TitleType, Display: line[1:], }) goto next case '-': doc.Lines = append(doc.Lines, gopher.MapItem{ Type: HiddenType, Selector: line[1:], }) goto next case ':': doc.Lines = append(doc.Lines, gopher.MapItem{ Type: ExtensionType, Display: line[1:], }) goto next case '=': doc.Lines = append(doc.Lines, gopher.MapItem{ Type: InclusionType, Selector: line[1:], }) goto next } } switch line { case "~": doc.Lines = append(doc.Lines, gopher.MapItem{Type: UserListType}) goto next case "%": doc.Lines = append(doc.Lines, gopher.MapItem{Type: VHostListType}) goto next case ".": doc.Lines = append(doc.Lines, gopher.MapItem{Type: EndDocType}) break outer case "*": doc.Lines = append(doc.Lines, gopher.MapItem{Type: DirListType}) break outer } if !strings.Contains(line, "\t") { doc.Lines = append(doc.Lines, gopher.MapItem{ Type: gopher.InfoMessageType, Display: line, Selector: location.Path, Hostname: location.Hostname(), Port: location.Port(), }) goto next } item = gopher.MapItem{Type: types.Status(line[0])} spl = strings.Split(line[1:], "\t") item.Display = string(spl[0]) if len(spl) == 1 { item.Selector = location.Path } else { item.Selector = string(spl[1]) } if len(spl) < 3 { item.Hostname = location.Hostname() } else { item.Hostname = string(spl[2]) } if len(spl) < 4 { item.Port = location.Port() } else { item.Port = string(spl[3]) } if _, err = strconv.Atoi(item.Port); err != nil { return doc, InvalidLine(num) } doc.Lines = append(doc.Lines, item) next: if isEOF { break } } return doc, nil } // Extensions to gopher types from Gophernicus. const ( // CommentType is omitted from generated compatible gophermaps. CommentType types.Status = '#' // TitleType defines the title of a gophermap document. TitleType types.Status = '!' // HiddenType hides a link from the generated compatible gophermap. HiddenType types.Status = '-' // ExtensionType defines the gopher type to use for files in the current directory with a given extension. ExtensionType types.Status = ':' // UserListType generates a list of users with valid ~/public_gopher directories. UserListType types.Status = '~' // VHostListType generates a listing of virtual hosts. // // It is not supported in sliderule. VHostListType types.Status = '%' // InclusionType causes another gophermap to be included at this location. InclusionType types.Status = '=' // DirListType stops parsing the current file and ends the generated gophermap with a listing of the current directory. DirListType types.Status = '*' // EndDocType ends the current gophermap file. EndDocType types.Status = '.' ) type FileSystemSettings struct { ParseExtended bool Exec bool ListUsers bool DirMaps []string DirTag string } // Compatible builds a standards-compliant gophermap from the current extended menu. func (edoc *ExtendedMapDocument) Compatible(cwd string, settings FileSystemSettings) (gopher.MapDocument, string, error) { doc := gopher.MapDocument{} title := "" hidden := map[string]struct{}{} extensions := map[string]types.Status{} lineloop: for num, item := range edoc.Lines { switch item.Type { case CommentType: case TitleType: title = item.Display case HiddenType: hidden[item.Selector] = struct{}{} case ExtensionType: from, to, found := strings.Cut(item.Display, "=") if !found { return nil, "", InvalidLine(num + 1) } extensions[from] = types.Status(to[0]) case UserListType: if !settings.ListUsers { doc = append(doc, gopher.MapItem{ Type: gopher.InfoMessageType, Display: "~", Selector: edoc.Location.Path, Hostname: edoc.Location.Hostname(), Port: edoc.Location.Port(), }) } users, err := internal.ListUsersWithHomeSubdir("public_gopher", 4) if err != nil { return nil, "", err } sort.Strings(users) for _, user := range users { doc = append(doc, gopher.MapItem{ Type: gopher.MenuType, Display: "~" + user, Selector: "/~" + user, Hostname: edoc.Location.Hostname(), Port: edoc.Location.Port(), }) } case VHostListType: return nil, "", errors.New("Virtual host listings '%' are not supported") case InclusionType: subEdoc, err := openExtended(item.Selector, edoc.Location, settings) if err != nil { return nil, "", err } lines, _, err := subEdoc.Compatible(filepath.Dir(item.Selector), settings) if err != nil { return nil, "", err } doc = append(doc, lines...) case DirListType: dirlist, err := listDir(cwd, edoc.Location, settings, hidden, extensions) if err != nil { return nil, "", err } doc = append(doc, dirlist...) break lineloop case EndDocType: break lineloop default: doc = append(doc, item) } } return doc, title, nil } func openExtended(path string, location *url.URL, settings FileSystemSettings) (*ExtendedMapDocument, error) { file, err := os.Open(path) if err != nil { return nil, err } defer func() { _ = file.Close() }() if settings.Exec { info, err := file.Stat() if err != nil { return nil, err } m := info.Mode() if m.IsRegular() && m&5 == 5 { cmd := exec.Command(path) cmd.Env = []string{} cmd.Dir = filepath.Dir(path) outbuf := &bytes.Buffer{} cmd.Stdout = outbuf if err := cmd.Run(); err != nil { var exErr *exec.ExitError if errors.As(err, &exErr) { return nil, fmt.Errorf("Inclusion exec returned exit code %d", exErr.ExitCode()) } return nil, err } return ParseExtended(outbuf, location) } } return ParseExtended(file, location) } func ExtendMiddleware(fsroot, urlroot string, settings *FileSystemSettings) sr.Middleware { return sr.Middleware(func(handler sr.Handler) sr.Handler { return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { response := handler.Handle(ctx, request) if settings == nil || response == nil || !settings.ParseExtended || response.Status != gopher.MenuType { return response } defer func() { _ = response.Close() }() edoc, err := ParseExtended(response.Body, request.URL) if err != nil { return gopher.Error(err).Response() } fpath := strings.TrimPrefix(request.Path, urlroot) fpath = strings.Trim(fpath, "/") fpath = filepath.Join(fsroot, fpath) cwd := fpath if !strings.HasSuffix(request.Path, "/") { cwd = filepath.Dir(cwd) } doc, _, err := edoc.Compatible(cwd, *settings) if err != nil { return gopher.Error(err).Response() } return doc.Response() }) }) }