CRC32: Move checksum generation to a dedicated package
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
2df0b6e4b1
commit
01da5bdec7
19 changed files with 379 additions and 42 deletions
|
@ -47,7 +47,7 @@ var AbortNotFound = func(c *gin.Context) {
|
|||
}
|
||||
|
||||
values := gin.H{
|
||||
"signUp": gin.H{"message": config.MsgSponsor, "url": config.SignUpURL},
|
||||
"signUp": config.SignUp,
|
||||
"config": conf.ClientPublic(),
|
||||
"error": i18n.Msg(i18n.ErrNotFound),
|
||||
"code": http.StatusNotFound,
|
||||
|
|
|
@ -7,7 +7,6 @@ import (
|
|||
"github.com/manifoldco/promptui"
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/internal/query"
|
||||
|
@ -45,11 +44,13 @@ func placesUpdateAction(ctx *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Show info to non-members.
|
||||
if !conf.Sponsor() && !conf.Test() {
|
||||
log.Errorf(config.MsgSponsorCommand)
|
||||
log.Errorf("Since running this command puts additional load on our infrastructure, we can unfortunately only offer it to members at this time.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize database connection.
|
||||
conf.InitDb()
|
||||
defer conf.Shutdown()
|
||||
|
||||
|
|
|
@ -1,10 +1,32 @@
|
|||
/*
|
||||
Package config provides global options, command-line flags, and user settings.
|
||||
|
||||
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
@ -34,16 +56,14 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/mutex"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/internal/ttl"
|
||||
"github.com/photoprism/photoprism/pkg/checksum"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// log points to the global logger.
|
||||
var log = event.Log
|
||||
var once sync.Once
|
||||
var LowMem = false
|
||||
var TotalMem uint64
|
||||
var crc32Castagnoli = crc32.MakeTable(crc32.Castagnoli)
|
||||
|
||||
// Config holds database, cache and all parameters of photoprism
|
||||
type Config struct {
|
||||
|
@ -357,15 +377,7 @@ func (c *Config) Serial() string {
|
|||
|
||||
// SerialChecksum returns the CRC32 checksum of the storage serial.
|
||||
func (c *Config) SerialChecksum() string {
|
||||
var result []byte
|
||||
|
||||
crc := crc32.New(crc32Castagnoli)
|
||||
|
||||
if _, err := crc.Write([]byte(c.Serial())); err != nil {
|
||||
log.Warnf("config: %s", err)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(crc.Sum(result))
|
||||
return checksum.Serial([]byte(c.Serial()))
|
||||
}
|
||||
|
||||
// Name returns the app name.
|
||||
|
@ -402,7 +414,7 @@ func (c *Config) Version() string {
|
|||
|
||||
// VersionChecksum returns the application version checksum.
|
||||
func (c *Config) VersionChecksum() uint32 {
|
||||
return crc32.ChecksumIEEE([]byte(c.Version()))
|
||||
return checksum.Crc32([]byte(c.Version()))
|
||||
}
|
||||
|
||||
// UserAgent returns an HTTP user agent string based on the app config and version.
|
||||
|
|
|
@ -590,11 +590,18 @@ func TestConfig_Serial(t *testing.T) {
|
|||
func TestConfig_SerialChecksum(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
serial := "zr2g80wvjmm1zwzg"
|
||||
expected := "c7dcdb1c"
|
||||
|
||||
c.serial = serial
|
||||
|
||||
result := c.SerialChecksum()
|
||||
|
||||
t.Logf("SerialChecksum: %s", result)
|
||||
// t.Logf("Serial: %s", c.serial)
|
||||
// t.Logf("SerialChecksum: %s", result)
|
||||
|
||||
assert.NotEmpty(t, result)
|
||||
assert.Equal(t, expected, result)
|
||||
}
|
||||
|
||||
func TestConfig_Public(t *testing.T) {
|
||||
|
|
8
internal/config/messages.go
Normal file
8
internal/config/messages.go
Normal file
|
@ -0,0 +1,8 @@
|
|||
package config
|
||||
|
||||
var (
|
||||
SignUpURL = "https://www.photoprism.app/membership"
|
||||
MsgSponsor = "Become a member today, support our mission and enjoy our member benefits! 💎"
|
||||
MsgSignUp = "Visit " + SignUpURL + " to learn more."
|
||||
SignUp = Values{"message": MsgSponsor, "url": SignUpURL}
|
||||
)
|
9
internal/config/var.go
Normal file
9
internal/config/var.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
var once sync.Once
|
||||
var LowMem = false
|
||||
var TotalMem uint64
|
|
@ -22,7 +22,7 @@ func registerPWARoutes(router *gin.Engine, conf *config.Config) {
|
|||
}
|
||||
|
||||
values := gin.H{
|
||||
"signUp": gin.H{"message": config.MsgSponsor, "url": config.SignUpURL},
|
||||
"signUp": config.SignUp,
|
||||
"config": conf.ClientPublic(),
|
||||
}
|
||||
c.HTML(http.StatusOK, conf.TemplateName(), values)
|
||||
|
|
16
pkg/checksum/charset.go
Normal file
16
pkg/checksum/charset.go
Normal file
|
@ -0,0 +1,16 @@
|
|||
package checksum
|
||||
|
||||
const (
|
||||
CharsetBase36 = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
CharsetBase62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
)
|
||||
|
||||
// Char returns a simple checksum byte based on Crc32 and the 62 characters specified in CharsetBase62.
|
||||
func Char(data []byte) byte {
|
||||
return CharsetBase62[Crc32(data)%62]
|
||||
}
|
||||
|
||||
// Base36 returns a simple checksum byte based on Crc32 and the 36 lower-case characters specified in CharsetBase36.
|
||||
func Base36(data []byte) byte {
|
||||
return CharsetBase36[Crc32(data)%36]
|
||||
}
|
93
pkg/checksum/charset_test.go
Normal file
93
pkg/checksum/charset_test.go
Normal file
|
@ -0,0 +1,93 @@
|
|||
package checksum
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestChar(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
data := ""
|
||||
expected := "a"
|
||||
|
||||
result := string(Char([]byte(data)))
|
||||
|
||||
t.Logf("Char(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("One", func(t *testing.T) {
|
||||
data := "1"
|
||||
expected := "R"
|
||||
|
||||
result := string(Char([]byte(data)))
|
||||
|
||||
t.Logf("Char(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("Serial", func(t *testing.T) {
|
||||
data := "zr2g80wvjmm1zwzg"
|
||||
expected := "G"
|
||||
|
||||
result := string(Char([]byte(data)))
|
||||
|
||||
t.Logf("Char(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("HelloWorld", func(t *testing.T) {
|
||||
data := "Hello World!"
|
||||
expected := "X"
|
||||
|
||||
result := string(Char([]byte(data)))
|
||||
|
||||
t.Logf("Char(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBase36(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
data := ""
|
||||
expected := "a"
|
||||
|
||||
result := string(Base36([]byte(data)))
|
||||
|
||||
t.Logf("Base36(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("One", func(t *testing.T) {
|
||||
data := "1"
|
||||
expected := "l"
|
||||
|
||||
result := string(Base36([]byte(data)))
|
||||
|
||||
t.Logf("Base36(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("Serial", func(t *testing.T) {
|
||||
data := "zr2g80wvjmm1zwzg"
|
||||
expected := "u"
|
||||
|
||||
result := string(Base36([]byte(data)))
|
||||
|
||||
t.Logf("Base36(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("HelloWorld", func(t *testing.T) {
|
||||
data := "Hello World!"
|
||||
expected := "x"
|
||||
|
||||
result := string(Base36([]byte(data)))
|
||||
|
||||
t.Logf("Base36(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Package config provides global options, command-line flags, and user settings.
|
||||
Package checksum provides functions and abstractions to generate data checksums.
|
||||
|
||||
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
|
||||
|
||||
|
@ -22,10 +22,4 @@ want to support our work, or just want to say hello.
|
|||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package config
|
||||
|
||||
var SignUpURL = "https://www.photoprism.app/membership"
|
||||
var MsgSponsor = "Become a member today, support our mission and enjoy our member benefits! 💎"
|
||||
var MsgSignUp = "Visit " + SignUpURL + " to learn more."
|
||||
var MsgSponsorCommand = "Since running this command puts additional load on our infrastructure," +
|
||||
" we unfortunately can only offer it to members."
|
||||
package checksum
|
19
pkg/checksum/crc32.go
Normal file
19
pkg/checksum/crc32.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package checksum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
)
|
||||
|
||||
var Crc32Castagnoli = crc32.MakeTable(crc32.Castagnoli)
|
||||
|
||||
// Crc32 returns the CRC-32 checksum of data using the crc32.IEEE polynomial.
|
||||
func Crc32(data []byte) uint32 {
|
||||
return crc32.ChecksumIEEE(data)
|
||||
}
|
||||
|
||||
// Serial returns the CRC-32 checksum as a hexadecimal encoded string using the Castagnoli polynomial,
|
||||
// which has better error detection properties than crc32.IEEE.
|
||||
func Serial(data []byte) string {
|
||||
return fmt.Sprintf("%08x", crc32.Checksum(data, Crc32Castagnoli))
|
||||
}
|
98
pkg/checksum/crc32_test.go
Normal file
98
pkg/checksum/crc32_test.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
package checksum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCrc32(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
data := ""
|
||||
expected := "00000000"
|
||||
|
||||
crc := Crc32([]byte(data))
|
||||
result := fmt.Sprintf("%08x", crc)
|
||||
|
||||
t.Logf("Crc32(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("One", func(t *testing.T) {
|
||||
data := "1"
|
||||
expected := "83dcefb7"
|
||||
|
||||
crc := Crc32([]byte(data))
|
||||
result := fmt.Sprintf("%08x", crc)
|
||||
|
||||
t.Logf("Crc32(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("Serial", func(t *testing.T) {
|
||||
data := "zr2g80wvjmm1zwzg"
|
||||
expected := "2db41d54"
|
||||
|
||||
crc := Crc32([]byte(data))
|
||||
result := fmt.Sprintf("%08x", crc)
|
||||
|
||||
t.Logf("Crc32(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("HelloWorld", func(t *testing.T) {
|
||||
data := "Hello World!"
|
||||
expected := "1c291ca3"
|
||||
|
||||
crc := Crc32([]byte(data))
|
||||
result := fmt.Sprintf("%08x", crc)
|
||||
|
||||
t.Logf("Crc32(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSerial(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
data := ""
|
||||
expected := "00000000"
|
||||
|
||||
result := Serial([]byte(data))
|
||||
|
||||
t.Logf("Serial(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("One", func(t *testing.T) {
|
||||
data := "1"
|
||||
expected := "90f599e3"
|
||||
|
||||
result := Serial([]byte(data))
|
||||
|
||||
t.Logf("Serial(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("Serial", func(t *testing.T) {
|
||||
data := "zr2g80wvjmm1zwzg"
|
||||
expected := "c7dcdb1c"
|
||||
|
||||
result := Serial([]byte(data))
|
||||
|
||||
t.Logf("Serial(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
t.Run("HelloWorld", func(t *testing.T) {
|
||||
data := "Hello World!"
|
||||
expected := "fe6cf1dc"
|
||||
|
||||
result := Serial([]byte(data))
|
||||
|
||||
t.Logf("Serial(%s): result %s, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
}
|
6
pkg/checksum/digit.go
Normal file
6
pkg/checksum/digit.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package checksum
|
||||
|
||||
// Digit returns a Crc32-based checksum number between 0 and 9.
|
||||
func Digit(data []byte) int {
|
||||
return int(Crc32(data) % 10)
|
||||
}
|
70
pkg/checksum/digit_test.go
Normal file
70
pkg/checksum/digit_test.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package checksum
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDigit(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
data := ""
|
||||
expected := "0"
|
||||
|
||||
result := Digit([]byte(data))
|
||||
|
||||
t.Logf("Digit(%s): result %d, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, fmt.Sprintf("%d", result))
|
||||
})
|
||||
t.Run("One", func(t *testing.T) {
|
||||
data := "1"
|
||||
expected := "3"
|
||||
|
||||
result := Digit([]byte(data))
|
||||
|
||||
t.Logf("Digit(%s): result %d, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, fmt.Sprintf("%d", result))
|
||||
})
|
||||
t.Run("Serial", func(t *testing.T) {
|
||||
data := "zr2g80wvjmm1zwzg"
|
||||
expected := "8"
|
||||
|
||||
result := Digit([]byte(data))
|
||||
|
||||
t.Logf("Digit(%s): result %d, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, fmt.Sprintf("%d", result))
|
||||
})
|
||||
t.Run("HelloWorld", func(t *testing.T) {
|
||||
data := "Hello World!"
|
||||
expected := "5"
|
||||
|
||||
result := Digit([]byte(data))
|
||||
|
||||
t.Logf("Digit(%s): result %d, expected %s", data, result, expected)
|
||||
|
||||
assert.Equal(t, expected, fmt.Sprintf("%d", result))
|
||||
})
|
||||
t.Run("Rand", func(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
b := make([]byte, 24)
|
||||
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
result := Digit(b)
|
||||
|
||||
if result < 0 {
|
||||
t.Fatal("result < 0")
|
||||
} else if result > 9 {
|
||||
t.Fatal("result > 9")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -6,6 +6,8 @@ import (
|
|||
"hash/crc32"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/checksum"
|
||||
)
|
||||
|
||||
// Hash returns the SHA1 hash of a file as string.
|
||||
|
@ -41,7 +43,7 @@ func Checksum(fileName string) string {
|
|||
|
||||
defer file.Close()
|
||||
|
||||
hash := crc32.New(crc32.MakeTable(crc32.Castagnoli))
|
||||
hash := crc32.New(checksum.Crc32Castagnoli)
|
||||
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return ""
|
||||
|
|
|
@ -3,9 +3,10 @@ package rnd
|
|||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"log"
|
||||
"math/big"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/checksum"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -50,7 +51,7 @@ func AppPassword() string {
|
|||
if (i+1)%7 == 0 {
|
||||
b = append(b, AppPasswordSeparator)
|
||||
} else if i == AppPasswordLength-1 {
|
||||
b = append(b, CharsetBase62[crc32.ChecksumIEEE(b)%62])
|
||||
b = append(b, checksum.Char(b))
|
||||
return string(b)
|
||||
} else if r, err := rand.Int(rand.Reader, m); err == nil {
|
||||
b = append(b, CharsetBase62[r.Int64()])
|
||||
|
@ -85,7 +86,7 @@ func IsAppPassword(s string, verifyChecksum bool) bool {
|
|||
}
|
||||
|
||||
// Verify token checksum.
|
||||
return s[AppPasswordLength-1] == CharsetBase62[crc32.ChecksumIEEE([]byte(s[:AppPasswordLength-1]))%62]
|
||||
return s[AppPasswordLength-1] == checksum.Char([]byte(s[:AppPasswordLength-1]))
|
||||
}
|
||||
|
||||
// IsAuthAny checks if the string might be a valid auth token or app password.
|
||||
|
|
|
@ -2,8 +2,9 @@ package rnd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"strconv"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/checksum"
|
||||
)
|
||||
|
||||
// CrcToken returns a string token with checksum.
|
||||
|
@ -14,9 +15,8 @@ func CrcToken() string {
|
|||
token = append(token, '-')
|
||||
token = append(token, []byte(Base36(4))...)
|
||||
|
||||
checksum := crc32.ChecksumIEEE(token)
|
||||
|
||||
sum := strconv.FormatInt(int64(checksum), 16)
|
||||
crc := checksum.Crc32(token)
|
||||
sum := strconv.FormatInt(int64(crc), 16)
|
||||
|
||||
return fmt.Sprintf("%s-%.4s", token, sum)
|
||||
}
|
||||
|
@ -29,9 +29,8 @@ func ValidateCrcToken(s string) bool {
|
|||
|
||||
token := []byte(s[:9])
|
||||
|
||||
checksum := crc32.ChecksumIEEE(token)
|
||||
|
||||
sum := strconv.FormatInt(int64(checksum), 16)
|
||||
crc := checksum.Crc32(token)
|
||||
sum := strconv.FormatInt(int64(crc), 16)
|
||||
|
||||
return s == fmt.Sprintf("%s-%.4s", token, sum)
|
||||
}
|
||||
|
|
|
@ -3,10 +3,12 @@ package rnd
|
|||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/checksum"
|
||||
)
|
||||
|
||||
const CharsetBase36 = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const CharsetBase62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const CharsetBase36 = checksum.CharsetBase36
|
||||
const CharsetBase62 = checksum.CharsetBase62
|
||||
|
||||
// Base36 generates a random token containing lowercase letters and numbers.
|
||||
func Base36(length int) string {
|
||||
|
|
Loading…
Reference in a new issue