diff --git a/handler/app/app.go b/handler/app/app.go index 7743b4d0..0190c2b8 100644 --- a/handler/app/app.go +++ b/handler/app/app.go @@ -1028,30 +1028,6 @@ func Updated(t any, s string) string { } } -// ValidD returns a valid day or a null value. -func ValidD(d int16) null.Int16 { - if d < 1 || d > 31 { - return null.Int16{Int16: 0, Valid: false} - } - return null.Int16{Int16: d, Valid: true} -} - -// ValidM returns a valid month or a null value. -func ValidM(m int16) null.Int16 { - if m < 1 || m > 12 { - return null.Int16{Int16: 0, Valid: false} - } - return null.Int16{Int16: m, Valid: true} -} - -// ValidY returns a valid year or a null value. -func ValidY(y int16) null.Int16 { - if y < 1980 || y > int16(time.Now().Year()) { - return null.Int16{Int16: 0, Valid: false} - } - return null.Int16{Int16: y, Valid: true} -} - // websiteIcon returns a Bootstrap icon name for the given website url. func WebsiteIcon(url string) template.HTML { icon := websiteIcon(url) @@ -1092,9 +1068,9 @@ func YMDEdit(c echo.Context) error { if err != nil { return err } - y := ValidY(f.Year) - m := ValidM(f.Month) - d := ValidD(f.Day) + y := model.ValidY(f.Year) + m := model.ValidM(f.Month) + d := model.ValidD(f.Day) if err = model.UpdateYMD(c, int64(f.ID), y, m, d); err != nil { return badRequest(c, err) } diff --git a/handler/app/dirs.go b/handler/app/dirs.go index c52d1ecf..f48c5017 100644 --- a/handler/app/dirs.go +++ b/handler/app/dirs.go @@ -51,7 +51,7 @@ const ( ) const ( - epoch = 1980 // epoch is the default year for MS-DOS files without a timestamp + epoch = model.EpochYear // epoch is the default year for MS-DOS files without a timestamp ) // Artifact404 renders the error page for the artifact links. diff --git a/model/file.go b/model/file.go index a0833021..8320f4bb 100644 --- a/model/file.go +++ b/model/file.go @@ -7,17 +7,11 @@ import ( "encoding/hex" "errors" "fmt" - "net/url" - "strconv" - "time" - "github.com/Defacto2/server/internal/demozoo" "github.com/Defacto2/server/internal/helper" "github.com/Defacto2/server/internal/postgres" "github.com/Defacto2/server/internal/postgres/models" - "github.com/google/uuid" "github.com/volatiletech/null/v8" - "github.com/volatiletech/sqlboiler/v4/boil" "github.com/volatiletech/sqlboiler/v4/queries/qm" ) @@ -89,32 +83,6 @@ func FindDemozooFile(ctx context.Context, db *sql.DB, id int64) (bool, int64, er return deleted, f.ID, nil } -// InsertDemozooFile inserts a new file record into the database using a Demozoo production ID. -// This will not check if the Demozoo production ID already exists in the database. -// When successful the function will return the new record ID. -func InsertDemozooFile(ctx context.Context, db *sql.DB, id int64) (int64, error) { - if db == nil { - return 0, ErrDB - } - if id < startID || id > demozoo.Sanity { - return 0, fmt.Errorf("%w: %d", ErrID, id) - } - uid, err := uuid.NewV7() - if err != nil { - return 0, err - } - now := time.Now() - f := models.File{ - UUID: null.StringFrom(uid.String()), - WebIDDemozoo: null.Int64From(id), - Deletedat: null.TimeFromPtr(&now), - } - if err = f.Insert(ctx, db, boil.Infer()); err != nil { - return 0, err - } - return f.ID, nil -} - // ExistPouetFile returns true if the file record exists in the database using a Pouet production ID. // This function will also return true for records that have been marked as deleted. func ExistPouetFile(ctx context.Context, db *sql.DB, id int64) (bool, error) { @@ -145,73 +113,6 @@ func FindPouetFile(ctx context.Context, db *sql.DB, id int64) (bool, int64, erro return deleted, f.ID, nil } -// InsertPouetFile inserts a new file record into the database using a Pouet production ID. -// This will not check if the Pouet production ID already exists in the database. -// When successful the function will return the new record ID. -func InsertPouetFile(ctx context.Context, db *sql.DB, id int64) (int64, error) { - if db == nil { - return 0, ErrDB - } - if id < startID || id > demozoo.Sanity { - return 0, fmt.Errorf("%w: %d", ErrID, id) - } - uid, err := uuid.NewV7() - if err != nil { - return 0, err - } - now := time.Now() - f := models.File{ - UUID: null.StringFrom(uid.String()), - WebIDPouet: null.Int64From(id), - Deletedat: null.TimeFromPtr(&now), - } - if err = f.Insert(ctx, db, boil.Infer()); err != nil { - return 0, err - } - return f.ID, nil -} - -func InsertUpload(ctx context.Context, db *sql.DB, values url.Values) (int64, error) { - if db == nil { - return 0, ErrDB - } - uid, err := uuid.NewV7() - if err != nil { - return 0, err - } - now := time.Now() - - y, _ := strconv.ParseInt(values.Get("year"), 10, 16) - year := int16(y) - m, _ := strconv.ParseInt(values.Get("month"), 10, 16) - month := int16(m) - s, _ := strconv.ParseInt(values.Get("size"), 10, 64) - size := int64(s) - - f := models.File{ - UUID: null.StringFrom(uid.String()), - Deletedat: null.TimeFromPtr(&now), - Createdat: null.TimeFromPtr(&now), - WebIDYoutube: null.StringFrom(values.Get("youtube")), // validate - GroupBrandFor: null.StringFrom(values.Get("group")), // validate and format - GroupBrandBy: null.StringFrom(values.Get("brand")), // validate and format - RecordTitle: null.StringFrom(values.Get("title")), // validate and format - DateIssuedYear: null.Int16From(year), - DateIssuedMonth: null.Int16From(month), - Filename: null.StringFrom(values.Get("filename")), // validate - Filesize: size, - FileMagicType: null.StringFrom(values.Get("magic")), // validate - FileIntegrityStrong: null.StringFrom(values.Get("integrity")), // validate - FileLastModified: null.TimeFromPtr(&now), // collect from form and validate - Platform: null.StringFrom(values.Get("platform")), // validate - Section: null.StringFrom(values.Get("section")), // hardcode value and validate - } - if err = f.Insert(ctx, db, boil.Infer()); err != nil { - return 0, err - } - return f.ID, nil -} - // FindObf retrieves a single file record from the database using the obfuscated record key. func FindObf(key string) (*models.File, error) { return recordObf(false, key) diff --git a/model/insert.go b/model/insert.go new file mode 100644 index 00000000..2a657a80 --- /dev/null +++ b/model/insert.go @@ -0,0 +1,256 @@ +package model + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/Defacto2/releaser" + "github.com/Defacto2/server/internal/demozoo" + "github.com/Defacto2/server/internal/postgres/models" + "github.com/google/uuid" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +const ( + shortLimit = 100 + longFilename = 255 +) + +// uuidV7 generates a new UUID version 7, if that fails then it will fallback to version 1. +// It also returns the current time. +func uuidV7() (time.Time, uuid.UUID, error) { + now := time.Now() + uid, err := uuid.NewV7() + if err == nil { + return now, uid, nil + } + uid, err = uuid.NewUUID() + if err != nil { + return now, uuid.Nil, fmt.Errorf("%w: %s", ErrUUID, err) + } + return now, uid, nil +} + +// dateIssue returns a valid year, month and day or a null value. +func dateIssue(y, m, d string) (null.Int16, null.Int16, null.Int16) { + const base, bitSize = 10, 16 + i, _ := strconv.ParseInt(y, base, bitSize) + year := ValidY(int16(i)) + + i, _ = strconv.ParseInt(m, base, bitSize) + month := ValidM(int16(i)) + + i, _ = strconv.ParseInt(d, base, bitSize) + day := ValidD(int16(i)) + + return year, month, day +} + +// ValidD returns a valid day or a null value. +func ValidD(d int16) null.Int16 { + const first, last = 1, 31 + if d < first || d > last { + return null.Int16{Int16: 0, Valid: false} + } + return null.Int16{Int16: d, Valid: true} +} + +// ValidM returns a valid month or a null value. +func ValidM(m int16) null.Int16 { + const jan, dec = 1, 12 + if m < jan || m > dec { + return null.Int16{Int16: 0, Valid: false} + } + return null.Int16{Int16: m, Valid: true} +} + +// ValidY returns a valid year or a null value. +func ValidY(y int16) null.Int16 { + current := int16(time.Now().Year()) + if y < EpochYear || y > current { + return null.Int16{Int16: 0, Valid: false} + } + return null.Int16{Int16: y, Valid: true} +} + +// trimShort returns a string that is no longer than the short limit. +// It will also remove any leading or trailing white space. +func trimShort(s string) string { + s = strings.TrimSpace(s) + if len(s) > shortLimit { + return s[:shortLimit] + } + return s +} + +// ValidReleasers returns two valid releaser group strings or null values. +func ValidReleasers(s1, s2 string) (null.String, null.String) { + invalid := null.String{String: "", Valid: false} + t1, t2 := trimShort(s1), trimShort(s2) + t1, t2 = releaser.Clean(t1), releaser.Clean(t2) + t1, t2 = strings.ToUpper(t1), strings.ToUpper(t2) + x1, x2 := invalid, invalid + if len(t1) > 0 { + x1 = null.StringFrom(s1) + } + if len(t2) > 0 { + x2 = null.StringFrom(s2) + } + return x1, x2 +} + +// ValidTitle returns a valid title or a null value. +// The title is trimmed and shortened to the short limit. +func ValidTitle(s string) null.String { + invalid := null.String{String: "", Valid: false} + t := trimShort(s) + if len(t) == 0 { + return invalid + } + return null.StringFrom(t) +} + +// ValidYouTube returns true if the string is a valid YouTube video ID. +// An error is only returned if the regular expression match cannot compile. +func ValidYouTube(s string) (null.String, error) { + const fixLen = 11 + invalid := null.String{String: "", Valid: false} + if len(s) != fixLen { + return invalid, nil + } + match, err := regexp.MatchString("^[a-zA-Z0-9_-]{11}$", s) + if err != nil { + return invalid, err + } + if !match { + return invalid, nil + } + return null.String{String: s, Valid: true}, nil + +} + +// InsertUpload inserts a new file record into the database using a URL values map. +// This will not check if the file already exists in the database. +// Invalid values will be ignored, but will not prevent the record from being inserted. +// When successful the function will return the new record ID. +func InsertUpload(ctx context.Context, db *sql.DB, values url.Values) (int64, error) { + if db == nil { + return 0, ErrDB + } + + // handle required table fields + now, uid, err := uuidV7() + if err != nil { + return 0, err + } + uniqueID := null.StringFrom(uid.String()) + + delTime := null.TimeFromPtr(&now) + if !delTime.Valid || delTime.Time.IsZero() { + return 0, fmt.Errorf("%w: %v", ErrTime, delTime.Time) + } + + makeTime := null.TimeFromPtr(&now) + if !makeTime.Valid || makeTime.Time.IsZero() { + return 0, fmt.Errorf("%w: %v", ErrTime, makeTime.Time) + } + + fname := null.StringFrom(values.Get("filename")) // validate + + // handle optional table fields + year, month, _ := dateIssue(values.Get("year"), values.Get("month"), "0") + tube, err := ValidYouTube(values.Get("youtube")) + if err != nil { + return 0, err + } + rel1, rel2 := ValidReleasers(values.Get("group"), values.Get("brand")) + title := ValidTitle(values.Get("title")) + + s, _ := strconv.ParseInt(values.Get("size"), 10, 64) + + size := int64(s) + + f := models.File{ + UUID: uniqueID, + Deletedat: delTime, + Createdat: makeTime, + WebIDYoutube: tube, + GroupBrandFor: rel1, + GroupBrandBy: rel2, + RecordTitle: title, + DateIssuedYear: year, + DateIssuedMonth: month, + Filename: fname, + Filesize: size, + FileMagicType: null.StringFrom(values.Get("magic")), // validate + FileIntegrityStrong: null.StringFrom(values.Get("integrity")), // validate + FileLastModified: null.TimeFromPtr(&now), // collect from form and validate + Platform: null.StringFrom(values.Get("platform")), // validate + Section: null.StringFrom(values.Get("section")), // hardcode value and validate + } + if err = f.Insert(ctx, db, boil.Infer()); err != nil { + return 0, err + } + return f.ID, nil +} + +// InsertDemozooFile inserts a new file record into the database using a Demozoo production ID. +// This will not check if the Demozoo production ID already exists in the database. +// When successful the function will return the new record ID. +func InsertDemozooFile(ctx context.Context, db *sql.DB, id int64) (int64, error) { + if db == nil { + return 0, ErrDB + } + if id < startID || id > demozoo.Sanity { + return 0, fmt.Errorf("%w: %d", ErrID, id) + } + + now, uid, err := uuidV7() + if err != nil { + return 0, err + } + + f := models.File{ + UUID: null.StringFrom(uid.String()), + WebIDDemozoo: null.Int64From(id), + Deletedat: null.TimeFromPtr(&now), + } + if err = f.Insert(ctx, db, boil.Infer()); err != nil { + return 0, err + } + return f.ID, nil +} + +// InsertPouetFile inserts a new file record into the database using a Pouet production ID. +// This will not check if the Pouet production ID already exists in the database. +// When successful the function will return the new record ID. +func InsertPouetFile(ctx context.Context, db *sql.DB, id int64) (int64, error) { + if db == nil { + return 0, ErrDB + } + if id < startID || id > demozoo.Sanity { + return 0, fmt.Errorf("%w: %d", ErrID, id) + } + + now, uid, err := uuidV7() + if err != nil { + return 0, err + } + + f := models.File{ + UUID: null.StringFrom(uid.String()), + WebIDPouet: null.Int64From(id), + Deletedat: null.TimeFromPtr(&now), + } + if err = f.Insert(ctx, db, boil.Infer()); err != nil { + return 0, err + } + return f.ID, nil +} diff --git a/model/model.go b/model/model.go index feac5382..2c5196ea 100644 --- a/model/model.go +++ b/model/model.go @@ -31,7 +31,9 @@ var ( ErrPlatform = errors.New("invalid platform") ErrSha384 = errors.New("sha384 value is invalid") ErrTag = errors.New("invalid tag") + ErrTime = errors.New("time value is invalid") ErrURI = errors.New("uri value is invalid") + ErrUUID = errors.New("could not create a new universial unique identifier") ErrYear = errors.New("invalid year") ErrZap = errors.New("zap logger instance is nil") ) @@ -52,6 +54,10 @@ const ( uidPlaceholder = `ADB7C2BF-7221-467B-B813-3636FE4AE16B` // UID of the user who deleted the file. ) +// EpochYear is the epoch year for the website, +// ie. the year 0 of the MS-DOS era. +const EpochYear = 1980 + // Maximum number of files to return per query. const Maximum = 998