From 3d7f8602d45583f25e2428bf6f8123453646dc08 Mon Sep 17 00:00:00 2001 From: Bobby Date: Sun, 13 Jul 2025 14:22:20 +0530 Subject: registration controllers and email sending support --- config/types.go | 1 + controllers/constants.go | 8 ++- controllers/home.go | 4 +- controllers/login.go | 24 +++----- controllers/logout.go | 23 +++++++ controllers/register.go | 75 ++++++++++++++++++++++- database/database.go | 1 + database/tokens.go | 65 ++++++++++++++++++++ database/user.go | 12 ++++ models/tokens.go | 34 +++++++++++ models/user.go | 19 ++++-- processors/processors.go | 1 + processors/user.go | 43 +++++++++++++ router/routes.go | 12 +++- session/session.go | 1 - static/css/main.css | 9 +++ templates/email/verification.html | 75 +++++++++++++++++++++++ templates/register.django | 11 ++-- utils/auth/auth.go | 26 ++++++++ utils/email/email.go | 124 ++++++++++++++++++++++++++++++++++++++ utils/validators/tokens.go | 14 +++++ 21 files changed, 549 insertions(+), 33 deletions(-) create mode 100644 controllers/logout.go create mode 100644 database/tokens.go create mode 100644 models/tokens.go create mode 100644 processors/user.go create mode 100644 templates/email/verification.html create mode 100644 utils/auth/auth.go create mode 100644 utils/email/email.go create mode 100644 utils/validators/tokens.go diff --git a/config/types.go b/config/types.go index c8bdd75..129e3d2 100644 --- a/config/types.go +++ b/config/types.go @@ -6,6 +6,7 @@ type ServerConfig struct { Host string `env:"SERVER_HOST" default:"localhost"` Port int `env:"SERVER_PORT" default:"8080"` AppName string `env:"APP_NAME" default:"ImageBoard"` + AppBaseURL string `env:"APP_BASE_URL" default:"http://localhost:8080"` AppSecret string `env:"APP_SECRET" default:"default_secret"` IsDevMode bool `env:"DEV_MODE" default:"true"` MinPasswordLength int `env:"MIN_PASSWORD_LENGTH" default:"8"` diff --git a/controllers/constants.go b/controllers/constants.go index 95d75c1..021eeb9 100644 --- a/controllers/constants.go +++ b/controllers/constants.go @@ -32,6 +32,10 @@ const ( ERR_LOGIN_INVALID_CREDENTIALS = `The credentials you provided are incorrect. Did you forget your password?` ERR_ACCOUNT_DISABLED = `Your account is disabled or banned. You can reach out to support for assistance.` ERR_ACCOUNT_UNABLE_TO_LOGIN = `You cannot log in at this time. Verify your email or contact support. If you misplaced your verification email, you can request a new one.` - ERR_SESSION_FAILED_TO_CREATE = "Failed to create session. Please try again later." - ERR_SESSION_FAILED_TO_SAVE = "Failed to save session. Please try again later." + ERR_PASSWORD_MISMATCH = "Entered passwords do not match. Ensure both fields are identical." + ERR_SESSION_FAILED_TO_CREATE = "Server failed to create a session. If this issue persists, contact support." + ERR_SESSION_FAILED_TO_SAVE = "Server failed to save session data. If this issue persists, contact support." + + // Success messages + SUCCESS_USER_REGISTERED = "Your account has been created successfully. A verification email has been sent to your email address. You will only be able to log in after verifying your email. If you did not receive the email, you can request a new one." ) diff --git a/controllers/home.go b/controllers/home.go index 6758a4b..186ef24 100644 --- a/controllers/home.go +++ b/controllers/home.go @@ -7,6 +7,6 @@ import ( ) func HomePageController(ctx *fiber.Ctx) error { - ctx.Locals("Title", "Home Page") - return shortcuts.Render(ctx, "home", nil) + ctx.Locals("Title", PT_HOME) + return shortcuts.Render(ctx, TEMPLATE_HOME, nil) } diff --git a/controllers/login.go b/controllers/login.go index 12262e3..6eb4996 100644 --- a/controllers/login.go +++ b/controllers/login.go @@ -3,17 +3,15 @@ package controllers import ( "imageboard/database" "imageboard/session" + "imageboard/utils/auth" "imageboard/utils/shortcuts" "github.com/gofiber/fiber/v2" ) -func getRedirectURL(ctx *fiber.Ctx) string { - referer := ctx.Get("Referer") - if referer != "" && referer != ctx.BaseURL()+URL_LOGIN && referer != ctx.BaseURL()+URL_REGISTER { - return referer - } - return URL_HOME +type LoginForm struct { + Username string `json:"username" form:"username"` + Password string `json:"password" form:"password"` } func renderLoginError(ctx *fiber.Ctx, errorMsg string, statusCode int) error { @@ -25,11 +23,9 @@ func renderLoginError(ctx *fiber.Ctx, errorMsg string, statusCode int) error { func LoginPageController(ctx *fiber.Ctx) error { ctx.Locals("Title", PT_LOGIN) - sess, err := session.Store.Get(ctx) - if err == nil { - if userID, ok := sess.Get("user_id").(int); ok && userID != 0 { - return ctx.Redirect(getRedirectURL(ctx), fiber.StatusSeeOther) - } + + if auth.IsAuthenticated(ctx) { + return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther) } return shortcuts.Render(ctx, TEMPLATE_LOGIN, nil) @@ -37,10 +33,6 @@ func LoginPageController(ctx *fiber.Ctx) error { func LoginPostController(ctx *fiber.Ctx) error { ctx.Locals("Title", PT_LOGIN) - type LoginForm struct { - Username string `json:"username" form:"username"` - Password string `json:"password" form:"password"` - } var form LoginForm var err error @@ -78,5 +70,5 @@ func LoginPostController(ctx *fiber.Ctx) error { user.UpdateLastUserLogin(database.DB) user.UpdateLastUserActivity(database.DB) - return ctx.Redirect(getRedirectURL(ctx), fiber.StatusSeeOther) + return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther) } diff --git a/controllers/logout.go b/controllers/logout.go new file mode 100644 index 0000000..20c280d --- /dev/null +++ b/controllers/logout.go @@ -0,0 +1,23 @@ +package controllers + +import ( + "imageboard/session" + "imageboard/utils/auth" + + "github.com/gofiber/fiber/v2" +) + +func LogoutController(ctx *fiber.Ctx) error { + sess, err := session.Store.Get(ctx) + if err != nil { + return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther) + } + + if err := sess.Destroy(); err != nil { + sess.Delete("user_id") + sess.Delete("username") + sess.Save() + } + + return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther) +} diff --git a/controllers/register.go b/controllers/register.go index d16db9d..acadbc5 100644 --- a/controllers/register.go +++ b/controllers/register.go @@ -1,12 +1,83 @@ package controllers import ( + "imageboard/database" + "imageboard/models" + "imageboard/utils/auth" + "imageboard/utils/email" "imageboard/utils/shortcuts" + "log" + "strings" "github.com/gofiber/fiber/v2" ) +type RegisterForm struct { + Username string `json:"username" form:"username"` + Email string `json:"email" form:"email"` + Password string `json:"password" form:"password"` + ConfirmPassword string `json:"confirm_password" form:"confirm_password"` +} + +func renderRegisterError(ctx *fiber.Ctx, errorMsg string, statusCode int) error { + return shortcuts.RenderWithStatus(ctx, TEMPLATE_REGISTER, fiber.Map{ + "Error": errorMsg, + "Username": ctx.FormValue("username"), + "Email": ctx.FormValue("email"), + }, statusCode) +} + func RegisterPageController(ctx *fiber.Ctx) error { - ctx.Locals("Title", "Register") - return shortcuts.Render(ctx, "register", nil) + ctx.Locals("Title", PT_REGISTER) + + if auth.IsAuthenticated(ctx) { + return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther) + } + + return shortcuts.Render(ctx, TEMPLATE_REGISTER, nil) +} + +func RegisterPostController(ctx *fiber.Ctx) error { + ctx.Locals("Title", PT_REGISTER) + + if auth.IsAuthenticated(ctx) { + return ctx.Redirect(auth.GetRedirectURL(ctx), fiber.StatusSeeOther) + } + + var form RegisterForm + if err := ctx.BodyParser(&form); err != nil { + return renderRegisterError(ctx, ERR_INVALID_FORM_DATA, fiber.StatusBadRequest) + } + + if form.Password != form.ConfirmPassword { + return renderRegisterError(ctx, ERR_PASSWORD_MISMATCH, fiber.StatusBadRequest) + } + + user := &models.User{ + Username: form.Username, + Email: form.Email, + Password: form.Password, + } + + if err := database.CreateUser(user); err != nil { + var statusCode int + if strings.Contains(err.Error(), "username") { + statusCode = fiber.StatusConflict + } else if strings.Contains(err.Error(), "email") { + statusCode = fiber.StatusBadRequest + } else { + statusCode = fiber.StatusInternalServerError + } + + return renderRegisterError(ctx, "Failed to create user: "+err.Error(), statusCode) + } + + if err := email.SendVerificationEmail(user); err != nil { + log.Printf("Failed to send verification email: %v", err) + return renderRegisterError(ctx, "User created but failed to send verification email", fiber.StatusInternalServerError) + } + + return shortcuts.Render(ctx, TEMPLATE_REGISTER, fiber.Map{ + "Success": SUCCESS_USER_REGISTERED, + }) } diff --git a/database/database.go b/database/database.go index d284c0f..dedae59 100644 --- a/database/database.go +++ b/database/database.go @@ -64,6 +64,7 @@ func autoMigrate() error { &models.ImageSize{}, &models.Tag{}, &models.Comment{}, + &models.EmailToken{}, ) } diff --git a/database/tokens.go b/database/tokens.go new file mode 100644 index 0000000..ddbc1f6 --- /dev/null +++ b/database/tokens.go @@ -0,0 +1,65 @@ +package database + +import ( + "fmt" + "imageboard/models" + "imageboard/utils/validators" + "time" +) + +func GenerateEmailToken(userID int, tokenType models.EmailTokenType) (*models.EmailToken, error) { + var existingToken models.EmailToken + if err := DB.Where("user_id = ? AND type = ?", userID, tokenType).First(&existingToken).Error; err == nil { + if err := DB.Delete(&existingToken).Error; err != nil { + return nil, err + } + } + + tokenValue, err := validators.GenerateRandomToken() + if err != nil { + return nil, err + } + + var expirationDuration time.Duration + switch tokenType { + case models.EmailTokenTypeVerification: + expirationDuration = 24 * time.Hour + case models.EmailTokenTypePasswordReset: + expirationDuration = 1 * time.Hour + case models.EmailTokenTypeChangeEmail: + expirationDuration = 1 * time.Hour + default: + expirationDuration = 1 * time.Hour + } + + token := &models.EmailToken{ + UserID: uint(userID), + Token: tokenValue, + Type: tokenType, + ExpiresAt: time.Now().Add(expirationDuration), + } + + if err := DB.Create(token).Error; err != nil { + return nil, err + } + + return token, nil +} + +func VerifyToken(userID int, token string, tokenType models.EmailTokenType) (*models.EmailToken, error) { + var emailToken models.EmailToken + if err := DB.Where("user_id = ? AND token = ? AND type = ?", userID, token, tokenType).First(&emailToken).Error; err != nil { + return nil, err + } + + if !emailToken.IsValid() { + return nil, fmt.Errorf("token is invalid or expired") + } + + emailToken.MarkAsUsed() + if err := DB.Save(&emailToken).Error; err != nil { + return nil, fmt.Errorf("failed to mark token as used: %w", err) + } + + return &emailToken, nil +} diff --git a/database/user.go b/database/user.go index c512038..4fe7e18 100644 --- a/database/user.go +++ b/database/user.go @@ -9,3 +9,15 @@ func GetUserByUsername(username string) (*models.User, error) { } return &user, nil } + +func GetUserByID(userID uint) (*models.User, error) { + var user models.User + if err := DB.Where("id = ?", userID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func CreateUser(user *models.User) error { + return DB.Create(user).Error +} diff --git a/models/tokens.go b/models/tokens.go new file mode 100644 index 0000000..c53ea2e --- /dev/null +++ b/models/tokens.go @@ -0,0 +1,34 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type EmailToken struct { + gorm.Model + UserID uint `gorm:"not null;index" json:"user_id"` + Token string `gorm:"uniqueIndex;not null;size:64" json:"token"` + Type EmailTokenType `gorm:"not null;size:20" json:"type"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + UsedAt *time.Time `gorm:"default:null" json:"used_at"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +func (et *EmailToken) IsExpired() bool { + return time.Now().After(et.ExpiresAt) +} + +func (et *EmailToken) IsUsed() bool { + return et.UsedAt != nil +} + +func (et *EmailToken) IsValid() bool { + return !et.IsExpired() && !et.IsUsed() +} + +func (et *EmailToken) MarkAsUsed() { + now := time.Now() + et.UsedAt = &now +} diff --git a/models/user.go b/models/user.go index 5607e68..3b92077 100644 --- a/models/user.go +++ b/models/user.go @@ -72,13 +72,27 @@ func (u *User) BeforeCreate(tx *gorm.DB) error { var userCount int64 if err := tx.Model(&User{}).Where("is_deleted = ?", false).Count(&userCount).Error; err != nil { - return err + return fmt.Errorf("failed to count existing users: %v", err) } if userCount == 0 { u.Level = UserLevelSuperAdmin // First user becomes Super Admin } + if len(u.Password) < config.Server.MinPasswordLength { + return fmt.Errorf("password must be at least %d characters long", config.Server.MinPasswordLength) + } + if len(u.Password) > 255 { + return fmt.Errorf("password must not exceed 255 characters") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) + if err != nil { + return err + } + + u.Password = string(hashedPassword) + return nil } @@ -93,16 +107,13 @@ func (u *User) SetPassword(password string) error { if len(password) < config.Server.MinPasswordLength { return fmt.Errorf("password must be at least %d characters long", config.Server.MinPasswordLength) } - if len(password) > 255 { return fmt.Errorf("password must not exceed 255 characters") } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } - u.Password = string(hashedPassword) return nil } diff --git a/processors/processors.go b/processors/processors.go index bbd23fe..db42480 100644 --- a/processors/processors.go +++ b/processors/processors.go @@ -4,6 +4,7 @@ import "github.com/gofiber/fiber/v2" func Initialize(app *fiber.App) { app.Use(RequestContextProcessor) + app.Use(UserContextProcessor) app.Use(MetaContextProcessor) app.Use(SidebarContextProcessor) app.Use(PreferencesContextProcessor) diff --git a/processors/user.go b/processors/user.go new file mode 100644 index 0000000..b875c89 --- /dev/null +++ b/processors/user.go @@ -0,0 +1,43 @@ +package processors + +import ( + "imageboard/database" + "imageboard/models" + "imageboard/session" + + "github.com/gofiber/fiber/v2" +) + +func UserContextProcessor(ctx *fiber.Ctx) error { + var user *models.User + + sess, err := session.Store.Get(ctx) + if err == nil { + var userID uint + if id := sess.Get("user_id"); id != nil { + switch v := id.(type) { + case uint: + userID = v + case int: + userID = uint(v) + case int64: + userID = uint(v) + case float64: + userID = uint(v) + } + } + + if userID != 0 { + dbUser, err := database.GetUserByID(userID) + if err == nil && dbUser != nil { + dbUser.UpdateLastUserActivity(database.DB) + user = dbUser + } + } + } + + ctx.Locals("User", user) + ctx.Locals("IsAuthenticated", user != nil) + + return ctx.Next() +} diff --git a/router/routes.go b/router/routes.go index d72e990..c522599 100644 --- a/router/routes.go +++ b/router/routes.go @@ -9,8 +9,6 @@ import ( func Initialize(router *fiber.App) { main := router.Group("/") main.Get("/", controllers.HomePageController) - main.Get("/register", controllers.RegisterPageController) - main.Get("/preferences", controllers.PreferencesPageController) posts := router.Group("/posts") posts.Get("/", controllers.PostsController) @@ -19,5 +17,15 @@ func Initialize(router *fiber.App) { login.Get("/", controllers.LoginPageController) login.Post("/", controllers.LoginPostController) + logout := router.Group("/logout") + logout.Get("/", controllers.LogoutController) + + register := router.Group("/register") + register.Get("/", controllers.RegisterPageController) + register.Post("/", controllers.RegisterPostController) + + preferences := router.Group("/preferences") + preferences.Get("/", controllers.PreferencesPageController) + router.Use(controllers.NotFoundController) } diff --git a/session/session.go b/session/session.go index ed89c5a..ac99300 100644 --- a/session/session.go +++ b/session/session.go @@ -25,7 +25,6 @@ func init() { Storage: storage, Expiration: config.Session.Expiration, KeyLookup: "cookie:" + config.Session.CookieName, - CookieName: config.Session.CookieName, CookieDomain: config.Session.CookieDomain, CookiePath: config.Session.CookiePath, CookieSecure: config.Session.CookieSecure, diff --git a/static/css/main.css b/static/css/main.css index af99d59..55b4fa1 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -363,4 +363,13 @@ footer::before { padding: 8px; margin-bottom: 16px; text-align: center; +} + +.success { + color: #ccffcc; + background-color: #003300; + border: 1px solid #00ff00; + padding: 8px; + margin-bottom: 16px; + text-align: center; } \ No newline at end of file diff --git a/templates/email/verification.html b/templates/email/verification.html new file mode 100644 index 0000000..bef032c --- /dev/null +++ b/templates/email/verification.html @@ -0,0 +1,75 @@ + + + + + Verify your email + + + +
+

Welcome to {{ .Appname }}, {{ .Username }}!

+

+ Your account has been created successfully. To complete your + registration, you need to verify your email address by clicking the link + below: +

+ Verify Email +

+ If you are unable to click the link, copy and paste it into your + browser: +

+

+ {{ .Link }} +

+ +
+ + diff --git a/templates/register.django b/templates/register.django index 02ba9d9..2990ba5 100644 --- a/templates/register.django +++ b/templates/register.django @@ -2,14 +2,17 @@ {% block content %}
-
+
Register Image

Join {{ Appname }}

Create your account to start sharing and exploring images!

- {% if Error %} -
{{ Error }}
- {% endif %}
+ {% if Error %} +
{{ Error|safe }}
+ {% endif %} + {% if Success %} +
{{ Success|safe }}
+ {% endif %}
diff --git a/utils/auth/auth.go b/utils/auth/auth.go new file mode 100644 index 0000000..7b8f260 --- /dev/null +++ b/utils/auth/auth.go @@ -0,0 +1,26 @@ +package auth + +import ( + "imageboard/models" + + "github.com/gofiber/fiber/v2" +) + +func GetCurrentUser(ctx *fiber.Ctx) *models.User { + if user, ok := ctx.Locals("User").(*models.User); ok { + return user + } + return nil +} + +func IsAuthenticated(ctx *fiber.Ctx) bool { + return GetCurrentUser(ctx) != nil +} + +func GetRedirectURL(ctx *fiber.Ctx) string { + referer := ctx.Get("Referer") + if referer != "" && referer != ctx.BaseURL()+"/login" && referer != ctx.BaseURL()+"/register" { + return referer + } + return "/" +} diff --git a/utils/email/email.go b/utils/email/email.go new file mode 100644 index 0000000..fb1d58d --- /dev/null +++ b/utils/email/email.go @@ -0,0 +1,124 @@ +package email + +import ( + "bytes" + "fmt" + "html/template" + "imageboard/config" + "imageboard/database" + "imageboard/models" + "net/smtp" + "regexp" +) + +func extractEmailAddress(from string) string { + re := regexp.MustCompile(`<([^>]+)>`) + matches := re.FindStringSubmatch(from) + if len(matches) == 2 { + return matches[1] + } + return from +} + +func SendMail(to, subject, body string) error { + var auth smtp.Auth + if config.SMTP.Username != "" { + auth = smtp.PlainAuth("", config.SMTP.Username, config.SMTP.Password, config.SMTP.Host) + } else { + auth = nil + } + fromHeader := config.SMTP.From + fromAddress := extractEmailAddress(config.SMTP.From) + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", + fromHeader, to, subject, body) + addr := fmt.Sprintf("%s:%d", config.SMTP.Host, config.SMTP.Port) + return smtp.SendMail(addr, auth, fromAddress, []string{to}, []byte(msg)) +} + +func SendVerificationEmail(user *models.User) error { + token, err := database.GenerateEmailToken(int(user.ID), models.EmailTokenTypeVerification) + if err != nil { + return fmt.Errorf("failed to generate verification token: %w", err) + } + + tmpl, err := template.ParseFiles("templates/email/verification.html") + if err != nil { + return fmt.Errorf("failed to parse email template: %w", err) + } + verificationLink := fmt.Sprintf("%s/account/verify?token=%s", config.Server.AppBaseURL, token.Token) + data := struct { + Username string + Appname string + Link string + }{ + Username: user.Username, + Appname: config.Server.AppName, + Link: verificationLink, + } + + var body bytes.Buffer + if err := tmpl.Execute(&body, data); err != nil { + return fmt.Errorf("failed to execute email template: %w", err) + } + + subject := fmt.Sprintf("Verify your email for %s", config.Server.AppName) + return SendMail(user.Email, subject, body.String()) +} + +// func SendPasswordResetEmail(user *models.User) error { +// token, err := user.GenerateToken(database.DB, models.EmailTokenTypePasswordReset) +// if err != nil { +// return fmt.Errorf("failed to generate password reset token: %w", err) +// } + +// tmpl, err := template.ParseFiles("templates/email/password_reset.html") +// if err != nil { +// return fmt.Errorf("failed to parse email template: %w", err) +// } +// resetLink := fmt.Sprintf("%s/account/reset-password?token=%s", config.Server.AppBaseURL, token.Token) +// data := struct { +// Username string +// Link string +// }{ +// Username: user.Username, +// Link: resetLink, +// } + +// var body bytes.Buffer +// if err := tmpl.Execute(&body, data); err != nil { +// return fmt.Errorf("failed to execute email template: %w", err) +// } + +// subject := fmt.Sprintf("Password reset for %s", config.Server.AppName) +// return SendMail(user.Email, subject, body.String()) +// } + +// func SendEmailChangeConfirmation(user *models.User, newEmail string) error { +// token, err := user.GenerateToken(database.DB, models.EmailTokenTypeChangeEmail) +// if err != nil { +// return fmt.Errorf("failed to generate email change token: %w", err) +// } + +// tmpl, err := template.ParseFiles("templates/email/email_change.html") +// if err != nil { +// return fmt.Errorf("failed to parse email template: %w", err) +// } +// confirmLink := fmt.Sprintf("%s/account/confirm-email-change?token=%s&email=%s", config.Server.AppBaseURL, token.Token, newEmail) +// data := struct { +// Username string +// NewEmail string +// Link string +// }{ +// Username: user.Username, +// NewEmail: newEmail, +// Link: confirmLink, +// } + +// var body bytes.Buffer +// if err := tmpl.Execute(&body, data); err != nil { +// return fmt.Errorf("failed to execute email template: %w", err) +// } + +// subject := fmt.Sprintf("Confirm email change for %s", config.Server.AppName) +// return SendMail(newEmail, subject, body.String()) +// } diff --git a/utils/validators/tokens.go b/utils/validators/tokens.go new file mode 100644 index 0000000..f377c2e --- /dev/null +++ b/utils/validators/tokens.go @@ -0,0 +1,14 @@ +package validators + +import ( + "crypto/rand" + "encoding/hex" +) + +func GenerateRandomToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} -- cgit v1.2.3