diff options
Diffstat (limited to 'modules')
-rw-r--r-- | modules/auth/user_form.go | 1 | ||||
-rw-r--r-- | modules/base/tool.go | 13 | ||||
-rw-r--r-- | modules/lfs/LICENSE | 20 | ||||
-rw-r--r-- | modules/lfs/content_store.go | 94 | ||||
-rw-r--r-- | modules/lfs/server.go | 549 | ||||
-rw-r--r-- | modules/setting/setting.go | 90 |
6 files changed, 767 insertions, 0 deletions
diff --git a/modules/auth/user_form.go b/modules/auth/user_form.go index 0bdd7c1532..f020f365d2 100644 --- a/modules/auth/user_form.go +++ b/modules/auth/user_form.go @@ -23,6 +23,7 @@ type InstallForm struct { AppName string `binding:"Required" locale:"install.app_name"` RepoRootPath string `binding:"Required"` + LFSRootPath string RunUser string `binding:"Required"` Domain string `binding:"Required"` SSHPort int diff --git a/modules/base/tool.go b/modules/base/tool.go index cb2eafd309..f4249f6d6d 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -12,6 +12,7 @@ import ( "encoding/hex" "fmt" "html/template" + "io" "math" "math/big" "net/http" @@ -103,6 +104,18 @@ func GetRandomString(n int) (string, error) { return string(buffer), nil } +// GetRandomBytesAsBase64 generates a random base64 string from n bytes +func GetRandomBytesAsBase64(n int) string { + bytes := make([]byte, 32) + _, err := io.ReadFull(rand.Reader, bytes) + + if err != nil { + log.Fatal(4, "Error reading random bytes: %s", err) + } + + return base64.RawURLEncoding.EncodeToString(bytes) +} + func randomInt(max *big.Int) (int, error) { rand, err := rand.Int(rand.Reader, max) if err != nil { diff --git a/modules/lfs/LICENSE b/modules/lfs/LICENSE new file mode 100644 index 0000000000..0a94a80c8c --- /dev/null +++ b/modules/lfs/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 The Gitea Authors +Copyright (c) GitHub, Inc. and LFS Test Server contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modules/lfs/content_store.go b/modules/lfs/content_store.go new file mode 100644 index 0000000000..2ca44512f1 --- /dev/null +++ b/modules/lfs/content_store.go @@ -0,0 +1,94 @@ +package lfs + +import ( + "code.gitea.io/gitea/models" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" +) + +var ( + errHashMismatch = errors.New("Content hash does not match OID") + errSizeMismatch = errors.New("Content size does not match") +) + +// ContentStore provides a simple file system based storage. +type ContentStore struct { + BasePath string +} + +// Get takes a Meta object and retreives the content from the store, returning +// it as an io.Reader. If fromByte > 0, the reader starts from that byte +func (s *ContentStore) Get(meta *models.LFSMetaObject, fromByte int64) (io.ReadCloser, error) { + path := filepath.Join(s.BasePath, transformKey(meta.Oid)) + + f, err := os.Open(path) + if err != nil { + return nil, err + } + if fromByte > 0 { + _, err = f.Seek(fromByte, os.SEEK_CUR) + } + return f, err +} + +// Put takes a Meta object and an io.Reader and writes the content to the store. +func (s *ContentStore) Put(meta *models.LFSMetaObject, r io.Reader) error { + path := filepath.Join(s.BasePath, transformKey(meta.Oid)) + tmpPath := path + ".tmp" + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0750); err != nil { + return err + } + + file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0640) + if err != nil { + return err + } + defer os.Remove(tmpPath) + + hash := sha256.New() + hw := io.MultiWriter(hash, file) + + written, err := io.Copy(hw, r) + if err != nil { + file.Close() + return err + } + file.Close() + + if written != meta.Size { + return errSizeMismatch + } + + shaStr := hex.EncodeToString(hash.Sum(nil)) + if shaStr != meta.Oid { + return errHashMismatch + } + + if err := os.Rename(tmpPath, path); err != nil { + return err + } + return nil +} + +// Exists returns true if the object exists in the content store. +func (s *ContentStore) Exists(meta *models.LFSMetaObject) bool { + path := filepath.Join(s.BasePath, transformKey(meta.Oid)) + if _, err := os.Stat(path); os.IsNotExist(err) { + return false + } + return true +} + +func transformKey(key string) string { + if len(key) < 5 { + return key + } + + return filepath.Join(key[0:2], key[2:4], key[4:len(key)]) +} diff --git a/modules/lfs/server.go b/modules/lfs/server.go new file mode 100644 index 0000000000..f82cb70364 --- /dev/null +++ b/modules/lfs/server.go @@ -0,0 +1,549 @@ +package lfs + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "github.com/dgrijalva/jwt-go" + "gopkg.in/macaron.v1" +) + +const ( + contentMediaType = "application/vnd.git-lfs" + metaMediaType = contentMediaType + "+json" +) + +// RequestVars contain variables from the HTTP request. Variables from routing, json body decoding, and +// some headers are stored. +type RequestVars struct { + Oid string + Size int64 + User string + Password string + Repo string + Authorization string +} + +// BatchVars contains multiple RequestVars processed in one batch operation. +// https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md +type BatchVars struct { + Transfers []string `json:"transfers,omitempty"` + Operation string `json:"operation"` + Objects []*RequestVars `json:"objects"` +} + +// BatchResponse contains multiple object metadata Representation structures +// for use with the batch API. +type BatchResponse struct { + Transfer string `json:"transfer,omitempty"` + Objects []*Representation `json:"objects"` +} + +// Representation is object medata as seen by clients of the lfs server. +type Representation struct { + Oid string `json:"oid"` + Size int64 `json:"size"` + Actions map[string]*link `json:"actions"` + Error *ObjectError `json:"error,omitempty"` +} + +// ObjectError defines the JSON structure returned to the client in case of an error +type ObjectError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// ObjectLink builds a URL linking to the object. +func (v *RequestVars) ObjectLink() string { + return fmt.Sprintf("%s%s/%s/info/lfs/objects/%s", setting.AppURL, v.User, v.Repo, v.Oid) +} + +// link provides a structure used to build a hypermedia representation of an HTTP link. +type link struct { + Href string `json:"href"` + Header map[string]string `json:"header,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` +} + +// ObjectOidHandler is the main request routing entry point into LFS server functions +func ObjectOidHandler(ctx *context.Context) { + + if !setting.LFS.StartServer { + writeStatus(ctx, 404) + return + } + + if ctx.Req.Method == "GET" || ctx.Req.Method == "HEAD" { + if MetaMatcher(ctx.Req) { + GetMetaHandler(ctx) + return + } + if ContentMatcher(ctx.Req) || len(ctx.Params("filename")) > 0 { + GetContentHandler(ctx) + return + } + } else if ctx.Req.Method == "PUT" && ContentMatcher(ctx.Req) { + PutHandler(ctx) + return + } + +} + +// GetContentHandler gets the content from the content store +func GetContentHandler(ctx *context.Context) { + + rv := unpack(ctx) + + meta, err := models.GetLFSMetaObjectByOid(rv.Oid) + if err != nil { + writeStatus(ctx, 404) + return + } + + repository, err := models.GetRepositoryByID(meta.RepositoryID) + + if err != nil { + writeStatus(ctx, 404) + return + } + + if !authenticate(ctx, repository, rv.Authorization, false) { + requireAuth(ctx) + return + } + + // Support resume download using Range header + var fromByte int64 + statusCode := 200 + if rangeHdr := ctx.Req.Header.Get("Range"); rangeHdr != "" { + regex := regexp.MustCompile(`bytes=(\d+)\-.*`) + match := regex.FindStringSubmatch(rangeHdr) + if match != nil && len(match) > 1 { + statusCode = 206 + fromByte, _ = strconv.ParseInt(match[1], 10, 32) + ctx.Resp.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", fromByte, meta.Size-1, int64(meta.Size)-fromByte)) + } + } + + contentStore := &ContentStore{BasePath: setting.LFS.ContentPath} + content, err := contentStore.Get(meta, fromByte) + if err != nil { + writeStatus(ctx, 404) + return + } + + ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10)) + ctx.Resp.Header().Set("Content-Type", "application/octet-stream") + + filename := ctx.Params("filename") + if len(filename) > 0 { + decodedFilename, err := base64.RawURLEncoding.DecodeString(filename) + if err == nil { + ctx.Resp.Header().Set("Content-Disposition", "attachment; filename=\""+string(decodedFilename)+"\"") + } + } + + ctx.Resp.WriteHeader(statusCode) + io.Copy(ctx.Resp, content) + content.Close() + logRequest(ctx.Req, statusCode) +} + +// GetMetaHandler retrieves metadata about the object +func GetMetaHandler(ctx *context.Context) { + + rv := unpack(ctx) + + meta, err := models.GetLFSMetaObjectByOid(rv.Oid) + if err != nil { + writeStatus(ctx, 404) + return + } + + repository, err := models.GetRepositoryByID(meta.RepositoryID) + + if err != nil { + writeStatus(ctx, 404) + return + } + + if !authenticate(ctx, repository, rv.Authorization, false) { + requireAuth(ctx) + return + } + + ctx.Resp.Header().Set("Content-Type", metaMediaType) + + if ctx.Req.Method == "GET" { + enc := json.NewEncoder(ctx.Resp) + enc.Encode(Represent(rv, meta, true, false)) + } + + logRequest(ctx.Req, 200) +} + +// PostHandler instructs the client how to upload data +func PostHandler(ctx *context.Context) { + + if !setting.LFS.StartServer { + writeStatus(ctx, 404) + return + } + + if !MetaMatcher(ctx.Req) { + writeStatus(ctx, 400) + return + } + + rv := unpack(ctx) + + repositoryString := rv.User + "/" + rv.Repo + repository, err := models.GetRepositoryByRef(repositoryString) + + if err != nil { + log.Debug("Could not find repository: %s - %s", repositoryString, err) + writeStatus(ctx, 404) + return + } + + if !authenticate(ctx, repository, rv.Authorization, true) { + requireAuth(ctx) + } + + meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID}) + + if err != nil { + writeStatus(ctx, 404) + return + } + + ctx.Resp.Header().Set("Content-Type", metaMediaType) + + sentStatus := 202 + contentStore := &ContentStore{BasePath: setting.LFS.ContentPath} + if meta.Existing && contentStore.Exists(meta) { + sentStatus = 200 + } + ctx.Resp.WriteHeader(sentStatus) + + enc := json.NewEncoder(ctx.Resp) + enc.Encode(Represent(rv, meta, meta.Existing, true)) + logRequest(ctx.Req, sentStatus) +} + +// BatchHandler provides the batch api +func BatchHandler(ctx *context.Context) { + + if !setting.LFS.StartServer { + writeStatus(ctx, 404) + return + } + + if !MetaMatcher(ctx.Req) { + writeStatus(ctx, 400) + return + } + + bv := unpackbatch(ctx) + + var responseObjects []*Representation + + // Create a response object + for _, object := range bv.Objects { + + repositoryString := object.User + "/" + object.Repo + repository, err := models.GetRepositoryByRef(repositoryString) + + if err != nil { + log.Debug("Could not find repository: %s - %s", repositoryString, err) + writeStatus(ctx, 404) + return + } + + requireWrite := false + if bv.Operation == "upload" { + requireWrite = true + } + + if !authenticate(ctx, repository, object.Authorization, requireWrite) { + requireAuth(ctx) + return + } + + meta, err := models.GetLFSMetaObjectByOid(object.Oid) + + contentStore := &ContentStore{BasePath: setting.LFS.ContentPath} + if err == nil && contentStore.Exists(meta) { // Object is found and exists + responseObjects = append(responseObjects, Represent(object, meta, true, false)) + continue + } + + // Object is not found + meta, err = models.NewLFSMetaObject(&models.LFSMetaObject{Oid: object.Oid, Size: object.Size, RepositoryID: repository.ID}) + + if err == nil { + responseObjects = append(responseObjects, Represent(object, meta, meta.Existing, true)) + } + } + + ctx.Resp.Header().Set("Content-Type", metaMediaType) + + respobj := &BatchResponse{Objects: responseObjects} + + enc := json.NewEncoder(ctx.Resp) + enc.Encode(respobj) + logRequest(ctx.Req, 200) +} + +// PutHandler receives data from the client and puts it into the content store +func PutHandler(ctx *context.Context) { + rv := unpack(ctx) + + meta, err := models.GetLFSMetaObjectByOid(rv.Oid) + + if err != nil { + writeStatus(ctx, 404) + return + } + + repository, err := models.GetRepositoryByID(meta.RepositoryID) + + if err != nil { + writeStatus(ctx, 404) + return + } + + if !authenticate(ctx, repository, rv.Authorization, true) { + requireAuth(ctx) + return + } + + contentStore := &ContentStore{BasePath: setting.LFS.ContentPath} + if err := contentStore.Put(meta, ctx.Req.Body().ReadCloser()); err != nil { + models.RemoveLFSMetaObjectByOid(rv.Oid) + ctx.Resp.WriteHeader(500) + fmt.Fprintf(ctx.Resp, `{"message":"%s"}`, err) + return + } + + logRequest(ctx.Req, 200) +} + +// Represent takes a RequestVars and Meta and turns it into a Representation suitable +// for json encoding +func Represent(rv *RequestVars, meta *models.LFSMetaObject, download, upload bool) *Representation { + rep := &Representation{ + Oid: meta.Oid, + Size: meta.Size, + Actions: make(map[string]*link), + } + + header := make(map[string]string) + header["Accept"] = contentMediaType + + if rv.Authorization == "" { + //https://github.com/github/git-lfs/issues/1088 + header["Authorization"] = "Authorization: Basic dummy" + } else { + header["Authorization"] = rv.Authorization + } + + if download { + rep.Actions["download"] = &link{Href: rv.ObjectLink(), Header: header} + } + + if upload { + rep.Actions["upload"] = &link{Href: rv.ObjectLink(), Header: header} + } + + return rep +} + +// ContentMatcher provides a mux.MatcherFunc that only allows requests that contain +// an Accept header with the contentMediaType +func ContentMatcher(r macaron.Request) bool { + mediaParts := strings.Split(r.Header.Get("Accept"), ";") + mt := mediaParts[0] + return mt == contentMediaType +} + +// MetaMatcher provides a mux.MatcherFunc that only allows requests that contain +// an Accept header with the metaMediaType +func MetaMatcher(r macaron.Request) bool { + mediaParts := strings.Split(r.Header.Get("Accept"), ";") + mt := mediaParts[0] + return mt == metaMediaType +} + +func unpack(ctx *context.Context) *RequestVars { + r := ctx.Req + rv := &RequestVars{ + User: ctx.Params("username"), + Repo: strings.TrimSuffix(ctx.Params("reponame"), ".git"), + Oid: ctx.Params("oid"), + Authorization: r.Header.Get("Authorization"), + } + + if r.Method == "POST" { // Maybe also check if +json + var p RequestVars + dec := json.NewDecoder(r.Body().ReadCloser()) + err := dec.Decode(&p) + if err != nil { + return rv + } + + rv.Oid = p.Oid + rv.Size = p.Size + } + + return rv +} + +// TODO cheap hack, unify with unpack +func unpackbatch(ctx *context.Context) *BatchVars { + + r := ctx.Req + var bv BatchVars + + dec := json.NewDecoder(r.Body().ReadCloser()) + err := dec.Decode(&bv) + if err != nil { + return &bv + } + + for i := 0; i < len(bv.Objects); i++ { + bv.Objects[i].User = ctx.Params("username") + bv.Objects[i].Repo = strings.TrimSuffix(ctx.Params("reponame"), ".git") + bv.Objects[i].Authorization = r.Header.Get("Authorization") + } + + return &bv +} + +func writeStatus(ctx *context.Context, status int) { + message := http.StatusText(status) + + mediaParts := strings.Split(ctx.Req.Header.Get("Accept"), ";") + mt := mediaParts[0] + if strings.HasSuffix(mt, "+json") { + message = `{"message":"` + message + `"}` + } + + ctx.Resp.WriteHeader(status) + fmt.Fprint(ctx.Resp, message) + logRequest(ctx.Req, status) +} + +func logRequest(r macaron.Request, status int) { + log.Debug("LFS request - Method: %s, URL: %s, Status %d", r.Method, r.URL, status) +} + +// authenticate uses the authorization string to determine whether +// or not to proceed. This server assumes an HTTP Basic auth format. +func authenticate(ctx *context.Context, repository *models.Repository, authorization string, requireWrite bool) bool { + + accessMode := models.AccessModeRead + if requireWrite { + accessMode = models.AccessModeWrite + } + + if !repository.IsPrivate && !requireWrite { + return true + } + + if ctx.IsSigned { + accessCheck, _ := models.HasAccess(ctx.User, repository, accessMode) + return accessCheck + } + + if authorization == "" { + return false + } + + if authenticateToken(repository, authorization, requireWrite) { + return true + } + + if !strings.HasPrefix(authorization, "Basic ") { + return false + } + + c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authorization, "Basic ")) + if err != nil { + return false + } + cs := string(c) + i := strings.IndexByte(cs, ':') + if i < 0 { + return false + } + user, password := cs[:i], cs[i+1:] + + userModel, err := models.GetUserByName(user) + if err != nil { + return false + } + + if !userModel.ValidatePassword(password) { + return false + } + + accessCheck, _ := models.HasAccess(userModel, repository, accessMode) + return accessCheck +} + +func authenticateToken(repository *models.Repository, authorization string, requireWrite bool) bool { + if !strings.HasPrefix(authorization, "Bearer ") { + return false + } + + token, err := jwt.Parse(authorization[7:], func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return setting.LFS.JWTSecretBytes, nil + }) + if err != nil { + return false + } + claims, claimsOk := token.Claims.(jwt.MapClaims) + if !token.Valid || !claimsOk { + return false + } + + opStr, ok := claims["op"].(string) + if !ok { + return false + } + + if requireWrite && opStr != "upload" { + return false + } + + repoID, ok := claims["repo"].(float64) + if !ok { + return false + } + + if repository.ID != int64(repoID) { + return false + } + + return true +} + +func requireAuth(ctx *context.Context) { + ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs") + writeStatus(ctx, 401) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 81fcb4b150..105d33371e 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -5,7 +5,10 @@ package setting import ( + "crypto/rand" + "encoding/base64" "fmt" + "io" "net/mail" "net/url" "os" @@ -17,6 +20,7 @@ import ( "strings" "time" + "code.gitea.io/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/user" "github.com/Unknwon/com" @@ -89,6 +93,13 @@ var ( MinimumKeySizes map[string]int `ini:"-"` } + LFS struct { + StartServer bool `ini:"LFS_START_SERVER"` + ContentPath string `ini:"LFS_CONTENT_PATH"` + JWTSecretBase64 string `ini:"LFS_JWT_SECRET"` + JWTSecretBytes []byte `ini:"-"` + } + // Security settings InstallLock bool SecretKey string @@ -583,6 +594,85 @@ please consider changing to GITEA_CUSTOM`) } } + if err = Cfg.Section("server").MapTo(&LFS); err != nil { + log.Fatal(4, "Fail to map LFS settings: %v", err) + } + + if LFS.StartServer { + + if err := os.MkdirAll(LFS.ContentPath, 0700); err != nil { + log.Fatal(4, "Fail to create '%s': %v", LFS.ContentPath, err) + } + + LFS.JWTSecretBytes = make([]byte, 32) + n, err := base64.RawURLEncoding.Decode(LFS.JWTSecretBytes, []byte(LFS.JWTSecretBase64)) + + if err != nil || n != 32 { + //Generate new secret and save to config + + _, err := io.ReadFull(rand.Reader, LFS.JWTSecretBytes) + + if err != nil { + log.Fatal(4, "Error reading random bytes: %s", err) + } + + LFS.JWTSecretBase64 = base64.RawURLEncoding.EncodeToString(LFS.JWTSecretBytes) + + // Save secret + cfg := ini.Empty() + if com.IsFile(CustomConf) { + // Keeps custom settings if there is already something. + if err := cfg.Append(CustomConf); err != nil { + log.Error(4, "Fail to load custom conf '%s': %v", CustomConf, err) + } + } + + cfg.Section("server").Key("LFS_JWT_SECRET").SetValue(LFS.JWTSecretBase64) + + os.MkdirAll(filepath.Dir(CustomConf), os.ModePerm) + if err := cfg.SaveTo(CustomConf); err != nil { + log.Fatal(4, "Error saving generated JWT Secret to custom config: %v", err) + return + } + } + + //Disable LFS client hooks if installed for the current OS user + //Needs at least git v2.1.2 + + binVersion, err := git.BinVersion() + if err != nil { + log.Fatal(4, "Error retrieving git version: %s", err) + } + + splitVersion := strings.SplitN(binVersion, ".", 3) + + majorVersion, err := strconv.ParseUint(splitVersion[0], 10, 64) + if err != nil { + log.Fatal(4, "Error parsing git major version: %s", err) + } + minorVersion, err := strconv.ParseUint(splitVersion[1], 10, 64) + if err != nil { + log.Fatal(4, "Error parsing git minor version: %s", err) + } + revisionVersion, err := strconv.ParseUint(splitVersion[2], 10, 64) + if err != nil { + log.Fatal(4, "Error parsing git revision version: %s", err) + } + + if !((majorVersion > 2) || (majorVersion == 2 && minorVersion > 1) || + (majorVersion == 2 && minorVersion == 1 && revisionVersion >= 2)) { + + LFS.StartServer = false + log.Error(4, "LFS server support needs at least Git v2.1.2") + + } else { + + git.GlobalCommandArgs = append(git.GlobalCommandArgs, "-c", "filter.lfs.required=", + "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=") + + } + } + sec = Cfg.Section("security") InstallLock = sec.Key("INSTALL_LOCK").MustBool(false) SecretKey = sec.Key("SECRET_KEY").MustString("!#@FDEWREWR&*(") |