package s5 import ( "bytes" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/hex" "errors" "fmt" "git.lumeweb.com/LumeWeb/libs5-go/encoding" s5interfaces "git.lumeweb.com/LumeWeb/libs5-go/interfaces" "git.lumeweb.com/LumeWeb/libs5-go/metadata" s5storage "git.lumeweb.com/LumeWeb/libs5-go/storage" "git.lumeweb.com/LumeWeb/libs5-go/types" "git.lumeweb.com/LumeWeb/portal/db/models" "git.lumeweb.com/LumeWeb/portal/interfaces" "git.lumeweb.com/LumeWeb/portal/protocols" emailverifier "github.com/AfterShip/email-verifier" "github.com/samber/lo" "github.com/vmihailenco/msgpack/v5" "go.sia.tech/jape" "go.uber.org/zap" "io" "math" "mime/multipart" "net/http" "strings" "time" ) const ( errMultiformParse = "Error parsing multipart form" errRetrievingFile = "Error retrieving the file" errReadFile = "Error reading the file" errClosingStream = "Error closing the stream" errUploadingFile = "Error uploading the file" errAccountGenerateChallenge = "Error generating challenge" errAccountRegister = "Error registering account" errAccountLogin = "Error logging in account" errFailedToGetPins = "Failed to get pins" errFailedToDelPin = "Failed to delete pin" errFailedToAddPin = "Failed to add pin" errorNotMultiform = "Not a multipart form" errFetchingUrls = "Error fetching urls" ) var ( errUploadingFileErr = errors.New(errUploadingFile) errAccountGenerateChallengeErr = errors.New(errAccountGenerateChallenge) errAccountRegisterErr = errors.New(errAccountRegister) errInvalidChallengeErr = errors.New("Invalid challenge") errInvalidSignatureErr = errors.New("Invalid signature") errPubkeyNotSupported = errors.New("Only ed25519 keys are supported") errInvalidEmail = errors.New("Invalid email") errEmailAlreadyExists = errors.New("Email already exists") errGeneratingPassword = errors.New("Error generating password") errPubkeyAlreadyExists = errors.New("Pubkey already exists") errPubkeyNotExist = errors.New("Pubkey does not exist") errAccountLoginErr = errors.New(errAccountLogin) errFailedToGetPinsErr = errors.New(errFailedToGetPins) errFailedToDelPinErr = errors.New(errFailedToDelPin) errFailedToAddPinErr = errors.New(errFailedToAddPin) errNotMultiformErr = errors.New(errorNotMultiform) errFetchingUrlsErr = errors.New(errFetchingUrls) ) type HttpHandler struct { portal interfaces.Portal verifier *emailverifier.Verifier } func NewHttpHandler(portal interfaces.Portal) *HttpHandler { verifier := emailverifier.NewVerifier() verifier.DisableSMTPCheck() verifier.DisableGravatarCheck() verifier.DisableDomainSuggest() verifier.DisableAutoUpdateDisposable() return &HttpHandler{ portal: portal, verifier: verifier, } } func (h *HttpHandler) SmallFileUpload(jc jape.Context) { var rs io.ReadSeeker var bufferSize int64 r := jc.Request contentType := r.Header.Get("Content-Type") if strings.HasPrefix(contentType, "multipart/form-data") { // Parse the multipart form err := r.ParseMultipartForm(h.portal.Config().GetInt64("core.post-upload-limit")) if jc.Check(errMultiformParse, err) != nil { h.portal.Logger().Error(errMultiformParse, zap.Error(err)) return } // Retrieve the file from the form data file, _, err := r.FormFile("file") if jc.Check(errRetrievingFile, err) != nil { h.portal.Logger().Error(errRetrievingFile, zap.Error(err)) return } defer func(file multipart.File) { err := file.Close() if err != nil { h.portal.Logger().Error(errClosingStream, zap.Error(err)) } }(file) rs = file } else { data, err := io.ReadAll(r.Body) if jc.Check(errReadFile, err) != nil { h.portal.Logger().Error(errReadFile, zap.Error(err)) return } buffer := bytes.NewReader(data) bufferSize = int64(buffer.Len()) rs = buffer defer func(Body io.ReadCloser) { err := Body.Close() if err != nil { h.portal.Logger().Error(errClosingStream, zap.Error(err)) } }(r.Body) } hash, err := h.portal.Storage().GetHash(rs) _, err = rs.Seek(0, io.SeekStart) if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } if exists, upload := h.portal.Storage().FileExists(hash); exists { cid, err := encoding.CIDFromHash(hash, upload.Size, types.CIDTypeRaw, types.HashTypeBlake3) if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } cidStr, err := cid.ToString() if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } jc.Encode(map[string]string{"hash": cidStr}) return } hash, err = h.portal.Storage().PutFile(rs, "s5", false) if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } cid, err := encoding.CIDFromHash(hash, uint64(bufferSize), types.CIDTypeRaw, types.HashTypeBlake3) if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } cidStr, err := cid.ToString() if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } _, err = h.portal.Storage().CreateUpload(hash, uint(jc.Request.Context().Value(AuthUserIDKey).(uint64)), jc.Request.RemoteAddr, uint64(bufferSize), "s5") if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) } jc.Encode(&SmallUploadResponse{ CID: cidStr, }) } func (h *HttpHandler) AccountRegisterChallenge(jc jape.Context) { var pubkey string if jc.DecodeForm("pubKey", &pubkey) != nil { return } challenge := make([]byte, 32) _, err := rand.Read(challenge) if err != nil { _ = jc.Error(errAccountGenerateChallengeErr, http.StatusInternalServerError) h.portal.Logger().Error(errAccountGenerateChallenge, zap.Error(err)) return } decodedKey, err := base64.RawURLEncoding.DecodeString(pubkey) if err != nil { _ = jc.Error(errAccountGenerateChallengeErr, http.StatusInternalServerError) h.portal.Logger().Error(errAccountGenerateChallenge, zap.Error(err)) return } if len(decodedKey) != 33 && int(decodedKey[0]) != int(types.HashTypeEd25519) { _ = jc.Error(errAccountGenerateChallengeErr, http.StatusInternalServerError) h.portal.Logger().Error(errAccountGenerateChallenge, zap.Error(err)) return } result := h.portal.Database().Create(&models.S5Challenge{ Pubkey: pubkey, Challenge: base64.RawURLEncoding.EncodeToString(challenge), Type: "register", }) if result.Error != nil { _ = jc.Error(errAccountGenerateChallengeErr, http.StatusInternalServerError) h.portal.Logger().Error(errAccountGenerateChallenge, zap.Error(err)) return } jc.Encode(&AccountRegisterChallengeResponse{ Challenge: base64.RawURLEncoding.EncodeToString(challenge), }) } func (h *HttpHandler) AccountRegister(jc jape.Context) { var request AccountRegisterRequest if jc.Decode(&request) != nil { return } errored := func(err error) { _ = jc.Error(errAccountRegisterErr, http.StatusInternalServerError) h.portal.Logger().Error(errAccountRegister, zap.Error(err)) } decodedKey, err := base64.RawURLEncoding.DecodeString(request.Pubkey) if err != nil { errored(err) return } if len(decodedKey) != 33 { errored(err) return } var challenge models.S5Challenge result := h.portal.Database().Model(&models.S5Challenge{}).Where(&models.S5Challenge{Pubkey: request.Pubkey, Type: "register"}).First(&challenge) if result.RowsAffected == 0 || result.Error != nil { errored(err) return } decodedResponse, err := base64.RawURLEncoding.DecodeString(request.Response) if err != nil { errored(errInvalidChallengeErr) return } if len(decodedResponse) != 65 { errored(errInvalidChallengeErr) return } decodedChallenge, err := base64.RawURLEncoding.DecodeString(challenge.Challenge) if err != nil { errored(errInvalidChallengeErr) return } if !bytes.Equal(decodedResponse[1:33], decodedChallenge) { errored(errInvalidChallengeErr) return } if int(decodedKey[0]) != int(types.HashTypeEd25519) { errored(errPubkeyNotSupported) return } decodedSignature, err := base64.RawURLEncoding.DecodeString(request.Signature) if err != nil { errored(err) return } if !ed25519.Verify(decodedKey[1:], decodedResponse, decodedSignature) { errored(errInvalidSignatureErr) return } if request.Email == "" { request.Email = fmt.Sprintf("%s@%s", hex.EncodeToString(decodedKey[1:]), "example.com") } verify, _ := h.verifier.Verify(request.Email) if !verify.Syntax.Valid { errored(errInvalidEmail) return } accountExists, _ := h.portal.Accounts().EmailExists(request.Email) if accountExists { errored(errEmailAlreadyExists) return } pubkeyExists, _ := h.portal.Accounts().PubkeyExists(hex.EncodeToString(decodedKey[1:])) if pubkeyExists { errored(errPubkeyAlreadyExists) return } passwd := make([]byte, 32) _, err = rand.Read(passwd) if accountExists { errored(errGeneratingPassword) return } newAccount, err := h.portal.Accounts().CreateAccount(request.Email, string(passwd)) if err != nil { errored(errAccountRegisterErr) return } rawPubkey := hex.EncodeToString(decodedKey[1:]) err = h.portal.Accounts().AddPubkeyToAccount(*newAccount, rawPubkey) if err != nil { errored(errAccountRegisterErr) return } jwt, err := h.portal.Accounts().LoginPubkey(rawPubkey) if err != nil { errored(errAccountRegisterErr) return } result = h.portal.Database().Delete(&challenge) if result.Error != nil { errored(errAccountRegisterErr) return } setAuthCookie(jwt, jc) } func (h *HttpHandler) AccountLoginChallenge(jc jape.Context) { var pubkey string if jc.DecodeForm("pubKey", &pubkey) != nil { return } errored := func(err error) { _ = jc.Error(errAccountLoginErr, http.StatusInternalServerError) h.portal.Logger().Error(errAccountLogin, zap.Error(err)) } challenge := make([]byte, 32) _, err := rand.Read(challenge) if err != nil { _ = jc.Error(errAccountGenerateChallengeErr, http.StatusInternalServerError) h.portal.Logger().Error(errAccountGenerateChallenge, zap.Error(err)) return } decodedKey, err := base64.RawURLEncoding.DecodeString(pubkey) if err != nil { errored(errAccountGenerateChallengeErr) return } if len(decodedKey) != 33 && int(decodedKey[0]) != int(types.HashTypeEd25519) { errored(errPubkeyNotSupported) return } pubkeyExists, _ := h.portal.Accounts().PubkeyExists(hex.EncodeToString(decodedKey[1:])) if pubkeyExists { errored(errPubkeyNotExist) return } result := h.portal.Database().Create(&models.S5Challenge{ Challenge: base64.RawURLEncoding.EncodeToString(challenge), Type: "login", }) if result.Error != nil { errored(result.Error) return } jc.Encode(&AccountLoginChallengeResponse{ Challenge: base64.RawURLEncoding.EncodeToString(challenge), }) } func (h *HttpHandler) AccountLogin(jc jape.Context) { var request AccountLoginRequest if jc.Decode(&request) != nil { return } errored := func(err error) { _ = jc.Error(errAccountLoginErr, http.StatusInternalServerError) h.portal.Logger().Error(errAccountLogin, zap.Error(err)) } decodedKey, err := base64.RawURLEncoding.DecodeString(request.Pubkey) if err != nil { errored(err) return } if len(decodedKey) != 32 { errored(err) return } var challenge models.S5Challenge result := h.portal.Database().Model(&models.S5Challenge{}).Where(&models.S5Challenge{Pubkey: request.Pubkey, Type: "login"}).First(&challenge) if result.RowsAffected == 0 || result.Error != nil { errored(err) return } decodedResponse, err := base64.RawURLEncoding.DecodeString(request.Response) if err != nil { errored(err) return } if len(decodedResponse) != 65 { errored(err) return } decodedChallenge, err := base64.RawURLEncoding.DecodeString(challenge.Challenge) if err != nil { errored(err) return } if !bytes.Equal(decodedResponse[1:33], decodedChallenge) { errored(errInvalidChallengeErr) return } if int(decodedKey[0]) != int(types.HashTypeEd25519) { errored(errPubkeyNotSupported) return } decodedSignature, err := base64.RawURLEncoding.DecodeString(request.Signature) if err != nil { errored(err) return } if !ed25519.Verify(decodedKey[1:], decodedResponse, decodedSignature) { errored(errInvalidSignatureErr) return } jwt, err := h.portal.Accounts().LoginPubkey(request.Pubkey) if err != nil { errored(errAccountLoginErr) return } result = h.portal.Database().Delete(&challenge) if result.Error != nil { errored(errAccountLoginErr) return } setAuthCookie(jwt, jc) } func (h *HttpHandler) AccountInfo(jc jape.Context) { _, user := h.portal.Accounts().AccountExists(jc.Request.Context().Value(AuthUserIDKey).(uint64)) info := &AccountInfoResponse{ Email: user.Email, QuotaExceeded: false, EmailConfirmed: false, IsRestricted: false, Tier: AccountTier{ Id: 1, Name: "default", UploadBandwidth: math.MaxUint64, StorageLimit: math.MaxUint64, Scopes: []interface{}{}, }, } jc.Encode(info) } func (h *HttpHandler) AccountStats(jc jape.Context) { _, user := h.portal.Accounts().AccountExists(jc.Request.Context().Value(AuthUserIDKey).(uint64)) info := &AccountStatsResponse{ AccountInfoResponse: AccountInfoResponse{ Email: user.Email, QuotaExceeded: false, EmailConfirmed: false, IsRestricted: false, Tier: AccountTier{ Id: 1, Name: "default", UploadBandwidth: math.MaxUint64, StorageLimit: math.MaxUint64, Scopes: []interface{}{}, }, }, Stats: AccountStats{ Total: AccountStatsTotal{ UsedStorage: 0, }, }, } jc.Encode(info) } func (h *HttpHandler) AccountPins(jc jape.Context) { var cursor uint64 if jc.DecodeForm("cursor", &cursor) != nil { return } errored := func(err error) { _ = jc.Error(errFailedToGetPinsErr, http.StatusInternalServerError) h.portal.Logger().Error(errFailedToGetPins, zap.Error(err)) } pins, err := h.portal.Accounts().AccountPins(jc.Request.Context().Value(AuthUserIDKey).(uint64), cursor) if err != nil { errored(err) return } pinsList := make([][]byte, len(pins)) for i, pin := range pins { hash, err := hex.DecodeString(pin.Upload.Hash) if err != nil { errored(err) return } pinsList[i] = encoding.MultihashFromBytes(hash, types.HashTypeBlake3).FullBytes() } result, err := msgpack.Marshal(pinsList) if err != nil { errored(err) return } jc.Custom(jc.Request, result) jc.ResponseWriter.WriteHeader(http.StatusOK) _, _ = jc.ResponseWriter.Write(result) } func (h *HttpHandler) AccountPinDelete(jc jape.Context) { var cid string if jc.DecodeParam("cid", &cid) != nil { return } errored := func(err error) { _ = jc.Error(errFailedToDelPinErr, http.StatusInternalServerError) h.portal.Logger().Error(errFailedToDelPin, zap.Error(err)) } decodedCid, err := encoding.CIDFromString(cid) if err != nil { errored(err) return } hash := hex.EncodeToString(decodedCid.Hash.HashBytes()) err = h.portal.Accounts().DeletePinByHash(hash, uint(jc.Request.Context().Value(AuthUserIDKey).(uint64))) if err != nil { errored(err) } jc.ResponseWriter.WriteHeader(http.StatusNoContent) } func (h *HttpHandler) AccountPin(jc jape.Context) { var cid string if jc.DecodeParam("cid", &cid) != nil { return } errored := func(err error) { _ = jc.Error(errFailedToAddPinErr, http.StatusInternalServerError) h.portal.Logger().Error(errFailedToAddPin, zap.Error(err)) } decodedCid, err := encoding.CIDFromString(cid) if err != nil { errored(err) return } hash := hex.EncodeToString(decodedCid.Hash.HashBytes()) err = h.portal.Accounts().PinByHash(hash, uint(jc.Request.Context().Value(AuthUserIDKey).(uint64))) if err != nil { errored(err) return } jc.ResponseWriter.WriteHeader(http.StatusNoContent) } func (h *HttpHandler) DirectoryUpload(jc jape.Context) { var tryFiles []string var errorPages map[int]string var name string if jc.DecodeForm("tryFiles", &tryFiles) != nil { return } if jc.DecodeForm("errorPages", &errorPages) != nil { return } if jc.DecodeForm("name", &name) != nil { return } r := jc.Request contentType := r.Header.Get("Content-Type") errored := func(err error) { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) } if !strings.HasPrefix(contentType, "multipart/form-data") { _ = jc.Error(errNotMultiformErr, http.StatusBadRequest) h.portal.Logger().Error(errorNotMultiform) return } err := r.ParseMultipartForm(h.portal.Config().GetInt64("core.post-upload-limit")) if jc.Check(errMultiformParse, err) != nil { h.portal.Logger().Error(errMultiformParse, zap.Error(err)) return } uploadMap := make(map[string]models.Upload, len(r.MultipartForm.File)) mimeMap := make(map[string]string, len(r.MultipartForm.File)) for _, files := range r.MultipartForm.File { for _, fileHeader := range files { // Open the file. file, err := fileHeader.Open() if err != nil { errored(err) return } defer func(file multipart.File) { err := file.Close() if err != nil { h.portal.Logger().Error(errClosingStream, zap.Error(err)) } }(file) var rs io.ReadSeeker hash, err := h.portal.Storage().GetHash(rs) _, err = rs.Seek(0, io.SeekStart) if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } if exists, upload := h.portal.Storage().FileExists(hash); exists { uploadMap[fileHeader.Filename] = upload continue } hash, err = h.portal.Storage().PutFile(rs, "s5", false) if err != nil { errored(err) return } upload, err := h.portal.Storage().CreateUpload(hash, uint(jc.Request.Context().Value(AuthUserIDKey).(uint64)), jc.Request.RemoteAddr, uint64(fileHeader.Size), "s5") if err != nil { errored(err) return } // Reset the read pointer back to the start of the file. if _, err := file.Seek(0, io.SeekStart); err != nil { errored(err) return } // Read a snippet of the file to determine its MIME type. buffer := make([]byte, 512) // 512 bytes should be enough for http.DetectContentType to determine the type if _, err := file.Read(buffer); err != nil { errored(err) return } // Reset the read pointer back to the start of the file. if _, err := file.Seek(0, 0); err != nil { errored(err) return } // Detect MIME type. mimeType := http.DetectContentType(buffer) uploadMap[fileHeader.Filename] = *upload mimeMap[fileHeader.Filename] = mimeType } } filesMap := make(map[string]metadata.WebAppMetadataFileReference, len(uploadMap)) for name, file := range uploadMap { hashDecoded, err := hex.DecodeString(file.Hash) if err != nil { errored(err) return } cid, err := encoding.CIDFromHash(hashDecoded, file.Size, types.CIDTypeRaw, types.HashTypeBlake3) if err != nil { errored(err) return } filesMap[name] = *metadata.NewWebAppMetadataFileReference(cid, mimeMap[name]) } app := metadata.NewWebAppMetadata(name, tryFiles, *metadata.NewExtraMetadata(map[int]interface{}{}), errorPages, filesMap) appData, err := msgpack.Marshal(app) if err != nil { errored(err) return } var rs = bytes.NewReader(appData) hash, err := h.portal.Storage().GetHash(rs) _, err = rs.Seek(0, io.SeekStart) if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } if exists, upload := h.portal.Storage().FileExists(hash); exists { cid, err := encoding.CIDFromHash(hash, upload.Size, types.CIDTypeMetadataWebapp, types.HashTypeBlake3) if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } cidStr, err := cid.ToString() if err != nil { _ = jc.Error(errUploadingFileErr, http.StatusInternalServerError) h.portal.Logger().Error(errUploadingFile, zap.Error(err)) return } jc.Encode(map[string]string{"hash": cidStr}) return } hash, err = h.portal.Storage().PutFile(rs, "s5", false) if err != nil { errored(err) return } cid, err := encoding.CIDFromHash(hash, uint64(len(appData)), types.CIDTypeRaw, types.HashTypeBlake3) if err != nil { errored(err) return } cidStr, err := cid.ToString() if err != nil { errored(err) return } jc.Encode(&AppUploadResponse{CID: cidStr}) } func (h *HttpHandler) DebugDownloadUrls(jc jape.Context) { var cid string if jc.DecodeParam("cid", &cid) != nil { return } decodedCid, err := encoding.CIDFromString(cid) if err != nil { _ = jc.Error(errFetchingUrlsErr, http.StatusInternalServerError) h.portal.Logger().Error(errFetchingUrls, zap.Error(err)) return } node := h.getNode() dlUriProvider := s5storage.NewStorageLocationProvider(node, &decodedCid.Hash, types.StorageLocationTypeFull, types.StorageLocationTypeFile, types.StorageLocationTypeBridge) err = dlUriProvider.Start() if err != nil { _ = jc.Error(errFetchingUrlsErr, http.StatusInternalServerError) h.portal.Logger().Error(errFetchingUrls, zap.Error(err)) return } _, err = dlUriProvider.Next() if err != nil { _ = jc.Error(errFetchingUrlsErr, http.StatusInternalServerError) h.portal.Logger().Error(errFetchingUrls, zap.Error(err)) return } locations, err := node.GetCachedStorageLocations(&decodedCid.Hash, []types.StorageLocationType{ types.StorageLocationTypeFull, types.StorageLocationTypeFile, types.StorageLocationTypeBridge, }) if err != nil { _ = jc.Error(errFetchingUrlsErr, http.StatusInternalServerError) h.portal.Logger().Error(errFetchingUrls, zap.Error(err)) return } availableNodes := lo.Keys[string, s5interfaces.StorageLocation](locations) availableNodesIds := make([]*encoding.NodeId, len(availableNodes)) for i, nodeIdStr := range availableNodes { nodeId, err := encoding.DecodeNodeId(nodeIdStr) if err != nil { _ = jc.Error(errFetchingUrlsErr, http.StatusInternalServerError) h.portal.Logger().Error(errFetchingUrls, zap.Error(err)) return } availableNodesIds[i] = nodeId } sorted, err := node.Services().P2P().SortNodesByScore(availableNodesIds) if err != nil { return } if err != nil { _ = jc.Error(errFetchingUrlsErr, http.StatusInternalServerError) h.portal.Logger().Error(errFetchingUrls, zap.Error(err)) return } output := make([]string, len(sorted)) for i, nodeId := range sorted { nodeIdStr, err := nodeId.ToString() if err != nil { _ = jc.Error(errFetchingUrlsErr, http.StatusInternalServerError) h.portal.Logger().Error(errFetchingUrls, zap.Error(err)) return } output[i] = locations[nodeIdStr].BytesURL() } jc.ResponseWriter.WriteHeader(http.StatusOK) _, _ = jc.ResponseWriter.Write([]byte(strings.Join(output, "\n"))) } func (h *HttpHandler) getNode() s5interfaces.Node { proto, _ := h.portal.ProtocolRegistry().Get("s5") protoInstance := proto.(*protocols.S5Protocol) return protoInstance.Node() } func setAuthCookie(jwt string, jc jape.Context) { authCookie := http.Cookie{ Name: "s5-auth-token", Value: jwt, Path: "/", HttpOnly: true, MaxAge: int(time.Hour.Seconds() * 24), Secure: true, } http.SetCookie(jc.ResponseWriter, &authCookie) }