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 @@
-
-
+
+
-
-
+
+
-
+
+
-
+
-
- {{$previewImg}}
- Preview image assets.
+ Preview assets
+
These are displayed when viewing the artifact page
{{- range $name, $info := .modAssetPreview}}
@@ -131,16 +150,21 @@
{{index $info 1}} |
{{- end}}
+
+
+
+
+ {{$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 @@
{{index $info 1}} |
{{- end}}
+
+
+
+
+ {{$thumbImg}}
-
+
-
+
+ {{/*
+ id="edUploadPreviewReset"
+ id="edUploadPreviewBtn"
+ id="asset-editor-preview"
+ */}}
Select an image, text or ansi file to use as the image asset samples.
-
TODO: add MS-DOS only .exe button for use with emulation and show form value
+
+
+
+
+ Repacked ZIP
+
@@ -205,65 +256,10 @@
-
-
-
- This tab is intentionally not working and is due for a redesign.
-
-
+
+ {{/*
{{ 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 */}}
-
-
+ */}}
{{/* 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}}