diff --git a/assets/css/layout.css b/assets/css/layout.css index 8989e30e..fdafebab 100644 --- a/assets/css/layout.css +++ b/assets/css/layout.css @@ -90,7 +90,7 @@ } .reader { - font-size: 150%; + font-size: 133%; line-height: 1; white-space: pre-wrap; hyphens: manual; diff --git a/docs/todo.md b/docs/todo.md index ecbd4311..fd118319 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -12,6 +12,9 @@ - [ ] Complete `internal/archive/archive.go to support all archive types. need to supprt legacy zip via hwzip and arc. - [ ] Automatic cleanup of tmp/defacto2-server if drive space is low. - [ ] When extracting archives for MS-DOS, flag invalid filenames and suggest a repack. +- [ ] Render HTML in an iframe instead of readme? Example, http://localhost:1323/f/ad3075 +- [ ] Handle magazines title on artifact page, http://localhost:1323/f/a55ed, this is hard to read, "Issue 4\nThe Pirate Syndicate +\nThe Pirate World" +- [ ] If artifact is a text file displayed in readme, then delete image preview, these are often super long, large and not needed. - [ ] - http://www.platohistory.org/ - [ ] - https://portcommodore.com/dokuwiki/doku.php?id=larry:comp:bbs:about_cbbs @@ -23,6 +26,7 @@ Magic files to add: - Excel, http://localhost:1323/f/b02fcc - Multipart zip, http://localhost:1323/f/a9247de, http://localhost:1323/f/a619619 +- Convert ms-dos filenames to valid utf-8 names, see http://localhost:1323/f/b323bda ### Bug fixes diff --git a/handler/app/app.go b/handler/app/app.go index e357a49a..83ce0bba 100644 --- a/handler/app/app.go +++ b/handler/app/app.go @@ -824,6 +824,40 @@ func ReadmeSuggest(filename, group string, content ...string) string { return "" } +// Readmes returns a list of readme text files found in the file archive. +func Readmes(content ...string) []string { + finds := []string{} + skip := []string{"scene.org", "scene.org.txt"} + for _, name := range content { + if name == "" { + continue + } + s := strings.ToLower(name) + if slices.Contains(skip, s) { + continue + } + ext := filepath.Ext(s) + if slices.Contains(priority(), ext) { + finds = append(finds, name) + continue + } + if slices.Contains(candidate(), ext) { + finds = append(finds, name) + } + } + return finds +} + +// priority returns a list of readme text file extensions in priority order. +func priority() []string { + return []string{".nfo", ".txt", ".unp", ".doc"} +} + +// candidate returns a list of other, common text file extensions in priority order. +func candidate() []string { + return []string{".diz", ".asc", ".1st", ".dox", ".me", ".cap", ".ans", ".pcb"} +} + // RecordRels returns the groups associated with a release and joins them with a plus sign. func RecordRels(a, b any) string { av, bv, s := "", "", "" diff --git a/handler/app/dirs.go b/handler/app/dirs.go index 95016730..d6f6da6f 100644 --- a/handler/app/dirs.go +++ b/handler/app/dirs.go @@ -5,16 +5,12 @@ package app import ( "bufio" "bytes" - "context" - "errors" "fmt" "html/template" - "image" _ "image/gif" // gif format decoder _ "image/jpeg" // jpeg format decoder _ "image/png" // png format decoder "io" - "io/fs" "net/http" "os" "path/filepath" @@ -22,69 +18,23 @@ import ( "strconv" "strings" "time" - uni "unicode" "github.com/Defacto2/releaser" - "github.com/Defacto2/server/handler/sess" - "github.com/Defacto2/server/internal/archive" - "github.com/Defacto2/server/internal/command" "github.com/Defacto2/server/internal/helper" - "github.com/Defacto2/server/internal/magicnumber" - "github.com/Defacto2/server/internal/postgres" "github.com/Defacto2/server/internal/postgres/models" - "github.com/Defacto2/server/internal/render" - "github.com/Defacto2/server/internal/tags" "github.com/Defacto2/server/model" "github.com/dustin/go-humanize" - "github.com/h2non/filetype" "github.com/labstack/echo/v4" - "go.uber.org/zap" - "golang.org/x/exp/maps" - "golang.org/x/exp/slices" _ "golang.org/x/image/webp" // webp format decoder - "golang.org/x/text/encoding/charmap" - "golang.org/x/text/encoding/unicode" ) -// Dirs contains the directories used by the artifact pages. -type Dirs struct { - Download string // path to the artifact download directory - Preview string // path to the preview and screenshot directory - Thumbnail string // path to the file thumbnail directory - Extra string // path to the extra files directory - URI string // the URI of the file record -} - type extract int // extract target format for the file archive extractor const ( - imgs extract = iota // extract image - ansis // extract ansilove compatible text -) - -const ( - epoch = model.EpochYear // epoch is the default year for MS-DOS files without a timestamp + picture extract = iota // extract a picture or image + ansitext // extract ansilove compatible text ) -// errorWithID returns an error with the artifact ID appended to the error message. -// The key string is expected any will always be displayed in the error message. -// The id can be an integer or string value and should be the database numeric ID. -func errorWithID(err error, key string, id any) error { - if err == nil { - return nil - } - key = strings.TrimSpace(key) - const cause = "caused by artifact" - switch id.(type) { - case int, int64: - return fmt.Errorf("%w: %s %s (%d)", err, cause, key, id) - case string: - return fmt.Errorf("%w: %s %s (%s)", err, cause, key, id) - default: - return fmt.Errorf("%w: %s %s", err, cause, key) - } -} - // Artifact404 renders the error page for the artifact links. func Artifact404(c echo.Context, id string) error { const name = "status" @@ -107,309 +57,238 @@ func Artifact404(c echo.Context, id string) error { return nil } -// Artifact is the handler for the of the file record. -func (dir Dirs) Artifact(c echo.Context, logger *zap.SugaredLogger, readonly bool) error { //nolint:funlen - const name = "artifact" - if logger == nil { - return InternalErr(c, name, ErrZap) - } - ctx := context.Background() - db, err := postgres.ConnectDB() - if err != nil { - return DatabaseErr(c, "f/"+dir.URI, err) +// errorWithID returns an error with the artifact ID appended to the error message. +// The key string is expected any will always be displayed in the error message. +// The id can be an integer or string value and should be the database numeric ID. +func errorWithID(err error, key string, id any) error { + if err == nil { + return nil } - defer db.Close() - var art *models.File - if sess.Editor(c) { - art, err = model.OneEditByKey(ctx, db, dir.URI) - } else { - art, err = model.OneFileByKey(ctx, db, dir.URI) + key = strings.TrimSpace(key) + const cause = "caused by artifact" + switch id.(type) { + case int, int64: + return fmt.Errorf("%w: %s %s (%d)", err, cause, key, id) + case string: + return fmt.Errorf("%w: %s %s (%s)", err, cause, key, id) + default: + return fmt.Errorf("%w: %s %s", err, cause, key) } - if err != nil { - if errors.Is(err, model.ErrID) { - return Artifact404(c, dir.URI) - } - return DatabaseErr(c, "f/"+dir.URI, err) +} + +// Dirs contains the directories used by the artifact pages. +type Dirs struct { + Download string // path to the artifact download directory + Preview string // path to the preview and screenshot directory + Thumbnail string // path to the file thumbnail directory + Extra string // path to the extra files directory + URI string // the URI of the file record +} + +func alertURL(art *models.File) string { + if art == nil { + return "" } - fname := art.Filename.String - unid := art.UUID.String - extraZip := 0 - st, err := os.Stat(filepath.Join(dir.Extra, unid+".zip")) - if err == nil && !st.IsDir() { - extraZip = int(st.Size()) + // todo: confirm link is a valid url? + if art.FileSecurityAlertURL.Valid { + return strings.TrimSpace(art.FileSecurityAlertURL.String) } - data := empty(c) - data = dir.artifactEditor(art, data, readonly) - // page metadata - data["unid"] = unid - data["download"] = helper.ObfuscateID(art.ID) - data["title"] = fname - data["description"] = artifactDesc(art) - data["h1"] = artifactIssue(art) - data["lead"] = artifactLead(art) - data["comment"] = art.Comment.String - // file metadata - data["filename"] = fname - data["filesize"] = artifactByteCount(art.Filesize.Int64) - data["filebyte"] = art.Filesize - data["lastmodified"] = artifactLM(art) - data["lastmodifiedAgo"] = artifactModAgo(art) - data["checksum"] = strings.TrimSpace(art.FileIntegrityStrong.String) - data["magic"] = art.FileMagicType.String - data["releasers"] = string(LinkRels(art.GroupBrandBy, art.GroupBrandFor)) - data["published"] = dateIssued(art) - data["section"] = strings.TrimSpace(art.Section.String) - data["platform"] = strings.TrimSpace(art.Platform.String) - data["alertURL"] = art.FileSecurityAlertURL.String - data["extraZip"] = extraZip > 0 - // attributions and credits - data["writers"] = art.CreditText.String - data["artists"] = art.CreditIllustration.String - data["programmers"] = art.CreditProgram.String - data["musicians"] = art.CreditAudio.String - // links to other records and sites - data["relations"] = artifactRelations(art) - data["websites"] = artifactWebsites(art) - data["demozoo"] = artifactID(art.WebIDDemozoo.Int64) - data["pouet"] = artifactID(art.WebIDPouet.Int64) - data["sixteenColors"] = art.WebID16colors.String - data["youtube"] = strings.TrimSpace(art.WebIDYoutube.String) - data["github"] = art.WebIDGithub.String - // js-dos emulator - data = jsdos(data, logger, art) - // archive file content - data = content(art, data) - // record metadata - data["linkpreview"] = LinkPreviewHref(art.ID, art.Filename.String, art.Platform.String) - data["linkpreviewTip"] = LinkPreviewTip(art.Filename.String, art.Platform.String) - data = filentry(art, data) - d, err := dir.artifactReadme(art) - if err != nil { - return InternalErr(c, name, errorWithID(err, dir.URI, art.ID)) + return "" +} + +func attrArtist(art *models.File) string { + if art == nil { + return "" } - maps.Copy(data, d) - err = c.Render(http.StatusOK, name, data) - if err != nil { - return InternalErr(c, name, errorWithID(err, dir.URI, art.ID)) + if art.CreditIllustration.Valid { + return art.CreditIllustration.String } - return nil + return "" } -func (dir Dirs) artifactEditor(art *models.File, data map[string]interface{}, readonly bool) map[string]interface{} { - if readonly || art == nil { - return data - } - unid := art.UUID.String - abs := filepath.Join(dir.Download, unid) - data["readOnly"] = false - data["modID"] = art.ID - data["modTitle"] = art.RecordTitle.String - data["modOnline"] = art.Deletedat.Time.IsZero() - data["modReleasers"] = RecordRels(art.GroupBrandBy, art.GroupBrandFor) - rr := RecordReleasers(art.GroupBrandFor, art.GroupBrandBy) - data["modReleaser1"] = rr[0] - data["modReleaser2"] = rr[1] - data["modYear"] = art.DateIssuedYear.Int16 - data["modMonth"] = art.DateIssuedMonth.Int16 - data["modDay"] = art.DateIssuedDay.Int16 - data["modLastMod"] = !art.FileLastModified.IsZero() - data["modLMYear"] = art.FileLastModified.Time.Year() - data["modLMMonth"] = int(art.FileLastModified.Time.Month()) - data["modLMDay"] = art.FileLastModified.Time.Day() - data["modAbsDownload"] = abs - data["modMagicNumber"] = artifactMagic(abs) - data["modStatModify"] = artifactStat(abs)[0] - data["modStatSize"] = artifactStat(abs)[1] - data["modArchiveContent"] = artifactContent(abs) - data["modArchiveContentDst"], _ = artifactContentDst(abs) - data["modAssetPreview"] = dir.artifactAssets(dir.Preview, unid) - data["modAssetThumbnail"] = dir.artifactAssets(dir.Thumbnail, unid) - data["modAssetExtra"] = dir.artifactAssets(dir.Extra, unid) - data["modNoReadme"] = art.RetrotxtNoReadme.Int16 != 0 - data["modReadmeList"] = OptionsReadme(art.FileZipContent.String) - data["modPreviewList"] = OptionsPreview(art.FileZipContent.String) - data["modAnsiLoveList"] = OptionsAnsiLove(art.FileZipContent.String) - data["modReadmeSuggest"] = readmeSuggest(art) - data["modZipContent"] = strings.TrimSpace(art.FileZipContent.String) - data["modRelations"] = art.ListRelations.String - data["modWebsites"] = art.ListLinks.String - data["modOS"] = strings.ToLower(strings.TrimSpace(art.Platform.String)) - data["modTag"] = strings.ToLower(strings.TrimSpace(art.Section.String)) - data["virusTotal"] = strings.TrimSpace(art.FileSecurityAlertURL.String) - data["forApproval"] = !art.Deletedat.IsZero() && art.Deletedby.IsZero() - data["disableApproval"] = disableApproval(art) - data["disableRecord"] = !art.Deletedat.IsZero() && !art.Deletedby.IsZero() - data["missingAssets"] = missingAssets(art, dir) - data["modEmulateXMS"] = art.DoseeNoXMS.Int16 == 0 - data["modEmulateEMS"] = art.DoseeNoEms.Int16 == 0 - data["modEmulateUMB"] = art.DoseeNoUmb.Int16 == 0 - data["modEmulateBroken"] = art.DoseeIncompatible.Int16 != 0 - data["modEmulateRun"] = art.DoseeRunProgram.String - data["modEmulateCPU"] = art.DoseeHardwareCPU.String - data["modEmulateMachine"] = art.DoseeHardwareGraphic.String - data["modEmulateAudio"] = art.DoseeHardwareAudio.String - return data +func attrMusic(art *models.File) string { + if art == nil { + return "" + } + if art.CreditAudio.Valid { + return art.CreditAudio.String + } + return "" } -func disableApproval(art *models.File) string { - validate := model.Validate(art) - if validate == nil { +func attrProg(art *models.File) string { + if art == nil { return "" } - x := strings.Split(validate.Error(), ",") - s := make([]string, 0, len(x)) - for _, v := range x { - if strings.TrimSpace(v) == "" { - continue - } - s = append(s, v) + if art.CreditProgram.Valid { + return art.CreditProgram.String } - slices.Clip(s) - return strings.Join(s, " + ") + return "" } -func missingAssets(art *models.File, dir Dirs) string { - uid := art.UUID.String - missing := []string{} - d := helper.File(filepath.Join(dir.Download, uid)) - p := helper.File(filepath.Join(dir.Preview, uid+".png")) - t := helper.File(filepath.Join(dir.Thumbnail, uid+".png")) - if d && p && t { +func attrWriter(art *models.File) string { + if art == nil { return "" } - if !d { - missing = append(missing, "offer a file for download") - } - if art.Platform.String == tags.Audio.String() { - return strings.Join(missing, " + ") + if art.CreditText.Valid { + return art.CreditText.String } - if !p { - missing = append(missing, "create a preview image") + return "" +} + +func basename(art *models.File) string { + if art == nil { + return "" } - if !t { - missing = append(missing, "create a thumbnail image") + if art.Filename.Valid { + return art.Filename.String } - return strings.Join(missing, " + ") + return "" } -func content(art *models.File, data map[string]interface{}) map[string]interface{} { +func checksum(art *models.File) string { if art == nil { - return data - } - items := strings.Split(art.FileZipContent.String, "\n") - items = slices.DeleteFunc(items, func(s string) bool { - return strings.TrimSpace(s) == "" - }) - paths := slices.Compact(items) - data["content"] = paths - data["contentDesc"] = "" + return "" + } + if art.FileIntegrityStrong.Valid { + return strings.TrimSpace(art.FileIntegrityStrong.String) + } + return "" +} - l := len(paths) - switch l { - case 0: - return data - case 1: - data["contentDesc"] = "contains one file" - default: - data["contentDesc"] = fmt.Sprintf("contains %d files", l) +func comment(art *models.File) string { + if art == nil { + return "" + } + if art.Comment.Valid { + return art.Comment.String } - return data + return "" } -func jsdos(data map[string]interface{}, logger *zap.SugaredLogger, art *models.File, -) map[string]interface{} { - if logger == nil || art == nil { - return data - } - data["jsdos6"] = false - data["jsdos6Run"] = "" - data["jsdos6RunGuess"] = "" - data["jsdos6Config"] = "" - data["jsdos6Zip"] = false - data["jsdos6Utilities"] = false - if emulate := artifactJSDos(art); emulate { - data["jsdos6"] = emulate - cmd, err := model.JsDosCommand(art) - if err != nil { - logger.Error(errorWithID(err, "js-dos command", art.ID)) - return data +// dateIssued returns a formatted date string for the artifact's published date. +func dateIssued(f *models.File) template.HTML { + if f == nil { + return template.HTML(model.ErrModel.Error()) + } + ys, ms, ds := "", "", "" + if f.DateIssuedYear.Valid { + if i := int(f.DateIssuedYear.Int16); helper.Year(i) { + ys = strconv.Itoa(i) } - data["jsdos6Run"] = cmd - guess, err := model.JsDosBinary(art) - if err != nil { - logger.Error(errorWithID(err, "js-dos binary", art.ID)) - return data + } + if f.DateIssuedMonth.Valid { + if s := time.Month(f.DateIssuedMonth.Int16); s.String() != "" { + ms = s.String() } - data["jsdos6RunGuess"] = guess - cfg, err := model.JsDosConfig(art) - if err != nil { - logger.Error(errorWithID(err, "js-dos config", art.ID)) - return data + } + if f.DateIssuedDay.Valid { + if i := int(f.DateIssuedDay.Int16); helper.Day(i) { + ds = strconv.Itoa(i) } - data["jsdos6Config"] = cfg - data["jsdos6Zip"] = artifactJSDosArchive(art) - data["jsdos6Utilities"] = art.DoseeLoadUtilities.Int16 != 0 } - return data + strong := func(s string) template.HTML { + return template.HTML("" + s + "") + } + if isYearOnly := ys != "" && ms == "" && ds == ""; isYearOnly { + return strong(ys) + } + if isInvalidDay := ys != "" && ms != "" && ds == ""; isInvalidDay { + return strong(ys) + template.HTML(" "+ms) + } + if isInvalid := ys == "" && ms == "" && ds == ""; isInvalid { + return "unknown date" + } + return strong(ys) + template.HTML(fmt.Sprintf(" %s %s", ms, ds)) } -func filentry(art *models.File, data map[string]interface{}) map[string]interface{} { - if art == nil { - return data +func decode(src io.Reader) (string, error) { + out := strings.Builder{} + if _, err := io.Copy(&out, src); err != nil { + return "", fmt.Errorf("io.Copy: %w", err) + } + if !strings.HasSuffix(out.String(), "\n\n") { + out.WriteString("\n") + } + return out.String(), nil +} + +func description(art *models.File) string { + s := art.Filename.String + if art.RecordTitle.String != "" { + s = firstHeader(art) } - data["filentry"] = "" + r1 := releaser.Clean(strings.ToLower(art.GroupBrandBy.String)) + r2 := releaser.Clean(strings.ToLower(art.GroupBrandFor.String)) + r := "" switch { - case art.Createdat.Valid && art.Updatedat.Valid: - c := Updated(art.Createdat.Time, "") - u := Updated(art.Updatedat.Time, "") - if c != u { - c = Updated(art.Createdat.Time, "Created") - u = Updated(art.Updatedat.Time, "Updated") - data["filentry"] = c + br + u - return data - } - c = Updated(art.Createdat.Time, "Created") - data["filentry"] = c - case art.Createdat.Valid: - c := Updated(art.Createdat.Time, "Created") - data["filentry"] = c - case art.Updatedat.Valid: - u := Updated(art.Updatedat.Time, "Updated") - data["filentry"] = u - } - return data + case r1 != "" && r2 != "": + r = fmt.Sprintf("%s + %s", r1, r2) + case r1 != "": + r = r1 + case r2 != "": + r = r2 + } + s = fmt.Sprintf("%s released by %s", s, r) + y := art.DateIssuedYear.Int16 + if y > 0 { + s = fmt.Sprintf("%s in %d", s, y) + } + return s +} + +// dirsBytes returns the file size for the file record. +func dirsBytes(i int64) string { + if i == 0 { + return "(n/a)" + } + return humanize.Bytes(uint64(i)) } -// AnsiLovePost handles the post submission for the Preview from text in archive. -func (dir Dirs) AnsiLovePost(c echo.Context, logger *zap.SugaredLogger) error { - return dir.extractor(c, logger, ansis) +func downloadID(art *models.File) string { + if art == nil { + return "" + } + return helper.ObfuscateID(art.ID) } -// PreviewDel handles the post submission for the Delete complementary images button. -func (dir Dirs) PreviewDel(c echo.Context) error { - var f Form - if err := c.Bind(&f); err != nil { - return badRequest(c, err) - } - ctx := context.Background() - db, err := postgres.ConnectDB() - if err != nil { - return badRequest(c, err) +func (dir Dirs) extraZip(art *models.File) bool { + extraZip := 0 + unid := unid(art) + st, err := os.Stat(filepath.Join(dir.Extra, unid+".zip")) + if err == nil && !st.IsDir() { + extraZip = int(st.Size()) } - defer db.Close() - r, err := model.One(ctx, db, true, f.ID) - if err != nil { - return badRequest(c, err) + return extraZip > 0 +} + +// firstHeader returns the title of the file, +// unless the file is a magazine issue, in which case it returns the issue number. +func firstHeader(art *models.File) string { + sect := strings.TrimSpace(strings.ToLower(art.Section.String)) + if sect != "magazine" { + return art.RecordTitle.String } - if err = command.RemoveImgs(r.UUID.String, dir.Preview, dir.Thumbnail); err != nil { - return badRequest(c, err) + s := art.RecordTitle.String + if i, err := strconv.Atoi(s); err == nil { + return fmt.Sprintf("Issue %d", i) } - return c.JSON(http.StatusOK, r) + return s +} + +// firstLead returns the lead for the file record which is the filename and releasers. +func firstLead(art *models.File) string { + fname := art.Filename.String + span := fmt.Sprintf("%s ", fname) + rels := string(LinkRels(art.GroupBrandBy, art.GroupBrandFor)) + return fmt.Sprintf("%s
%s", rels, span) } -// PreviewPost handles the post submission for the Preview from image in archive. -func (dir Dirs) PreviewPost(c echo.Context, logger *zap.SugaredLogger) error { - return dir.extractor(c, logger, imgs) +func groupReleasers(art *models.File) string { + if art == nil { + return "" + } + return string(LinkRels(art.GroupBrandBy, art.GroupBrandFor)) } func moveCursor() string { @@ -430,6 +309,58 @@ func moveCursorToPos() string { return `\x1b\[\d+;\d+[Hf]` } +func idenfication16C(art *models.File) string { + if art == nil { + return "" + } + if art.WebID16colors.Valid { + return art.WebID16colors.String + } + return "" +} + +func idenficationDZ(art *models.File) string { + if art == nil { + return "" + } + if art.WebIDDemozoo.Valid { + id := art.WebIDDemozoo.Int64 + return strconv.FormatInt(id, 10) + } + return "" +} + +func idenficationGitHub(art *models.File) string { + if art == nil { + return "" + } + if art.WebIDGithub.Valid { + return art.WebIDGithub.String + } + return "" +} + +func idenficationPouet(art *models.File) string { + if art == nil { + return "" + } + if art.WebIDPouet.Valid { + id := art.WebIDPouet.Int64 + return strconv.FormatInt(id, 10) + } + return "" +} + +func idenficationYT(art *models.File) string { + if art == nil { + return "" + } + if art.WebIDYoutube.Valid { + return strings.TrimSpace(art.WebIDYoutube.String) + } + return "" +} + // incompatibleANSI scans for HTML incompatible, ANSI cursor escape codes in the reader. func incompatibleANSI(r io.Reader) (bool, error) { scanner := bufio.NewScanner(r) @@ -450,462 +381,138 @@ func incompatibleANSI(r io.Reader) (bool, error) { return false, nil } -func removeControlCodes(b []byte) []byte { - const ( - reAnsi = `\x1b\[[0-9;]*[a-zA-Z]` // ANSI escape codes - reAmiga = `\x1b\[[0-9;]*[ ]p` // unknown control code found in Amiga texts - reSauce = `SAUCE00.*` // SAUCE metadata that is appended to some files - nlWindows = "\r\n" // Windows line endings - nlUnix = "\n" // Unix line endings - ) - controlCodes := regexp.MustCompile(reAnsi + `|` + reAmiga + `|` + reSauce) - b = controlCodes.ReplaceAll(b, []byte{}) - b = bytes.ReplaceAll(b, []byte(nlWindows), []byte(nlUnix)) - return b -} - -func unsupported(art *models.File) bool { - const bbsRipImage = ".rip" - if filepath.Ext(strings.ToLower(art.Filename.String)) == bbsRipImage { - // the bbs era, remote images protcol is not supported - // example: /f/b02392f +// jsdosUse returns true if the file record is a known, MS-DOS executable. +// The supported file types are .zip archives and .exe, .com. binaries. +// Script files such as .bat and .cmd are not supported. +func jsdosUse(art *models.File) bool { + if art == nil { + return false + } + if strings.TrimSpace(strings.ToLower(art.Platform.String)) != "dos" { + return false + } + if jsdosArchive(art) { return true } - switch strings.TrimSpace(art.Platform.String) { - case "markup", "pdf": + ext := filepath.Ext(strings.ToLower(art.Filename.String)) + switch ext { + case ".exe", ".com": return true + case ".bat", ".cmd": + return false + default: + return false } - return false -} - -// artifactReadme returns the readme data for the file record. -func (dir Dirs) artifactReadme(art *models.File) (map[string]interface{}, error) { - data := map[string]interface{}{} - if art == nil || art.RetrotxtNoReadme.Int16 != 0 { - return data, nil - } - if unsupported(art) { - return data, nil - } - if skip := render.NoScreenshot(art, dir.Preview); skip { - data["noScreenshot"] = true - } - b, err := render.Read(art, dir.Download, dir.Extra) - if err != nil { - if errors.Is(err, render.ErrDownload) { - data["noDownload"] = true - return data, nil - } - if errors.Is(err, render.ErrFilename) { - return data, nil - } - return data, fmt.Errorf("render.Read: %w", err) - } - if b == nil { - return data, nil - } - r := bufio.NewReader(bytes.NewReader(b)) - // check the bytes are plain text but not utf16 or utf32 - if sign, err := magicnumber.Text(r); err != nil { - return data, fmt.Errorf("magicnumber.Text: %w", err) - } else if sign == magicnumber.Unknown || - sign == magicnumber.UTF16Text || - sign == magicnumber.UTF32Text { - return data, nil - } - // trim trailing whitespace and MS-DOS era EOF marker - b = bytes.TrimRightFunc(b, uni.IsSpace) - const endOfFile = 0x1a // Ctrl+Z - if bytes.HasSuffix(b, []byte{endOfFile}) { - b = bytes.TrimSuffix(b, []byte{endOfFile}) - } - if incompatible, err := incompatibleANSI(r); err != nil { - return data, fmt.Errorf("incompatibleANSI: %w", err) - } else if incompatible { - return data, nil - } - b = removeControlCodes(b) - return readmeEncoding(art, data, b...) } -func readmeEncoding(art *models.File, data map[string]interface{}, b ...byte) (map[string]interface{}, error) { - if len(b) == 0 { - return data, nil +func jsdosArchive(art *models.File) bool { + if art == nil { + return false } - const ( - sp = 0x20 // space - hyphen = 0x2d // hyphen-minus - shy = 0xad // soft hyphen for ISO8859-1 - nbsp = 0xa0 // non-breaking space for ISO8859-1 - nbsp437 = 0xff // non-breaking space for CP437 - space = " " // intentional space - chk = "checked" - ) - textEncoding := render.Encoder(art, bytes.NewReader(b)) - data["topazCheck"] = "" - data["vgaCheck"] = "" - switch textEncoding { - case charmap.ISO8859_1: - data["readmeLatin1Cls"] = "" - data["readmeCP437Cls"] = "d-none" + space - data["topazCheck"] = chk - b = bytes.ReplaceAll(b, []byte{nbsp}, []byte{sp}) - b = bytes.ReplaceAll(b, []byte{shy}, []byte{hyphen}) - case charmap.CodePage437: - data["readmeLatin1Cls"] = "d-none" + space - data["readmeCP437Cls"] = "" - data["vgaCheck"] = chk - b = bytes.ReplaceAll(b, []byte{nbsp437}, []byte{sp}) - case unicode.UTF8: - // use Cad font as default - data["readmeLatin1Cls"] = "d-none" + space - data["readmeCP437Cls"] = "" - data["vgaCheck"] = chk - } - var readme string - var err error - switch textEncoding { - case unicode.UTF8: - // unicode should apply to both latin1 and cp437 - readme, err = decode(bytes.NewReader(b)) - if err != nil { - return data, fmt.Errorf("unicode utf8 decode: %w", err) - } - data["readmeLatin1"] = readme - data["readmeCP437"] = readme - default: - d := charmap.ISO8859_1.NewDecoder().Reader(bytes.NewReader(b)) - readme, err = decode(d) - if err != nil { - return data, fmt.Errorf("iso8859_1 decode: %w", err) - } - data["readmeLatin1"] = readme - d = charmap.CodePage437.NewDecoder().Reader(bytes.NewReader(b)) - readme, err = decode(d) - if err != nil { - return data, fmt.Errorf("codepage437 decode: %w", err) - } - data["readmeCP437"] = readme + switch filepath.Ext(strings.ToLower(art.Filename.String)) { + case ".zip", ".lhz", ".lzh", ".arc", ".arj": + return true } - data["readmeLines"] = strings.Count(readme, "\n") - data["readmeRows"] = helper.MaxLineLength(readme) - return data, nil + return false } -func decode(src io.Reader) (string, error) { - out := strings.Builder{} - if _, err := io.Copy(&out, src); err != nil { - return "", fmt.Errorf("io.Copy: %w", err) +func jsdosUtilities(art *models.File) bool { + if art == nil { + return false } - if !strings.HasSuffix(out.String(), "\n\n") { - out.WriteString("\n") + if art.DoseeLoadUtilities.Valid { + return art.DoseeLoadUtilities.Int16 != 0 } - return out.String(), nil + return false } -// extractor is a helper function for the PreviewPost and AnsiLovePost handlers. -func (dir Dirs) extractor(c echo.Context, logger *zap.SugaredLogger, p extract) error { - var f Form - if err := c.Bind(&f); err != nil { - return badRequest(c, err) - } - ctx := context.Background() - db, err := postgres.ConnectDB() - if err != nil { - return badRequest(c, err) - } - defer db.Close() - r, err := model.One(ctx, db, true, f.ID) - if err != nil { - return badRequest(c, err) - } - - list := strings.Split(r.FileZipContent.String, "\n") - target := "" - for _, x := range list { - s := strings.TrimSpace(x) - if s == "" { - continue - } - if strings.EqualFold(s, f.Target) { - target = s - } +// lastModification returns the last modified date for the file record. +func lastModification(art *models.File) string { + const none = "no timestamp" + if !art.FileLastModified.Valid { + return none } - if target == "" { - return badRequest(c, ErrTarget) - } - src := filepath.Join(dir.Download, r.UUID.String) - cmd := command.Dirs{Download: dir.Download, Preview: dir.Preview, Thumbnail: dir.Thumbnail} - ext := filepath.Ext(strings.ToLower(r.Filename.String)) - switch p { - case imgs: - err = cmd.ExtractImage(logger, src, ext, r.UUID.String, target) - case ansis: - err = cmd.ExtractAnsiLove(logger, src, ext, r.UUID.String, target) - default: - return InternalErr(c, "extractor", fmt.Errorf("%w: %d", ErrExtract, p)) + year, _ := strconv.Atoi(art.FileLastModified.Time.Format("2006")) + if year <= epoch { + return none } - if err != nil { - return badRequest(c, err) + lm := art.FileLastModified.Time.Format("2006 Jan 2, 15:04") + if lm == "0001 Jan 1, 00:00" { + return none } - return c.JSON(http.StatusOK, r) + return lm } -// artifactByteCount returns the file size for the file record. -func artifactByteCount(i int64) string { - if i == 0 { - return "(n/a)" +// lastModificationAgo returns the last modified date in a human readable format. +func lastModificationAgo(art *models.File) string { + const none = "No recorded timestamp" + if !art.FileLastModified.Valid { + return none } - return humanize.Bytes(uint64(i)) -} - -// artifactContentDst returns the destination directory for the extracted archive content. -// The directory is created if it does not exist. The directory is named after the source file. -func artifactContentDst(src string) (string, error) { - name := strings.TrimSpace(strings.ToLower(filepath.Base(src))) - dir := filepath.Join(os.TempDir(), "defacto2-server") - - pattern := "artifact-content-" + name - dst := filepath.Join(dir, pattern) - if st, err := os.Stat(dst); err != nil { - if os.IsNotExist(err) { - if err := os.MkdirAll(dst, os.ModePerm); err != nil { - return "", err - } - return dst, nil - } - return dst, nil - } else if !st.IsDir() { - return "", fmt.Errorf("error, not a directory: %s", dir) + year, _ := strconv.Atoi(art.FileLastModified.Time.Format("2006")) + if year <= epoch { + return none } - return dst, nil + return Updated(art.FileLastModified.Time, "Modified") } -func artifactContent(src string) template.HTML { - const mb150 = 150 * 1024 * 1024 - if st, err := os.Stat(src); err != nil { - return template.HTML(err.Error()) - } else if st.IsDir() { - return "error, directory" - } else if st.Size() > mb150 { - return "will not decompress this archive as it is very large" - } - dst, err := artifactContentDst(src) - if err != nil { - return template.HTML(err.Error()) - } - - if entries, _ := os.ReadDir(dst); len(entries) == 0 { - if err := archive.ExtractAll(src, dst); err != nil { - defer os.RemoveAll(dst) - return template.HTML(err.Error()) - } - } - - files := 0 - var walkerCount = func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - return nil - } - files++ - return nil - } - if err := filepath.WalkDir(dst, walkerCount); err != nil { - return template.HTML(err.Error()) +func linkPreview(art *models.File) string { + if art == nil { + return "" } - - var b strings.Builder - items, zeroByteFiles := 0, 0 - var walkerFunc = func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - rel, err := filepath.Rel(dst, path) - if err != nil { - debug := fmt.Sprintf(`
... %v more files
`, err) - b.WriteString(debug) - return nil - } - if d.IsDir() { - return nil - } - info, err := d.Info() - if err != nil { - return nil - } - bytes := info.Size() - if bytes == 0 { - zeroByteFiles++ - return nil - } - size := humanize.Bytes(uint64(info.Size())) - image := false - texts := false - r, err := os.Open(path) - if err != nil { - return nil - } - sign, err := magicnumber.Find512B(r) - if err != nil { - return nil - } - for _, v := range magicnumber.Images() { - if v == sign { - image = true - break - } - } - for _, v := range magicnumber.Texts() { - if v == sign { - texts = true - break - } - } - items++ - htm := fmt.Sprintf(`
%s
`, - rel, rel) - if image || texts { - htm += `
` - } else { - htm += `
` - } - if texts { - htm += `
` - } else { - htm += `
` - } - htm += fmt.Sprintf(`
%s`, bytes, size) - htm += fmt.Sprintf(` %s
`, sign) - htm = fmt.Sprintf(`
%s
`, htm) - b.WriteString(htm) - if items > 200 { - more := fmt.Sprintf(`
... %d more files
`, files-items) - b.WriteString(more) - return filepath.SkipAll - } - return nil + if art.ID == 0 { + return "" } - err = filepath.WalkDir(dst, walkerFunc) - if err != nil { - return template.HTML(err.Error()) + id := art.ID + name := "" + platform := "" + if art.Filename.Valid { + name = art.Filename.String } - if zeroByteFiles > 0 { - zero := fmt.Sprintf(`
... skipped %d empty (0 B) files
`, zeroByteFiles) - b.WriteString(zero) + if art.Platform.Valid { + platform = art.Platform.String } - return template.HTML(b.String()) + return LinkPreviewHref(id, name, platform) } -// artifactDesc returns the description for the file record. -func artifactDesc(art *models.File) string { - s := art.Filename.String - if art.RecordTitle.String != "" { - s = artifactIssue(art) - } - r1 := releaser.Clean(strings.ToLower(art.GroupBrandBy.String)) - r2 := releaser.Clean(strings.ToLower(art.GroupBrandFor.String)) - r := "" - switch { - case r1 != "" && r2 != "": - r = fmt.Sprintf("%s + %s", r1, r2) - case r1 != "": - r = r1 - case r2 != "": - r = r2 - } - s = fmt.Sprintf("%s released by %s", s, r) - y := art.DateIssuedYear.Int16 - if y > 0 { - s = fmt.Sprintf("%s in %d", s, y) +func linkPreviewTip(art *models.File) string { + if art == nil { + return "" } - return s -} - -// artifactIssue returns the title of the file, -// unless the file is a magazine issue, in which case it returns the issue number. -func artifactIssue(art *models.File) string { - sect := strings.TrimSpace(strings.ToLower(art.Section.String)) - if sect != "magazine" { - return art.RecordTitle.String + name := "" + platform := "" + if art.Filename.Valid { + name = art.Filename.String } - s := art.RecordTitle.String - if i, err := strconv.Atoi(s); err == nil { - return fmt.Sprintf("Issue %d", i) + if art.Platform.Valid { + platform = art.Platform.String } - return s -} + return LinkPreviewTip(name, platform) -// artifactLead returns the lead for the file record which is the filename and releasers. -func artifactLead(art *models.File) string { - fname := art.Filename.String - span := fmt.Sprintf("%s ", fname) - rels := string(LinkRels(art.GroupBrandBy, art.GroupBrandFor)) - return fmt.Sprintf("%s
%s", rels, span) } -// artifactLM returns the last modified date for the file record. -func artifactLM(art *models.File) string { - const none = "no timestamp" - if !art.FileLastModified.Valid { - return none - } - year, _ := strconv.Atoi(art.FileLastModified.Time.Format("2006")) - if year <= epoch { - return none - } - lm := art.FileLastModified.Time.Format("2006 Jan 2, 15:04") - if lm == "0001 Jan 1, 00:00" { - return none - } - return lm -} - -// artifactMagic returns the MIME type for the file record. -func artifactMagic(name string) string { - file, err := os.Open(name) - if err != nil { - return err.Error() - } - defer file.Close() - - const sample = 512 - head := make([]byte, sample) - _, err = file.Read(head) - if err != nil { - return err.Error() - } - - kind, err := filetype.Match(head) - if err != nil { - return err.Error() +func magic(art *models.File) string { + if art == nil { + return "" } - if kind != filetype.Unknown { - return kind.MIME.Value + if art.FileMagicType.Valid { + return strings.TrimSpace(art.FileMagicType.String) } - - return http.DetectContentType(head) + return "" } -// artifactModAgo returns the last modified date in a human readable format. -func artifactModAgo(art *models.File) string { - const none = "No recorded timestamp" - if !art.FileLastModified.Valid { - return none +func platform(art *models.File) string { + if art == nil { + return "" } - year, _ := strconv.Atoi(art.FileLastModified.Time.Format("2006")) - if year <= epoch { - return none + if art.Platform.Valid { + return strings.TrimSpace(art.Platform.String) } - return Updated(art.FileLastModified.Time, "Modified") + return "" } -// artifactRelations returns the list of relationships for the file record. -func artifactRelations(art *models.File) template.HTML { +// relations returns the list of relationships for the file record. +func relations(art *models.File) template.HTML { s := art.ListRelations.String if s == "" { return "" @@ -932,8 +539,57 @@ func artifactRelations(art *models.File) template.HTML { return template.HTML(rows) } -// artifactWebsites returns the list of links for the file record. -func artifactWebsites(art *models.File) template.HTML { +func removeControlCodes(b []byte) []byte { + const ( + reAnsi = `\x1b\[[0-9;]*[a-zA-Z]` // ANSI escape codes + reAmiga = `\x1b\[[0-9;]*[ ]p` // unknown control code found in Amiga texts + reSauce = `SAUCE00.*` // SAUCE metadata that is appended to some files + nlWindows = "\r\n" // Windows line endings + nlUnix = "\n" // Unix line endings + ) + controlCodes := regexp.MustCompile(reAnsi + `|` + reAmiga + `|` + reSauce) + b = controlCodes.ReplaceAll(b, []byte{}) + b = bytes.ReplaceAll(b, []byte(nlWindows), []byte(nlUnix)) + return b +} + +func section(art *models.File) string { + if art == nil { + return "" + } + // TODO: validate using the tag pkg? + if art.Section.Valid { + return strings.TrimSpace(art.Section.String) + } + return "" +} + +func unid(art *models.File) string { + if art == nil { + return "" + } + if art.UUID.Valid { + return art.UUID.String + } + return "" +} + +func unsupportedText(art *models.File) bool { + const bbsRipImage = ".rip" + if filepath.Ext(strings.ToLower(art.Filename.String)) == bbsRipImage { + // the bbs era, remote images protcol is not supported + // example: /f/b02392f + return true + } + switch strings.TrimSpace(art.Platform.String) { + case "markup", "pdf": + return true + } + return false +} + +// websites returns the list of links for the file record. +func websites(art *models.File) template.HTML { s := art.ListLinks.String if s == "" { return "" @@ -959,222 +615,3 @@ func artifactWebsites(art *models.File) template.HTML { } return template.HTML(rows) } - -// artifactJSDos returns true if the file record is a known, MS-DOS executable. -// The supported file types are .zip archives and .exe, .com. binaries. -// Script files such as .bat and .cmd are not supported. -func artifactJSDos(art *models.File) bool { - if strings.TrimSpace(strings.ToLower(art.Platform.String)) != "dos" { - return false - } - if artifactJSDosArchive(art) { - return true - } - ext := filepath.Ext(strings.ToLower(art.Filename.String)) - switch ext { - case ".exe", ".com": - return true - case ".bat", ".cmd": - return false - default: - return false - } -} - -func artifactJSDosArchive(art *models.File) bool { - if art == nil { - return false - } - switch filepath.Ext(strings.ToLower(art.Filename.String)) { - case ".zip", ".lhz", ".lzh", ".arc", ".arj": - return true - } - return false -} - -// artifactID returns the record ID as a string. -func artifactID(id int64) string { - if id == 0 { - return "" - } - return strconv.FormatInt(id, 10) -} - -// artifactStat returns the file last modified date and formatted file size. -func artifactStat(name string) [2]string { - stat, err := os.Stat(name) - if err != nil { - return [2]string{err.Error(), err.Error()} - } - return [2]string{ - stat.ModTime().Format("2006-Jan-02"), - fmt.Sprintf("%s bytes - %s - %s", - humanize.Comma(stat.Size()), - humanize.Bytes(uint64(stat.Size())), - humanize.IBytes(uint64(stat.Size()))), - } -} - -// artifactAssets returns a list of downloads and image assets belonging to the file record. -// any errors are appended to the list. -// The returned map contains a short description of the asset, the file size and extra information, -// such as image dimensions or the number of lines in a text file. -func (dir Dirs) artifactAssets(nameDir, unid string) map[string][2]string { - matches := map[string][2]string{} - files, err := os.ReadDir(nameDir) - if err != nil { - matches["error"] = [2]string{err.Error(), ""} - } - // Provide a string path and use that instead of dir Dirs. - const assetDownload = "" - for _, file := range files { - if strings.HasPrefix(file.Name(), unid) { - if filepath.Ext(file.Name()) == assetDownload { - continue - } - ext := strings.ToUpper(filepath.Ext(file.Name())) - st, err := file.Info() - if err != nil { - matches["error"] = [2]string{err.Error(), ""} - } - s := "" - switch ext { - case ".AVIF": - s = "AVIF" - matches[s] = [2]string{humanize.Comma(st.Size()), ""} - case ".JPG": - s = "Jpeg" - matches[s] = artifactImgInfo(filepath.Join(nameDir, file.Name())) - case ".PNG": - s = "PNG" - matches[s] = artifactImgInfo(filepath.Join(nameDir, file.Name())) - case ".TXT": - s = "README" - i, _ := helper.Lines(filepath.Join(dir.Extra, file.Name())) - matches[s] = [2]string{humanize.Comma(st.Size()), fmt.Sprintf("%d lines", i)} - case ".WEBP": - s = "WebP" - matches[s] = artifactImgInfo(filepath.Join(nameDir, file.Name())) - case ".ZIP": - s = "Repack ZIP" - matches[s] = [2]string{humanize.Comma(st.Size()), ""} - } - } - } - return matches -} - -// artifactImgInfo returns the image file size and dimensions. -func artifactImgInfo(name string) [2]string { - switch filepath.Ext(strings.ToLower(name)) { - case ".jpg", ".jpeg", ".gif", ".png", ".webp": - default: - st, err := os.Stat(name) - if err != nil { - return [2]string{err.Error(), ""} - } - return [2]string{humanize.Comma(st.Size()), ""} - } - reader, err := os.Open(name) - if err != nil { - return [2]string{err.Error(), ""} - } - defer reader.Close() - st, err := reader.Stat() - if err != nil { - return [2]string{err.Error(), ""} - } - config, _, err := image.DecodeConfig(reader) - if err != nil { - return [2]string{err.Error(), ""} - } - return [2]string{humanize.Comma(st.Size()), fmt.Sprintf("%dx%d", config.Width, config.Height)} -} - -// readmeSuggest returns a suggested readme file name for the record. -func readmeSuggest(r *models.File) string { - if r == nil { - return "" - } - filename := r.Filename.String - group := r.GroupBrandFor.String - if group == "" { - group = r.GroupBrandBy.String - } - if x := strings.Split(group, " "); len(x) > 1 { - group = x[0] - } - cont := strings.ReplaceAll(r.FileZipContent.String, "\r\n", "\n") - content := strings.Split(cont, "\n") - return ReadmeSuggest(filename, group, content...) -} - -// Readmes returns a list of readme text files found in the file archive. -func Readmes(content ...string) []string { - finds := []string{} - skip := []string{"scene.org", "scene.org.txt"} - for _, name := range content { - if name == "" { - continue - } - s := strings.ToLower(name) - if slices.Contains(skip, s) { - continue - } - ext := filepath.Ext(s) - if slices.Contains(priority(), ext) { - finds = append(finds, name) - continue - } - if slices.Contains(candidate(), ext) { - finds = append(finds, name) - } - } - return finds -} - -// priority returns a list of readme text file extensions in priority order. -func priority() []string { - return []string{".nfo", ".txt", ".unp", ".doc"} -} - -// candidate returns a list of other, common text file extensions in priority order. -func candidate() []string { - return []string{".diz", ".asc", ".1st", ".dox", ".me", ".cap", ".ans", ".pcb"} -} - -// dateIssued returns a formatted date string for the artifact's published date. -func dateIssued(f *models.File) template.HTML { - if f == nil { - return template.HTML(model.ErrModel.Error()) - } - ys, ms, ds := "", "", "" - if f.DateIssuedYear.Valid { - if i := int(f.DateIssuedYear.Int16); helper.Year(i) { - ys = strconv.Itoa(i) - } - } - if f.DateIssuedMonth.Valid { - if s := time.Month(f.DateIssuedMonth.Int16); s.String() != "" { - ms = s.String() - } - } - if f.DateIssuedDay.Valid { - if i := int(f.DateIssuedDay.Int16); helper.Day(i) { - ds = strconv.Itoa(i) - } - } - strong := func(s string) template.HTML { - return template.HTML("" + s + "") - } - if isYearOnly := ys != "" && ms == "" && ds == ""; isYearOnly { - return strong(ys) - } - if isInvalidDay := ys != "" && ms != "" && ds == ""; isInvalidDay { - return strong(ys) + template.HTML(" "+ms) - } - if isInvalid := ys == "" && ms == "" && ds == ""; isInvalid { - return "unknown date" - } - return strong(ys) + template.HTML(fmt.Sprintf(" %s %s", ms, ds)) -} diff --git a/handler/app/dirsartifact.go b/handler/app/dirsartifact.go new file mode 100644 index 00000000..5f57601e --- /dev/null +++ b/handler/app/dirsartifact.go @@ -0,0 +1,339 @@ +package app + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "maps" + "net/http" + "slices" + "strings" + uni "unicode" + + "github.com/Defacto2/server/handler/sess" + "github.com/Defacto2/server/internal/helper" + "github.com/Defacto2/server/internal/magicnumber" + "github.com/Defacto2/server/internal/postgres" + "github.com/Defacto2/server/internal/postgres/models" + "github.com/Defacto2/server/internal/render" + "github.com/Defacto2/server/model" + "github.com/labstack/echo/v4" + "go.uber.org/zap" + "golang.org/x/text/encoding/charmap" + "golang.org/x/text/encoding/unicode" +) + +const epoch = model.EpochYear // epoch is the default year for MS-DOS files without a timestamp + +// Artifact is the handler for the of the file record. +func (dir Dirs) Artifact(c echo.Context, logger *zap.SugaredLogger, readonly bool) error { + const name = "artifact" + if logger == nil { + return InternalErr(c, name, ErrZap) + } + art, err := dir.modelsFile(c) + if err != nil { + return err + } + data := empty(c) + // artifact editor + data = dir.artifactEditor(art, data, readonly) + // page metadata + data["unid"] = unid(art) + data["download"] = downloadID(art) + data["title"] = basename(art) + data["description"] = description(art) + data["h1"] = firstHeader(art) + data["lead"] = firstLead(art) + data["comment"] = comment(art) + // file metadata + data = dir.filemetadata(art, data) + // attributions and credits + data = dir.attributions(art, data) + // links to other records and sites + data = dir.otherRelations(art, data) + // js-dos emulator + data = jsdos(art, data, logger) + // archive file content + data = content(art, data) + // record metadata + data = recordmetadata(art, data) + // readme text + d, err := dir.artifactReadme(art) + if err != nil { + return InternalErr(c, name, errorWithID(err, dir.URI, art.ID)) + } + maps.Copy(data, d) + err = c.Render(http.StatusOK, name, data) + if err != nil { + return InternalErr(c, name, errorWithID(err, dir.URI, art.ID)) + } + return nil +} + +// modelsFile returns the URI artifact record from the file table. +func (dir Dirs) modelsFile(c echo.Context) (*models.File, error) { + ctx := context.Background() + db, err := postgres.ConnectDB() + if err != nil { + return nil, DatabaseErr(c, "f/"+dir.URI, err) + } + defer db.Close() + var art *models.File + if sess.Editor(c) { + art, err = model.OneEditByKey(ctx, db, dir.URI) + } else { + art, err = model.OneFileByKey(ctx, db, dir.URI) + } + if err != nil { + if errors.Is(err, model.ErrID) { + return nil, Artifact404(c, dir.URI) + } + return nil, DatabaseErr(c, "f/"+dir.URI, err) + } + return art, nil +} + +func (dir Dirs) attributions(art *models.File, data map[string]interface{}) map[string]interface{} { + data["writers"] = attrWriter(art) + data["artists"] = attrArtist(art) + data["programmers"] = attrProg(art) + data["musicians"] = attrMusic(art) + return data +} + +func (dir Dirs) filemetadata(art *models.File, data map[string]interface{}) map[string]interface{} { + data["filename"] = basename(art) + data["filesize"] = dirsBytes(art.Filesize.Int64) + data["filebyte"] = art.Filesize + data["lastmodified"] = lastModification(art) + data["lastmodifiedAgo"] = lastModificationAgo(art) + data["checksum"] = checksum(art) + data["magic"] = magic(art) + data["releasers"] = groupReleasers(art) + data["published"] = dateIssued(art) + data["section"] = section(art) + data["platform"] = platform(art) + data["alertURL"] = alertURL(art) + data["extraZip"] = dir.extraZip(art) + return data +} + +func (dir Dirs) otherRelations(art *models.File, data map[string]interface{}) map[string]interface{} { + data["relations"] = relations(art) + data["websites"] = websites(art) + data["demozoo"] = idenficationDZ(art) + data["pouet"] = idenficationPouet(art) + data["sixteenColors"] = idenfication16C(art) + data["youtube"] = idenficationYT(art) + data["github"] = idenficationGitHub(art) + return data +} + +func content(art *models.File, data map[string]interface{}) map[string]interface{} { + if art == nil { + return data + } + data["content"] = "" + data["contentDesc"] = "" + items := strings.Split(art.FileZipContent.String, "\n") + items = slices.DeleteFunc(items, func(s string) bool { + return strings.TrimSpace(s) == "" + }) + paths := slices.Compact(items) + data["content"] = paths + data["contentDesc"] = "" + l := len(paths) + switch l { + case 0: + return data + case 1: + data["contentDesc"] = "contains one file" + default: + data["contentDesc"] = fmt.Sprintf("contains %d files", l) + } + return data +} + +func jsdos(art *models.File, data map[string]interface{}, logger *zap.SugaredLogger, +) map[string]interface{} { + if logger == nil || art == nil { + return data + } + data["jsdos6"] = false + data["jsdos6Run"] = "" + data["jsdos6RunGuess"] = "" + data["jsdos6Config"] = "" + data["jsdos6Zip"] = false + data["jsdos6Utilities"] = false + if emulate := jsdosUse(art); !emulate { + return data + } + data["jsdos6"] = true + cmd, err := model.JsDosCommand(art) + if err != nil { + logger.Error(errorWithID(err, "js-dos command", art.ID)) + return data + } + data["jsdos6Run"] = cmd + guess, err := model.JsDosBinary(art) + if err != nil { + logger.Error(errorWithID(err, "js-dos binary", art.ID)) + return data + } + data["jsdos6RunGuess"] = guess + cfg, err := model.JsDosConfig(art) + if err != nil { + logger.Error(errorWithID(err, "js-dos config", art.ID)) + return data + } + data["jsdos6Config"] = cfg + data["jsdos6Zip"] = jsdosArchive(art) + data["jsdos6Utilities"] = jsdosUtilities(art) + return data +} + +func recordmetadata(art *models.File, data map[string]interface{}) map[string]interface{} { + if art == nil { + return data + } + data["linkpreview"] = linkPreview(art) + data["linkpreviewTip"] = linkPreviewTip(art) + data["filentry"] = "" + switch { + case art.Createdat.Valid && art.Updatedat.Valid: + c := Updated(art.Createdat.Time, "") + u := Updated(art.Updatedat.Time, "") + if c != u { + c = Updated(art.Createdat.Time, "Created") + u = Updated(art.Updatedat.Time, "Updated") + data["filentry"] = c + br + u + return data + } + c = Updated(art.Createdat.Time, "Created") + data["filentry"] = c + case art.Createdat.Valid: + c := Updated(art.Createdat.Time, "Created") + data["filentry"] = c + case art.Updatedat.Valid: + u := Updated(art.Updatedat.Time, "Updated") + data["filentry"] = u + } + return data +} + +// artifactReadme returns the readme data for the file record. +func (dir Dirs) artifactReadme(art *models.File) (map[string]interface{}, error) { + data := map[string]interface{}{} + if art == nil || art.RetrotxtNoReadme.Int16 != 0 { + return data, nil + } + if unsupportedText(art) { + return data, nil + } + if skip := render.NoScreenshot(art, dir.Download, dir.Preview); skip { + data["noScreenshot"] = true + } + b, err := render.Read(art, dir.Download, dir.Extra) + if err != nil { + if errors.Is(err, render.ErrDownload) { + data["noDownload"] = true + return data, nil + } + if errors.Is(err, render.ErrFilename) { + return data, nil + } + return data, fmt.Errorf("render.Read: %w", err) + } + if b == nil { + return data, nil + } + r := bufio.NewReader(bytes.NewReader(b)) + // check the bytes are plain text but not utf16 or utf32 + if sign, err := magicnumber.Text(r); err != nil { + return data, fmt.Errorf("magicnumber.Text: %w", err) + } else if sign == magicnumber.Unknown || + sign == magicnumber.UTF16Text || + sign == magicnumber.UTF32Text { + return data, nil + } + // trim trailing whitespace and MS-DOS era EOF marker + b = bytes.TrimRightFunc(b, uni.IsSpace) + const endOfFile = 0x1a // Ctrl+Z + if bytes.HasSuffix(b, []byte{endOfFile}) { + b = bytes.TrimSuffix(b, []byte{endOfFile}) + } + if incompatible, err := incompatibleANSI(r); err != nil { + return data, fmt.Errorf("incompatibleANSI: %w", err) + } else if incompatible { + return data, nil + } + b = removeControlCodes(b) + return readmeEncoding(art, data, b...) +} + +func readmeEncoding(art *models.File, data map[string]interface{}, b ...byte) (map[string]interface{}, error) { + if len(b) == 0 { + return data, nil + } + const ( + sp = 0x20 // space + hyphen = 0x2d // hyphen-minus + shy = 0xad // soft hyphen for ISO8859-1 + nbsp = 0xa0 // non-breaking space for ISO8859-1 + nbsp437 = 0xff // non-breaking space for CP437 + space = " " // intentional space + chk = "checked" + ) + textEncoding := render.Encoder(art, bytes.NewReader(b)) + data["topazCheck"] = "" + data["vgaCheck"] = "" + switch textEncoding { + case charmap.ISO8859_1: + data["readmeLatin1Cls"] = "" + data["readmeCP437Cls"] = "d-none" + space + data["topazCheck"] = chk + b = bytes.ReplaceAll(b, []byte{nbsp}, []byte{sp}) + b = bytes.ReplaceAll(b, []byte{shy}, []byte{hyphen}) + case charmap.CodePage437: + data["readmeLatin1Cls"] = "d-none" + space + data["readmeCP437Cls"] = "" + data["vgaCheck"] = chk + b = bytes.ReplaceAll(b, []byte{nbsp437}, []byte{sp}) + case unicode.UTF8: + // use Cad font as default + data["readmeLatin1Cls"] = "d-none" + space + data["readmeCP437Cls"] = "" + data["vgaCheck"] = chk + } + var readme string + var err error + switch textEncoding { + case unicode.UTF8: + // unicode should apply to both latin1 and cp437 + readme, err = decode(bytes.NewReader(b)) + if err != nil { + return data, fmt.Errorf("unicode utf8 decode: %w", err) + } + data["readmeLatin1"] = readme + data["readmeCP437"] = readme + default: + d := charmap.ISO8859_1.NewDecoder().Reader(bytes.NewReader(b)) + readme, err = decode(d) + if err != nil { + return data, fmt.Errorf("iso8859_1 decode: %w", err) + } + data["readmeLatin1"] = readme + d = charmap.CodePage437.NewDecoder().Reader(bytes.NewReader(b)) + readme, err = decode(d) + if err != nil { + return data, fmt.Errorf("codepage437 decode: %w", err) + } + data["readmeCP437"] = readme + } + data["readmeLines"] = strings.Count(readme, "\n") + data["readmeRows"] = helper.MaxLineLength(readme) + return data, nil +} diff --git a/handler/app/dirseditor.go b/handler/app/dirseditor.go new file mode 100644 index 00000000..d29b3c2c --- /dev/null +++ b/handler/app/dirseditor.go @@ -0,0 +1,423 @@ +package app + +import ( + "fmt" + "html/template" + "image" + "io/fs" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/Defacto2/server/internal/archive" + "github.com/Defacto2/server/internal/helper" + "github.com/Defacto2/server/internal/magicnumber" + "github.com/Defacto2/server/internal/postgres/models" + "github.com/Defacto2/server/internal/tags" + "github.com/Defacto2/server/model" + "github.com/dustin/go-humanize" + "github.com/h2non/filetype" +) + +func (dir Dirs) artifactEditor(art *models.File, data map[string]interface{}, readonly bool) map[string]interface{} { + if readonly || art == nil { + return data + } + unid := art.UUID.String + abs := filepath.Join(dir.Download, unid) + mod, sizeB, sizeFmt := artifactStat(abs) + data["readOnly"] = false + data["modID"] = art.ID + data["modTitle"] = art.RecordTitle.String + data["modOnline"] = art.Deletedat.Time.IsZero() + data["modReleasers"] = RecordRels(art.GroupBrandBy, art.GroupBrandFor) + rr := RecordReleasers(art.GroupBrandFor, art.GroupBrandBy) + data["modReleaser1"] = rr[0] + data["modReleaser2"] = rr[1] + data["modYear"] = art.DateIssuedYear.Int16 + data["modMonth"] = art.DateIssuedMonth.Int16 + data["modDay"] = art.DateIssuedDay.Int16 + data["modLastMod"] = !art.FileLastModified.IsZero() + data["modLMYear"] = art.FileLastModified.Time.Year() + data["modLMMonth"] = int(art.FileLastModified.Time.Month()) + data["modLMDay"] = art.FileLastModified.Time.Day() + data["modAbsDownload"] = abs + data["modMagicMime"] = artifactMIME(abs) + data["modMagicNumber"] = magicNumber(abs) + data["modStatModify"] = mod + data["modDBModify"] = art.FileLastModified.Time.Format("2006-01-02") + data["modStatSizeB"] = sizeB + data["modStatSizeF"] = sizeFmt + data["modArchiveContent"] = artifactContent(abs, art.Platform.String) + data["modArchiveContentDst"], _ = artifactContentDst(abs) + data["modAssetPreview"] = dir.artifactAssets(dir.Preview, unid) + data["modAssetThumbnail"] = dir.artifactAssets(dir.Thumbnail, unid) + data["modAssetExtra"] = dir.artifactAssets(dir.Extra, unid) + data["modNoReadme"] = art.RetrotxtNoReadme.Int16 != 0 + data["modReadmeList"] = OptionsReadme(art.FileZipContent.String) + data["modPreviewList"] = OptionsPreview(art.FileZipContent.String) + data["modAnsiLoveList"] = OptionsAnsiLove(art.FileZipContent.String) + data["modReadmeSuggest"] = readmeSuggest(art) + data["modZipContent"] = strings.TrimSpace(art.FileZipContent.String) + data["modRelations"] = art.ListRelations.String + data["modWebsites"] = art.ListLinks.String + data["modOS"] = strings.ToLower(strings.TrimSpace(art.Platform.String)) + data["modTag"] = strings.ToLower(strings.TrimSpace(art.Section.String)) + data["virusTotal"] = strings.TrimSpace(art.FileSecurityAlertURL.String) + data["forApproval"] = !art.Deletedat.IsZero() && art.Deletedby.IsZero() + data["disableApproval"] = disableApproval(art) + data["disableRecord"] = !art.Deletedat.IsZero() && !art.Deletedby.IsZero() + data["missingAssets"] = missingAssets(art, dir) + data["modEmulateXMS"] = art.DoseeNoXMS.Int16 == 0 + data["modEmulateEMS"] = art.DoseeNoEms.Int16 == 0 + data["modEmulateUMB"] = art.DoseeNoUmb.Int16 == 0 + data["modEmulateBroken"] = art.DoseeIncompatible.Int16 != 0 + data["modEmulateRun"] = art.DoseeRunProgram.String + data["modEmulateCPU"] = art.DoseeHardwareCPU.String + data["modEmulateMachine"] = art.DoseeHardwareGraphic.String + data["modEmulateAudio"] = art.DoseeHardwareAudio.String + return data +} + +func magicNumber(name string) string { + r, err := os.Open(name) + if err != nil { + return err.Error() + } + defer r.Close() + sign, err := magicnumber.Find(r) + if err != nil { + return err.Error() + } + return sign.Title() +} + +func disableApproval(art *models.File) string { + validate := model.Validate(art) + if validate == nil { + return "" + } + x := strings.Split(validate.Error(), ",") + s := make([]string, 0, len(x)) + for _, v := range x { + if strings.TrimSpace(v) == "" { + continue + } + s = append(s, v) + } + s = slices.Clip(s) + return strings.Join(s, " + ") +} + +func missingAssets(art *models.File, dir Dirs) string { + uid := art.UUID.String + missing := []string{} + d := helper.File(filepath.Join(dir.Download, uid)) + p := helper.File(filepath.Join(dir.Preview, uid+".png")) + t := helper.File(filepath.Join(dir.Thumbnail, uid+".png")) + if d && p && t { + return "" + } + if !d { + missing = append(missing, "offer a file for download") + } + if art.Platform.String == tags.Audio.String() { + return strings.Join(missing, " + ") + } + if !p { + missing = append(missing, "create a preview image") + } + if !t { + missing = append(missing, "create a thumbnail image") + } + return strings.Join(missing, " + ") +} + +// artifactContentDst returns the destination directory for the extracted archive content. +// The directory is created if it does not exist. The directory is named after the source file. +func artifactContentDst(src string) (string, error) { + name := strings.TrimSpace(strings.ToLower(filepath.Base(src))) + dir := filepath.Join(os.TempDir(), "defacto2-server") + + pattern := "artifact-content-" + name + dst := filepath.Join(dir, pattern) + if st, err := os.Stat(dst); err != nil { + if os.IsNotExist(err) { + if err := os.MkdirAll(dst, os.ModePerm); err != nil { + return "", err + } + return dst, nil + } + return dst, nil + } else if !st.IsDir() { + return "", fmt.Errorf("error, not a directory: %s", dir) + } + return dst, nil +} + +func artifactContent(src, platform string) template.HTML { + const mb150 = 150 * 1024 * 1024 + if st, err := os.Stat(src); err != nil { + return template.HTML(err.Error()) + } else if st.IsDir() { + return "error, directory" + } else if st.Size() > mb150 { + return "will not decompress this archive as it is very large" + } + dst, err := artifactContentDst(src) + if err != nil { + return template.HTML(err.Error()) + } + + if entries, _ := os.ReadDir(dst); len(entries) == 0 { + if err := archive.ExtractAll(src, dst); err != nil { + defer os.RemoveAll(dst) + return template.HTML(err.Error()) + } + } + + files := 0 + var walkerCount = func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + files++ + return nil + } + if err := filepath.WalkDir(dst, walkerCount); err != nil { + return template.HTML(err.Error()) + } + + var b strings.Builder + items, zeroByteFiles := 0, 0 + var walkerFunc = func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + rel, err := filepath.Rel(dst, path) + if err != nil { + debug := fmt.Sprintf(`
... %v more files
`, err) + b.WriteString(debug) + return nil + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + bytes := info.Size() + if bytes == 0 { + zeroByteFiles++ + return nil + } + size := humanize.Bytes(uint64(info.Size())) + image := false + texts := false + program := false + r, err := os.Open(path) + if err != nil { + return nil + } + defer r.Close() + sign, err := magicnumber.Find512B(r) + if err != nil { + return nil + } + for _, v := range magicnumber.Images() { + if v == sign { + image = true + break + } + } + for _, v := range magicnumber.Texts() { + if v == sign { + texts = true + break + } + } + for _, v := range magicnumber.Programs() { + if strings.EqualFold(platform, tags.DOS.String()) { + break + } + if v == sign { + program = true + break + } + } + items++ + htm := fmt.Sprintf(`
%s
`, + rel, rel) + if image || texts { + htm += `
` + } else { + htm += `
` + } + if texts { + htm += `
` + } else if program { + htm += `
` + } else { + htm += `
` + } + htm += fmt.Sprintf(`
%s`, bytes, size) + htm += fmt.Sprintf(` %s
`, sign) + htm = fmt.Sprintf(`
%s
`, htm) + b.WriteString(htm) + if items > 200 { + more := fmt.Sprintf(`
... %d more files
`, files-items) + b.WriteString(more) + return filepath.SkipAll + } + return nil + } + err = filepath.WalkDir(dst, walkerFunc) + if err != nil { + return template.HTML(err.Error()) + } + if zeroByteFiles > 0 { + zero := fmt.Sprintf(`
... skipped %d empty (0 B) files
`, zeroByteFiles) + b.WriteString(zero) + } + return template.HTML(b.String()) +} + +// artifactMIME returns the MIME type for the file record. +func artifactMIME(name string) string { + file, err := os.Open(name) + if err != nil { + return err.Error() + } + defer file.Close() + + const sample = 512 + head := make([]byte, sample) + _, err = file.Read(head) + if err != nil { + return err.Error() + } + + kind, err := filetype.Match(head) + if err != nil { + return err.Error() + } + if kind != filetype.Unknown { + return kind.MIME.Value + } + + return http.DetectContentType(head) +} + +// artifactStat returns the file last modified date, file size in bytes and formatted. +func artifactStat(name string) (string, string, string) { + stat, err := os.Stat(name) + if err != nil { + return "", "", err.Error() + } + return stat.ModTime().Format("2006-Jan-02"), + humanize.Comma(stat.Size()), + humanize.Bytes(uint64(stat.Size())) +} + +// artifactAssets returns a list of downloads and image assets belonging to the file record. +// any errors are appended to the list. +// The returned map contains a short description of the asset, the file size and extra information, +// such as image dimensions or the number of lines in a text file. +func (dir Dirs) artifactAssets(nameDir, unid string) map[string][2]string { + matches := map[string][2]string{} + files, err := os.ReadDir(nameDir) + if err != nil { + matches["error"] = [2]string{err.Error(), ""} + } + // Provide a string path and use that instead of dir Dirs. + const assetDownload = "" + for _, file := range files { + if strings.HasPrefix(file.Name(), unid) { + if filepath.Ext(file.Name()) == assetDownload { + continue + } + ext := strings.ToUpper(filepath.Ext(file.Name())) + st, err := file.Info() + if err != nil { + matches["error"] = [2]string{err.Error(), ""} + } + s := "" + switch ext { + case ".AVIF": + s = "AVIF" + matches[s] = [2]string{humanize.Comma(st.Size()), ""} + case ".JPG": + s = "Jpeg" + matches[s] = artifactImgInfo(filepath.Join(nameDir, file.Name())) + case ".PNG": + s = "PNG" + matches[s] = artifactImgInfo(filepath.Join(nameDir, file.Name())) + case ".TXT": + s = "README" + i, _ := helper.Lines(filepath.Join(dir.Extra, file.Name())) + matches[s] = [2]string{humanize.Comma(st.Size()), fmt.Sprintf("%d lines", i)} + case ".WEBP": + s = "WebP" + matches[s] = artifactImgInfo(filepath.Join(nameDir, file.Name())) + case ".ZIP": + s = "Repacked ZIP" + matches[s] = [2]string{humanize.Comma(st.Size()), "Deflate compression"} + } + } + } + return matches +} + +// artifactImgInfo returns the image file size and dimensions. +func artifactImgInfo(name string) [2]string { + switch filepath.Ext(strings.ToLower(name)) { + case ".jpg", ".jpeg", ".gif", ".png", ".webp": + default: + st, err := os.Stat(name) + if err != nil { + return [2]string{err.Error(), ""} + } + return [2]string{humanize.Comma(st.Size()), ""} + } + reader, err := os.Open(name) + if err != nil { + return [2]string{err.Error(), ""} + } + defer reader.Close() + st, err := reader.Stat() + if err != nil { + return [2]string{err.Error(), ""} + } + config, _, err := image.DecodeConfig(reader) + if err != nil { + return [2]string{err.Error(), ""} + } + return [2]string{humanize.Comma(st.Size()), fmt.Sprintf("%dx%d", config.Width, config.Height)} +} + +// readmeSuggest returns a suggested readme file name for the record. +func readmeSuggest(r *models.File) string { + if r == nil { + return "" + } + filename := r.Filename.String + group := r.GroupBrandFor.String + if group == "" { + group = r.GroupBrandBy.String + } + if x := strings.Split(group, " "); len(x) > 1 { + group = x[0] + } + cont := strings.ReplaceAll(r.FileZipContent.String, "\r\n", "\n") + content := strings.Split(cont, "\n") + return ReadmeSuggest(filename, group, content...) +} diff --git a/handler/app/dirshtmx.go b/handler/app/dirshtmx.go new file mode 100644 index 00000000..c080236a --- /dev/null +++ b/handler/app/dirshtmx.go @@ -0,0 +1,95 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/Defacto2/server/internal/command" + "github.com/Defacto2/server/internal/postgres" + "github.com/Defacto2/server/model" + "github.com/labstack/echo/v4" + "go.uber.org/zap" +) + +// AnsiLovePost handles the post submission for the Preview from text in archive. +func (dir Dirs) AnsiLovePost(c echo.Context, logger *zap.SugaredLogger) error { + return dir.extractor(c, logger, ansitext) +} + +// PreviewDel handles the post submission for the Delete complementary images button. +func (dir Dirs) PreviewDel(c echo.Context) error { + var f Form + if err := c.Bind(&f); err != nil { + return badRequest(c, err) + } + ctx := context.Background() + db, err := postgres.ConnectDB() + if err != nil { + return badRequest(c, err) + } + defer db.Close() + r, err := model.One(ctx, db, true, f.ID) + if err != nil { + return badRequest(c, err) + } + if err = command.RemoveImgs(r.UUID.String, dir.Preview, dir.Thumbnail); err != nil { + return badRequest(c, err) + } + return c.JSON(http.StatusOK, r) +} + +// PreviewPost handles the post submission for the Preview from image in archive. +func (dir Dirs) PreviewPost(c echo.Context, logger *zap.SugaredLogger) error { + return dir.extractor(c, logger, picture) +} + +// extractor is a helper function for the PreviewPost and AnsiLovePost handlers. +func (dir Dirs) extractor(c echo.Context, logger *zap.SugaredLogger, p extract) error { + var f Form + if err := c.Bind(&f); err != nil { + return badRequest(c, err) + } + ctx := context.Background() + db, err := postgres.ConnectDB() + if err != nil { + return badRequest(c, err) + } + defer db.Close() + r, err := model.One(ctx, db, true, f.ID) + if err != nil { + return badRequest(c, err) + } + + list := strings.Split(r.FileZipContent.String, "\n") + target := "" + for _, x := range list { + s := strings.TrimSpace(x) + if s == "" { + continue + } + if strings.EqualFold(s, f.Target) { + target = s + } + } + if target == "" { + return badRequest(c, ErrTarget) + } + src := filepath.Join(dir.Download, r.UUID.String) + cmd := command.Dirs{Download: dir.Download, Preview: dir.Preview, Thumbnail: dir.Thumbnail} + ext := filepath.Ext(strings.ToLower(r.Filename.String)) + switch p { + case picture: + err = cmd.ExtractImage(logger, src, ext, r.UUID.String, target) + case ansitext: + err = cmd.ExtractAnsiLove(logger, src, ext, r.UUID.String, target) + default: + return InternalErr(c, "extractor", fmt.Errorf("%w: %d", ErrExtract, p)) + } + if err != nil { + return badRequest(c, err) + } + return c.JSON(http.StatusOK, r) +} diff --git a/handler/app/template.go b/handler/app/template.go index 299cf87d..f3e183d1 100644 --- a/handler/app/template.go +++ b/handler/app/template.go @@ -114,7 +114,7 @@ func DownloadB(i any) template.HTML { if !val.Valid { return " (n/a)" } - s = artifactByteCount(val.Int64) + s = dirsBytes(val.Int64) default: return template.HTML(fmt.Sprintf("%sDownloadB: %s", typeErr, reflect.TypeOf(i).String())) } @@ -383,7 +383,7 @@ func (web Templ) TemplateClosures() template.FuncMap { //nolint:funlen }, "capitalize": helper.Capitalize, "classification": func(s, p string) string { - count, _ := form.HumanizeAndCount(s, p) + count, _ := form.HumanizeCount(s, p) return string(count) }, "demozooSanity": func() string { @@ -536,6 +536,7 @@ func (web Templ) TemplateFuncs() template.FuncMap { "downloadB": DownloadB, "byteFile": ByteFile, "byteFileS": ByteFileS, + "classificationStr": form.HumanizeCountStr, "demozooGetLink": DemozooGetLink, "fmtDay": Day, "fmtMonth": Month, @@ -554,7 +555,7 @@ func (web Templ) TemplateFuncs() template.FuncMap { "linkSVG": LinkSVG, "linkWiki": LinkWiki, "logoText": LogoText, - "mimeMagic": MimeMagic, + "mimeMagic": MimeMagic, // TODO: replace "recordImgSample": web.ImageSample, "recordThumbSample": web.ThumbSample, "recordInfoOSTag": TagWithOS, diff --git a/handler/htmx/artifact.go b/handler/htmx/artifact.go index 60e5af3d..680b6a69 100644 --- a/handler/htmx/artifact.go +++ b/handler/htmx/artifact.go @@ -70,7 +70,7 @@ func RecordClassification(c echo.Context, logger *zap.SugaredLogger) error { section := c.FormValue("artifact-editor-categories") platform := c.FormValue("artifact-editor-operatingsystem") key := c.FormValue("artifact-editor-key") - html, err := form.HumanizeAndCount(section, platform) + html, err := form.HumanizeCount(section, platform) if err != nil { logger.Error(err) return badRequest(c, err) diff --git a/handler/htmx/transfer.go b/handler/htmx/transfer.go index 36b3ecae..448b2947 100644 --- a/handler/htmx/transfer.go +++ b/handler/htmx/transfer.go @@ -47,16 +47,16 @@ const ( category = "-category" ) -// HumanizeAndCount handles the post submission for the Uploader classification, +// HumanizeCount handles the post submission for the Uploader classification, // such as the platform, operating system, section or category tags. // The return value is either the humanized and counted classification or an error. -func HumanizeAndCount(c echo.Context, logger *zap.SugaredLogger, name string) error { +func HumanizeCount(c echo.Context, logger *zap.SugaredLogger, name string) error { section := c.FormValue(name + category) platform := c.FormValue(name + "-operatingsystem") if platform == "" { platform = c.FormValue(name + "-operating-system") } - html, err := form.HumanizeAndCount(section, platform) + html, err := form.HumanizeCount(section, platform) if err != nil { logger.Error(err) return badRequest(c, err) diff --git a/handler/routerhtmx.go b/handler/routerhtmx.go index d4d28df8..f19f91e8 100644 --- a/handler/routerhtmx.go +++ b/handler/routerhtmx.go @@ -39,7 +39,7 @@ func htmxGroup(e *echo.Echo, logger *zap.SugaredLogger, downloadDir string) *ech upload := g.Group("/uploader") upload.GET("/classifications", func(c echo.Context) error { - return htmx.HumanizeAndCount(c, logger, "uploader-advanced") + return htmx.HumanizeCount(c, logger, "uploader-advanced") }) upload.PATCH("/releaser/1", func(c echo.Context) error { return htmx.DataListReleasers(c, logger, releaser1(c)) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index d413c5e9..6e7f3bf4 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -494,8 +494,15 @@ func (x Extractor) Extract(targets ...string) error { case magicnumber.PKWAREZip, magicnumber.PKWAREZip64, - magicnumber.PKWAREMultiVolume: + magicnumber.PKWAREZipShrink, + magicnumber.PKWAREZipReduce, + magicnumber.PKWAREZipImplode: return x.extractZip(targets...) + case + magicnumber.PKLITE, + magicnumber.PKSFX, + magicnumber.PKWAREMultiVolume: + return fmt.Errorf("%w, %s", ErrNotImplemented, sign) case magicnumber.ARChiveSEA: return x.ARC(targets...) case magicnumber.ArchiveRobertJung: diff --git a/internal/form/form.go b/internal/form/form.go index 078c5667..2f98686e 100644 --- a/internal/form/form.go +++ b/internal/form/form.go @@ -18,15 +18,44 @@ import ( const ReSanitizePath = "[^a-zA-Z0-9-._/]+" // Regular expression to sanitize the URL path. -// HumanizeAndCount returns the human readable name of the platform and section tags combined +// HumanizeCount returns the human readable name of the platform and section tags combined // and the number of existing artifacts. The number of existing artifacts is colored based on // the count. If the count is 0, the text is red. If the count is 1, the text is blue. If the // count is greater than 1, the text is unmodified. -func HumanizeAndCount(section, platform string) (template.HTML, error) { +func HumanizeCount(section, platform string) (template.HTML, error) { + count, tag, err := humanizeCount(section, platform) + if err != nil { + return "", err + } + var html string + switch count { + case 0: + html = fmt.Sprintf("%s, %d existing artifacts", tag, count) + html = `` + html + `` + case 1: + html = fmt.Sprintf("%s, %d existing artifacts", tag, count) + html = `` + html + `` + default: + html = fmt.Sprintf("%s, %d existing artifacts", tag, count) + } + return template.HTML(html), nil +} + +// HumanizeCountStr returns the human readable name of the platform and section tags combined +// and the number of existing artifacts. Any errors are returned as a string. +func HumanizeCountStr(section, platform string) string { + count, tag, err := humanizeCount(section, platform) + if err != nil { + return err.Error() + } + return fmt.Sprintf("%s, %d existing artifacts", tag, count) +} + +func humanizeCount(section, platform string) (int64, string, error) { ctx := context.Background() db, err := postgres.ConnectDB() if err != nil { - return "cannot connect to the database", + return 0, "cannot connect to the database", fmt.Errorf("form humanize and count %w", err) } defer db.Close() @@ -36,32 +65,21 @@ func HumanizeAndCount(section, platform string) (template.HTML, error) { if strings.HasPrefix(tag, "unknown") { switch { case p.String() == "" && s.String() == "": - return "please choose both classifcations", nil + return 0, "please choose both classifcations", nil case s.String() == "": - return "please choose a tag as category", nil + return 0, "please choose a tag as category", nil case p.String() == "": - return "please choose an operating system", nil + return 0, "please choose an operating system", nil default: - return "unknown classification", nil + return 0, "unknown classification", nil } } count, err := model.ClassificationCount(ctx, db, section, platform) if err != nil { - return "cannot count the classification", + return 0, "cannot count the classification", fmt.Errorf("form humanize and count classification %w", err) } - var html string - switch count { - case 0: - html = fmt.Sprintf("%s, %d existing artifacts", tag, count) - html = `` + html + `` - case 1: - html = fmt.Sprintf("%s, %d existing artifacts", tag, count) - html = `` + html + `` - default: - html = fmt.Sprintf("%s, %d existing artifacts", tag, count) - } - return template.HTML(html), nil + return count, tag, nil } // SanitizeFilename returns a sanitized version of the filename. diff --git a/internal/magicnumber/magicnumber.go b/internal/magicnumber/magicnumber.go index 23d99050..d852df47 100644 --- a/internal/magicnumber/magicnumber.go +++ b/internal/magicnumber/magicnumber.go @@ -62,6 +62,9 @@ const ( OggVorbisCodec FreeLosslessAudioCodec WaveAudioForWindows + PKWAREZipShrink + PKWAREZipReduce + PKWAREZipImplode PKWAREZip64 PKWAREZip PKWAREMultiVolume @@ -130,6 +133,9 @@ func (sign Signature) String() string { "Ogg audio", "FLAC audio", "Wave audio", + "pkzip shrunk archive", + "pkzip reduced archive", + "pkzip imploded archive", "zip64 archive", "zip archive", "multivolume zip", @@ -199,19 +205,22 @@ func (sign Signature) Title() string { "Ogg Vorbis Codec", "Free Lossless Audio Codec", "Wave Audio for Windows", - "PKWARE Zip64", - "PKWARE Zip", - "PKWARE Multi-Volume", - "PKLITE", - "PKSFX", + "Shrunked pkzip archive", + "Reduced pkzip archive", + "Imploded pkzip archive", + "PKWARE zip64 archive", + "Zip archive", + "Zip multi-Volume archive", + "PKLITE compressed executable", + "PKSFX self-extracting archive", "Tape Archive", "Roshal Archive", "Roshal Archive v5", - "Gzip Compress Archive", - "Bzip2 Compress Archive", - "7z Compress Archive", - "XZ Compress Archive", - "ZStandard Archive", + "Gzip compress archive", + "Bzip2 compress archive", + "7z compress archive", + "XZ compress archive", + "ZStandard archive", "FreeArc", "Archive by SEA", "Yoshi LHA", @@ -220,13 +229,13 @@ func (sign Signature) Title() string { "Microsoft Cabinet", "Microsoft DOS KWAJ", "Microsoft DOS SZDD", - "Microsoft Executable", - "Microsoft Compound File", + "Microsoft executable", + "Microsoft compound file", "CD ISO 9660", "CD Nero", "CD PowerISO", "CD Alcohol 120", - "Java Archive", + "Java archive", "Windows Help File", "Portable Document Format", "Rich Text Format", @@ -269,6 +278,9 @@ func Ext() Extension { //nolint:funlen OggVorbisCodec: []string{".ogg"}, FreeLosslessAudioCodec: []string{".flac"}, WaveAudioForWindows: []string{".wav"}, + PKWAREZipShrink: []string{".zip"}, + PKWAREZipReduce: []string{".zip"}, + PKWAREZipImplode: []string{".zip"}, PKWAREZip64: []string{".zip"}, PKWAREZip: []string{".zip"}, PKWAREMultiVolume: []string{".zip"}, @@ -345,6 +357,9 @@ func New() Finder { //nolint:funlen OggVorbisCodec: Ogg, FreeLosslessAudioCodec: Flac, WaveAudioForWindows: Wave, + PKWAREZipShrink: PkShrink, + PKWAREZipReduce: PkReduce, + PKWAREZipImplode: PkImplode, PKWAREZip64: Zip64, PKWAREZip: Pkzip, PKWAREMultiVolume: PkzipMulti, @@ -385,6 +400,9 @@ func New() Finder { //nolint:funlen // Archives returns all the archive file type signatures. func Archives() []Signature { return []Signature{ + PKWAREZipShrink, + PKWAREZipReduce, + PKWAREZipImplode, PKWAREZip64, PKWAREZip, PKWAREMultiVolume, @@ -407,6 +425,22 @@ func Archives() []Signature { } } +// Archives returns all the archive file type signatures that were +// commonly used in the BBS online era of the 1980s and early 1990s. +// Eventually these were replaced by the universal ZIP format using +// the Deflate and Store compression methods. +func ArchivesBBS() []Signature { + return []Signature{ + PKWAREZipShrink, + PKWAREZipReduce, + PKWAREZipImplode, + ARChiveSEA, + YoshiLHA, + ZooArchive, + ArchiveRobertJung, + } +} + // DiscImages returns all the CD disk image file type signatures. func DiscImages() []Signature { return []Signature{ @@ -1036,25 +1070,62 @@ func Zip64(p []byte) bool { return true } -// Pkzip matches the PKWARE Zip archive format in the byte slice. +func Pkzip(p []byte) bool { + return pkzip(p) == pkZip +} + +func PkImplode(p []byte) bool { + return pkzip(p) == pkImplode +} + +func PkReduce(p []byte) bool { + return pkzip(p) == pkReduce +} + +func PkShrink(p []byte) bool { + return pkzip(p) == pkSkrink +} + +type pkComp int + +const ( + pkNone pkComp = iota + pkZip + pkSkrink + pkReduce + pkImplode +) + +// pkzip matches the PKWARE Zip archive format in the byte slice. // This is the most common ZIP format and is widely supported and has been // tested against many discountinued and legacy ZIP methods and packagers. -func Pkzip(p []byte) bool { +// +// Due to the complex history of the ZIP format, 4 possible return values +// maybe returned. +// - pkNone is returned if the file is not a ZIP archive. +// - pkOkay is returned if the file is a ZIP archive, except for the compression methods below. +// - pkSkrink is returned if the ZIP archive uses the PKWARE shrink method, found in PKZIP v0.9. +// - pkReduce is returned if the ZIP archive uses the PKWARE reduction method, found in PKZIP v0.8. +// - pkImplode is returned if the ZIP archive uses the PKWARE implode method, found in PKZIP v1.01. +// +// Compression methods Shrink, Reduce and Implode are legacy and are generally +// not supported in modern ZIP tools and libraries. +func pkzip(p []byte) pkComp { const min = 30 if len(p) < min { - return false + return pkNone } // local file header signature 4 bytes (0x04034b50) localFileHeader := []byte{'P', 'K', 0x3, 0x4} if !bytes.Equal(p[:4], localFileHeader) { - return false // 50 4b 03 04 + return pkNone // 50 4b 03 04 } // version needed to extract 2 bytes versionNeeded := p[4] + p[5] if versionNeeded == 0 { // legacy versions of PKZIP returned either 0x.0a (10) or 0x14 (20). - return false // 0a 00 + return pkNone // 0a 00 } // general purpose bit flag 2 bytes // skip this as there's too many reserved values that might cause false positive rejections @@ -1086,11 +1157,18 @@ func Pkzip(p []byte) bool { ae = 0x63 ) switch compresionMethod { - case store, shrink, reduce1, reduce2, reduce3, reduce4, implode, deflate, deflate64, - ibmTerse, bzip2, lzma, ibmCMPSC, ibmTerseNew, ibmLZ77z, zstd, mp3, xz, jpeg, wavPack, ppmd, ae: - return true + case store, deflate, deflate64: + return pkZip + case shrink: + return pkSkrink + case reduce1, reduce2, reduce3, reduce4: + return pkReduce + case implode: + return pkImplode + case ibmTerse, bzip2, lzma, ibmCMPSC, ibmTerseNew, ibmLZ77z, zstd, mp3, xz, jpeg, wavPack, ppmd, ae: + return pkZip default: - return false + return pkNone } } diff --git a/internal/render/render.go b/internal/render/render.go index 4f523067..abe7516d 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -122,7 +122,7 @@ func Viewer(art *models.File) bool { // NoScreenshot returns true when the file entry should not attempt to display a screenshot. // This is based on the platform, section or if the screenshot is missing on the server. -func NoScreenshot(art *models.File, path string) bool { +func NoScreenshot(art *models.File, downloadPath, previewPath string) bool { if art == nil { return true } @@ -132,8 +132,8 @@ func NoScreenshot(art *models.File, path string) bool { return true } unid := art.UUID.String - webp := strings.Join([]string{path, unid + ".webp"}, "/") - png := strings.Join([]string{path, unid + ".png"}, "/") + webp := strings.Join([]string{previewPath, unid + ".webp"}, "/") + png := strings.Join([]string{previewPath, unid + ".png"}, "/") if helper.Stat(webp) || helper.Stat(png) { return false } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index f52413cd..5383cb86 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -162,12 +162,12 @@ func TestViewer(t *testing.T) { func TestNoScreenshot(t *testing.T) { t.Parallel() var art models.File - assert.True(t, render.NoScreenshot(nil, "")) + assert.True(t, render.NoScreenshot(nil, "", "")) art = models.File{} - assert.True(t, render.NoScreenshot(&art, "")) + assert.True(t, render.NoScreenshot(&art, "", "")) art = models.File{} art.Platform = null.StringFrom("textamiga") - assert.True(t, render.NoScreenshot(&art, "")) + assert.True(t, render.NoScreenshot(&art, "", "")) const unid = "5b4c5f6e-8a1e-11e9-9f0e-000000000000" art.Platform = null.StringFrom("") @@ -176,5 +176,5 @@ func TestNoScreenshot(t *testing.T) { err := helper.Touch(name) require.NoError(t, err) defer os.Remove(name) - assert.False(t, render.NoScreenshot(&art, os.TempDir())) + assert.False(t, render.NoScreenshot(&art, "", os.TempDir())) } diff --git a/public/css/layout.min.css b/public/css/layout.min.css index a099fb9a..325a7c49 100644 --- a/public/css/layout.min.css +++ b/public/css/layout.min.css @@ -1,2 +1,2 @@ /* layout.min.css © Defacto2 2024 */ -.bi:before{display:inline-block;content:"";vertical-align:-.125em;background-image:url("data:image/svg+xml,");background-repeat:no-repeat;background-size:1rem 1rem}.bi-discord{color:#5865f2}.bi-envelope{color:#d44638}.bi-facebook{color:#3b5998}.bi-mastodon{color:#5a4fdb}.bi-twitter{color:#1da1f2}.bi-youtube{color:#cd201f}@font-face{font-family:font-cascadia-mono;font-display:swap;src:url(/font/CascadiaMono.woff2) format("woff2"),url(/font/CascadiaMono.woff) format("woff"),url(/font/CascadiaMono.ttf) format("truetype")}.font-cascadia-mono{font-family:font-cascadia-mono,sans-serif;font-size:1em;font-style:normal;font-weight:400}@font-face{font-family:font-vga-eight;font-display:swap;src:url(/font/pxplus_ibm_vga8.woff2) format("woff2"),url(/font/pxplus_ibm_vga8.woff) format("woff"),url(/font/pxplus_ibm_vga8.ttf) format("truetype")}.font-dos{font-family:font-vga-eight,monospace;font-size:1em;font-style:normal;font-weight:400;line-height:1}@font-face{font-family:font-topaz-two;font-display:swap;src:url(/font/topazplus_a1200.woff2) format("woff2"),url(/font/topazplus_a1200.woff) format("woff"),url(/font/topazplus_a1200.ttf) format("truetype")}.font-amiga{font-family:font-topaz-two,font-vga-eight,monospace;font-size:1em;font-style:normal;font-weight:400}.pre-wrap{white-space:pre-wrap}.reader{font-size:150%;line-height:1;white-space:pre-wrap;hyphens:manual}#logo-container{container-type:inline-size;container-name:cardcontainer}#logo-text{overflow:hidden}.reader-invert{background-color:#000;color:#fff}@container cardcontainer (width > 575px){pre{font-size:100%}}@container cardcontainer (width < 576px){pre{font-size:.75rem}}@container cardcontainer (width < 422px){pre{font-size:.5rem}}.input-date::placeholder{opacity:.3;color:red}canvas,.dosbox-container{width:100%;height:100%;margin:0;padding:0;overflow:hidden} +.bi:before{display:inline-block;content:"";vertical-align:-.125em;background-image:url("data:image/svg+xml,");background-repeat:no-repeat;background-size:1rem 1rem}.bi-discord{color:#5865f2}.bi-envelope{color:#d44638}.bi-facebook{color:#3b5998}.bi-mastodon{color:#5a4fdb}.bi-twitter{color:#1da1f2}.bi-youtube{color:#cd201f}@font-face{font-family:font-cascadia-mono;font-display:swap;src:url(/font/CascadiaMono.woff2) format("woff2"),url(/font/CascadiaMono.woff) format("woff"),url(/font/CascadiaMono.ttf) format("truetype")}.font-cascadia-mono{font-family:font-cascadia-mono,sans-serif;font-size:1em;font-style:normal;font-weight:400}@font-face{font-family:font-vga-eight;font-display:swap;src:url(/font/pxplus_ibm_vga8.woff2) format("woff2"),url(/font/pxplus_ibm_vga8.woff) format("woff"),url(/font/pxplus_ibm_vga8.ttf) format("truetype")}.font-dos{font-family:font-vga-eight,monospace;font-size:1em;font-style:normal;font-weight:400;line-height:1}@font-face{font-family:font-topaz-two;font-display:swap;src:url(/font/topazplus_a1200.woff2) format("woff2"),url(/font/topazplus_a1200.woff) format("woff"),url(/font/topazplus_a1200.ttf) format("truetype")}.font-amiga{font-family:font-topaz-two,font-vga-eight,monospace;font-size:1em;font-style:normal;font-weight:400}.pre-wrap{white-space:pre-wrap}.reader{font-size:133%;line-height:1;white-space:pre-wrap;hyphens:manual}#logo-container{container-type:inline-size;container-name:cardcontainer}#logo-text{overflow:hidden}.reader-invert{background-color:#000;color:#fff}@container cardcontainer (width > 575px){pre{font-size:100%}}@container cardcontainer (width < 576px){pre{font-size:.75rem}}@container cardcontainer (width < 422px){pre{font-size:.5rem}}.input-date::placeholder{opacity:.3;color:red}canvas,.dosbox-container{width:100%;height:100%;margin:0;padding:0;overflow:hidden} diff --git a/public/svg/bootstrap-icons.svg b/public/svg/bootstrap-icons.svg index 37d29b23..911628d4 100644 --- a/public/svg/bootstrap-icons.svg +++ b/public/svg/bootstrap-icons.svg @@ -117,4 +117,10 @@ + + + + \ No newline at end of file diff --git a/view/app/artifact.tmpl b/view/app/artifact.tmpl index 99f078c4..bd7947b3 100644 --- a/view/app/artifact.tmpl +++ b/view/app/artifact.tmpl @@ -40,7 +40,7 @@ {{- template "artifactlock" . }}
{{- /* Lead including published, platform and section */}} -

{{$published}}, {{$classification}}

+

{{$published}}, {{$classification}}

{{- /* Table of details */}} {{- template "artifactinfo" . }}
@@ -68,6 +68,16 @@ {{- end}} + {{/* TODO: + + */}} {{- if or ($readmeL) ($readmeC)}}
{{- if not $screenNone}} diff --git a/view/app/artifactfile.tmpl b/view/app/artifactfile.tmpl index 7976c9d6..7793ff63 100644 --- a/view/app/artifactfile.tmpl +++ b/view/app/artifactfile.tmpl @@ -8,8 +8,11 @@ {{- $id := index . "modID"}} {{- $unid := index . "unid"}} {{- $filename := index . "filename"}} +{{- $dbMod := index . "modDBModify"}} {{- $statMod := index . "modStatModify"}} -{{- $statSize := index . "modStatSize"}} +{{- $statSizeB := index . "modStatSizeB"}} +{{- $statSizeFmt := index . "modStatSizeF"}} +{{- $magicMIME := index . "modMagicMime"}} {{- $magicNumber := index . "modMagicNumber"}} {{- $absDownload := index . "modAbsDownload"}} {{- $previewImg := recordImgSample $unid}} @@ -45,6 +48,12 @@
+ {{/* + id="asset-editor-delete-text" + id="asset-editor-delete-images" + +
+ */}} {{radioHidden ($record)}}
@@ -79,48 +88,58 @@
- - + +
- - + +
- + +
- +
-
- +
+
Normally not required, upload and replace the artifact download.
Download content + {{- if eq "" (index . "modZipContent")}} +
+ +
+ {{- else}}
+
{{$modArchiveContent}}
+ {{- end}}
Image assets
- {{$previewImg}}

- Preview image assets.
+ Preview assets
+

These are displayed when viewing the artifact page
{{- range $name, $info := .modAssetPreview}} @@ -131,16 +150,21 @@ {{- end}}
{{index $info 1}}
+ + + +

+ {{$previewImg}}
- {{$thumbImg}}

- Thumbnail image assets.
+ Thumbnail assets
+

These are displayed when listing multiple artifacts on a page
{{- range $name, $info := .modAssetThumbnail}} @@ -151,21 +175,31 @@ {{- end}}
{{index $info 1}}
+ + + +

+ {{$thumbImg}}
- +
- +
+ {{/* + id="edUploadPreviewReset" + id="edUploadPreviewBtn" + id="asset-editor-preview" + */}}
Select an image, text or ansi file to use as the image asset samples.
Extras assets - TODO: add MS-DOS only .exe button for use with emulation and show form value
@@ -196,6 +230,23 @@
{{recordReadme (index . "modNoReadme") }} +
disable the use of the textfile content
+
Saved, {{$replace}}
+
error placeholder
+
+

+
+
+
+
+
+
+

+ Repacked ZIP +

+ {{recordReadme (index . "modNoReadme") }} + +
feature not used, download is not an obsolete archive
Saved, {{$replace}}
error placeholder
@@ -205,65 +256,10 @@
-
- - - + + {{/* {{ template "artifactzip" . }} -
- {{- /* Preview from upload */}} -
- -
- -
Download artifact replaced, {{$replace}}
-
-
- -
-
- -
-
-
- {{- /* Download artifact */}} -
- -
- -
Download artifact replaced, {{$replace}}
-
-
- -
-
- -
-
-
- {{- /* Delete complementary images */}} - {{- /* Delete readme asset */}} -
-
- {{- /* */}} -
-
- - -
- -
- {{- /* Preview and Thumbnail images */}} -
-
{{$previewImg}}
Preview
-
{{$thumbImg}}
Thumbnail
-
-
+ */}} {{/* Switch to assets and reset buttons */}} {{- template "artifactfooter" . }} diff --git a/view/app/artifactinfo.tmpl b/view/app/artifactinfo.tmpl index ae568d38..28feafcd 100644 --- a/view/app/artifactinfo.tmpl +++ b/view/app/artifactinfo.tmpl @@ -156,7 +156,7 @@ {{- if ne $mimetype ""}} - Mime type + Mime or file type {{$mimetype}} {{- end}}