Files
versitygw/website/handler.go
T
niksis02 f08f76fea4 feat: support x-amz-website-redirect-location
Integrate x-amz-website-redirect-location across object metadata flows so uploads, copies, multipart creation, HEAD, and GET preserve and return redirect locations, and website hosting applies object-level redirects from the stored value.
2026-06-10 12:41:55 +04:00

571 lines
17 KiB
Go

// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package website
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
var websiteAllowedMethods = []string{fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions}
type websiteController struct {
be backend.Backend
domain string
domainSuffix string
applyCORS fiber.Handler
}
// newWebsiteController returns a controller that serves static website content.
// It resolves the bucket name from the Host header using the configured domain,
// fetches the website configuration, and serves objects accordingly.
//
// Virtual-host routing with --website-domain example.com:
// - Host "blog.example.com" -> bucket "blog"
// - Host "example.com" -> bucket "example.com" (apex)
//
// Catch-all mode (--website-domain omitted or empty):
// - Host "blog.example.com" -> bucket "blog.example.com"
// - Host "mysite.org" -> bucket "mysite.org"
func newWebsiteController(be backend.Backend, domain string) *websiteController {
controller := &websiteController{
be: be,
domain: domain,
domainSuffix: "." + domain,
}
controller.applyCORS = middlewares.ApplyBucketCORS(be, controller.resolveBucket, "")
return controller
}
func (c *websiteController) Get(ctx *fiber.Ctx) error {
return c.serve(ctx, c.getObject)
}
func (c *websiteController) Head(ctx *fiber.Ctx) error {
return c.serve(ctx, c.headObject)
}
func (c *websiteController) Options(ctx *fiber.Ctx) error {
bucket, err := c.resolveBucket(ctx)
if err != nil {
return sendError(ctx, err)
}
origin := ctx.Get("Origin")
method := auth.CORSHTTPMethod(ctx.Get("Access-Control-Request-Method"))
headers := ctx.Get("Access-Control-Request-Headers")
if origin == "" {
debuglogger.Logf("origin is missing: %v", origin)
return sendError(ctx, s3err.GetAPIError(s3err.ErrMissingCORSOrigin))
}
if !method.IsValid() {
debuglogger.Logf("invalid cors method: %s", method)
return sendError(ctx, s3err.GetInvalidCORSMethodErr(method.String()))
}
parsedHeaders, err := auth.ParseCORSHeaders(headers)
if err != nil {
return sendError(ctx, err)
}
cors, err := c.be.GetBucketCors(ctx.Context(), bucket)
if err != nil {
debuglogger.Logf("failed to get bucket cors: %v", err)
if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)) {
err = s3err.GetAccessForbiddenErr(s3err.ErrCORSIsNotEnabled, http.MethodOptions, s3err.ResourceTypeBucket)
debuglogger.Logf("bucket cors is not set: %v", err)
}
return sendError(ctx, err)
}
corsConfig, err := auth.ParseCORSOutput(cors)
if err != nil {
return sendError(ctx, err)
}
allowConfig, err := corsConfig.IsAllowed(origin, method, parsedHeaders, s3err.ResourceTypeObject)
if err != nil {
debuglogger.Logf("cors access forbidden: %v", err)
return sendError(ctx, err)
}
setCORSPreflightHeaders(ctx, allowConfig)
ctx.Status(http.StatusOK)
return nil
}
func registerWebsiteRoutes(app *fiber.App, be backend.Backend, domain string) {
controller := newWebsiteController(be, domain)
app.Head("*", controller.Head)
app.Get("*", controller.Get)
app.Options("*", controller.Options)
app.All("*", controller.MethodNotAllowed)
}
func setCORSPreflightHeaders(ctx *fiber.Ctx, allowConfig *auth.CORSAllowanceConfig) {
ctx.Set("Access-Control-Allow-Origin", allowConfig.Origin)
ctx.Set("Access-Control-Allow-Methods", allowConfig.Methods)
ctx.Set("Access-Control-Expose-Headers", allowConfig.ExposedHeaders)
ctx.Set("Access-Control-Allow-Credentials", allowConfig.AllowCredentials)
ctx.Set("Access-Control-Allow-Headers", allowConfig.AllowHeaders)
ctx.Set("Vary", middlewares.VaryHdr)
if allowConfig.MaxAge != nil {
ctx.Set("Access-Control-Max-Age", strconv.Itoa(int(*allowConfig.MaxAge)))
}
}
func (c *websiteController) MethodNotAllowed(ctx *fiber.Ctx) error {
return sendError(ctx, s3err.GetMethodNotAllowedErr(ctx.Method(), s3err.ResourceTypeObject, websiteAllowedMethods))
}
type websiteRequestInfo struct {
bucket string
config *s3response.WebsiteConfiguration
key string
}
type websiteObjectReader func(ctx *fiber.Ctx, bucket, key string) websiteResult
func (c *websiteController) serve(ctx *fiber.Ctx, readObject websiteObjectReader) error {
req, err := c.resolveRequest(ctx)
if err != nil {
return sendError(ctx, err)
}
if err := c.applyCORS(ctx); err != nil {
return sendError(ctx, err)
}
if req.config.RedirectAllRequestsTo != nil {
return handleRedirectAll(ctx, req.config.RedirectAllRequestsTo, req.key)
}
if rule := req.config.MatchPrefetchRoutingRule(req.key); rule != nil {
return applyRedirect(ctx, rule.Redirect, rule.Condition, req.key)
}
resolvedKey := resolveIndexKey(req.key, req.config)
result := readObject(ctx, req.bucket, resolvedKey)
if result.Err == nil {
return serveWebsiteResult(ctx, req.bucket, req.config, result, readObject)
}
if result.StatusCode >= http.StatusInternalServerError {
return sendError(ctx, result.Err)
}
if rule := req.config.MatchPostErrorRoutingRule(req.key, result.StatusCode); rule != nil {
return applyRedirect(ctx, rule.Redirect, rule.Condition, req.key)
}
return serveWebsiteResult(ctx, req.bucket, req.config, result, readObject)
}
func (c *websiteController) resolveRequest(ctx *fiber.Ctx) (*websiteRequestInfo, error) {
bucket, err := c.resolveBucket(ctx)
if err != nil {
return nil, err
}
fmt.Println(bucket)
key := strings.TrimPrefix(ctx.Path(), "/")
if err := validateWebsiteNames(bucket, key); err != nil {
return nil, err
}
data, err := c.be.GetBucketWebsite(ctx.Context(), bucket)
if err != nil {
return nil, err
}
config, err := s3response.ParseWebsiteConfigOutput(data)
if err != nil {
return nil, err
}
return &websiteRequestInfo{
bucket: bucket,
config: config,
key: key,
}, nil
}
func validateWebsiteNames(bucket, key string) error {
if !utils.IsValidBucketName(bucket) {
return s3err.GetBucketErr(s3err.ErrInvalidBucketName, bucket)
}
if key != "" && !utils.IsObjectNameValid(key) {
return s3err.GetAPIError(s3err.ErrBadRequest)
}
return nil
}
// resolveBucket extracts the bucket name from the request host header.
//
// It strips the port when present before applying website endpoint routing.
//
// When domain is set:
// - If host equals the domain exactly, the bucket IS the domain (apex).
// - If host ends with ".<domain>", the bucket is the subdomain part.
// - Otherwise, no bucket can be resolved.
//
// When domain is empty (catch-all mode):
// - The full hostname is used as the bucket name.
func (c *websiteController) resolveBucket(ctx *fiber.Ctx) (string, error) {
host := ctx.Hostname()
if host == "" {
ctx.Set("Location", c.noBucketLocation(ctx, host))
return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest)
}
// Strip port from host if present. Be careful with IPv6: only strip if the
// last colon is not inside brackets.
host = stripHostPort(host)
if c.domain == "" {
return host, nil
}
if strings.EqualFold(host, c.domain) {
return c.domain, nil
}
lowerHost := strings.ToLower(host)
lowerDomainSuffix := strings.ToLower(c.domainSuffix)
if strings.HasSuffix(lowerHost, lowerDomainSuffix) {
bucket := host[:len(host)-len(c.domainSuffix)]
if bucket != "" && !strings.Contains(bucket, ".") {
return bucket, nil
}
}
ctx.Set("Location", c.noBucketLocation(ctx, ctx.Hostname()))
return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest)
}
func (c *websiteController) noBucketLocation(ctx *fiber.Ctx, host string) string {
locationHost := c.domain
if locationHost == "" {
locationHost = stripHostPort(host)
}
if locationHost == "" {
return "/"
}
if c.domain != "" {
if port := hostPort(host); port != "" {
locationHost += ":" + port
}
}
return fmt.Sprintf("%s://%s/", ctx.Protocol(), locationHost)
}
func stripHostPort(host string) string {
if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") {
return host[:idx]
}
return host
}
func hostPort(host string) string {
if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") {
return host[idx+1:]
}
return ""
}
type websiteResult struct {
Key string
StatusCode int
Object websiteObject
Err error
}
type websiteObject struct {
Body io.ReadCloser
Headers map[string]*string
Metadata map[string]string
WebsiteRedirectLocation *string
}
func resolveIndexKey(key string, config *s3response.WebsiteConfiguration) string {
if config.IndexDocument != nil && config.IndexDocument.Suffix != "" {
if key == "" || strings.HasSuffix(key, "/") {
return key + config.IndexDocument.Suffix
}
}
return key
}
func (c *websiteController) getObject(ctx *fiber.Ctx, bucket, key string) websiteResult {
if err := auth.VerifyPublicAccess(ctx.Context(), c.be, auth.GetObjectAction, auth.PermissionRead, bucket, key); err != nil {
return websiteResult{
Key: key,
StatusCode: statusCodeFromError(err),
Err: err,
}
}
result, err := c.be.GetObject(ctx.Context(), &s3.GetObjectInput{
Bucket: &bucket,
Key: &key,
})
if err != nil {
return websiteResult{
Key: key,
StatusCode: statusCodeFromError(err),
Err: err,
}
}
return websiteResult{
Key: key,
StatusCode: http.StatusOK,
Object: websiteObject{
Body: result.Body,
Headers: getObjectHeaders(result),
Metadata: result.Metadata,
WebsiteRedirectLocation: result.WebsiteRedirectLocation,
},
}
}
func (c *websiteController) headObject(ctx *fiber.Ctx, bucket, key string) websiteResult {
if err := auth.VerifyPublicAccess(ctx.Context(), c.be, auth.GetObjectAction, auth.PermissionRead, bucket, key); err != nil {
return websiteResult{
Key: key,
StatusCode: statusCodeFromError(err),
Err: err,
}
}
result, err := c.be.HeadObject(ctx.Context(), &s3.HeadObjectInput{
Bucket: &bucket,
Key: &key,
})
if err != nil {
return websiteResult{
Key: key,
StatusCode: statusCodeFromError(err),
Err: err,
}
}
return websiteResult{
Key: key,
StatusCode: http.StatusOK,
Object: websiteObject{
Headers: headObjectHeaders(result),
Metadata: result.Metadata,
WebsiteRedirectLocation: result.WebsiteRedirectLocation,
},
}
}
func statusCodeFromError(err error) int {
var serr s3err.S3Error
if errors.As(err, &serr) {
return serr.StatusCode()
}
return http.StatusInternalServerError
}
// handleRedirectAll sends a 301 redirect for RedirectAllRequestsTo configuration.
func handleRedirectAll(ctx *fiber.Ctx, redirect *s3response.RedirectAllRequestsTo, key string) error {
protocol := redirect.Protocol
if protocol == "" {
protocol = ctx.Protocol()
}
location := fmt.Sprintf("%s://%s/%s", protocol, redirect.HostName, key)
if query := string(ctx.Request().URI().QueryString()); query != "" {
location += "?" + query
}
return sendRedirect(ctx, http.StatusMovedPermanently, location)
}
// applyRedirect constructs and sends a redirect response from a routing rule.
func applyRedirect(ctx *fiber.Ctx, redirect *s3response.Redirect, condition *s3response.RoutingRuleCondition, originalKey string) error {
protocol := redirect.Protocol
if protocol == "" {
protocol = ctx.Protocol()
}
host := redirect.HostName
if host == "" {
host = ctx.Hostname()
}
key := originalKey
if redirect.ReplaceKeyWith != "" {
key = redirect.ReplaceKeyWith
} else if redirect.ReplaceKeyPrefixWith != "" && condition != nil && condition.KeyPrefixEquals != "" {
key = redirect.ReplaceKeyPrefixWith + strings.TrimPrefix(originalKey, condition.KeyPrefixEquals)
}
httpCode := http.StatusMovedPermanently
if redirect.HttpRedirectCode != "" {
if code, err := strconv.Atoi(redirect.HttpRedirectCode); err == nil {
httpCode = code
}
}
location := fmt.Sprintf("%s://%s/%s", protocol, host, key)
if query := string(ctx.Request().URI().QueryString()); query != "" {
location += "?" + query
}
return sendRedirect(ctx, httpCode, location)
}
func sendRedirect(ctx *fiber.Ctx, statusCode int, location string) error {
ctx.Set("Location", location)
_, _ = utils.EnsureRequestIDs(ctx)
ctx.Status(statusCode)
return nil
}
func getObjectHeaders(result *s3.GetObjectOutput) map[string]*string {
return map[string]*string{
"ETag": result.ETag,
"accept-ranges": result.AcceptRanges,
"Cache-Control": result.CacheControl,
"Content-Disposition": result.ContentDisposition,
"Content-Encoding": result.ContentEncoding,
"Content-Language": result.ContentLanguage,
"Content-Length": utils.ConvertPtrToStringPtr(result.ContentLength),
"Content-Range": result.ContentRange,
"Content-Type": result.ContentType,
"Expires": result.ExpiresString,
"Last-Modified": utils.FormatDatePtrToString(result.LastModified, http.TimeFormat),
"x-amz-restore": result.Restore,
"x-amz-version-id": result.VersionId,
}
}
func headObjectHeaders(result *s3.HeadObjectOutput) map[string]*string {
return map[string]*string{
"ETag": result.ETag,
"accept-ranges": result.AcceptRanges,
"Cache-Control": result.CacheControl,
"Content-Disposition": result.ContentDisposition,
"Content-Encoding": result.ContentEncoding,
"Content-Language": result.ContentLanguage,
"Content-Length": utils.ConvertPtrToStringPtr(result.ContentLength),
"Content-Range": result.ContentRange,
"Content-Type": result.ContentType,
"Expires": result.ExpiresString,
"Last-Modified": utils.FormatDatePtrToString(result.LastModified, http.TimeFormat),
"x-amz-restore": result.Restore,
"x-amz-version-id": result.VersionId,
}
}
func serveWebsiteResult(ctx *fiber.Ctx, bucket string, config *s3response.WebsiteConfiguration, result websiteResult, readObject websiteObjectReader) error {
if result.Err == nil {
// Precedence: RedirectAllRequestsTo, pre-fetch routing rules, object
// redirect metadata, then post-error routing/error documents.
if location := backend.GetStringFromPtr(result.Object.WebsiteRedirectLocation); location != "" {
if result.Object.Body != nil {
_ = result.Object.Body.Close()
}
return sendRedirect(ctx, http.StatusMovedPermanently, location)
}
return serveObject(ctx, result.Object, http.StatusOK)
}
if config.ErrorDocument != nil && config.ErrorDocument.Key != "" {
return serveErrorDocument(ctx, readObject, bucket, config.ErrorDocument.Key, result.StatusCode)
}
return sendError(ctx, result.Err)
}
func serveObject(ctx *fiber.Ctx, object websiteObject, statusCode int) error {
ctx.Status(statusCode)
setWebsiteObjectHeaders(ctx, object)
if object.Body == nil {
return nil
}
defer object.Body.Close()
_, err := io.Copy(ctx.Response().BodyWriter(), object.Body)
if err != nil {
return sendError(ctx, err)
}
return nil
}
func setWebsiteObjectHeaders(ctx *fiber.Ctx, object websiteObject) {
utils.SetMetaHeaders(ctx, object.Metadata)
for key, value := range object.Headers {
if value != nil && *value != "" {
ctx.Set(key, *value)
}
}
}
// serveErrorDocument fetches and serves the configured error document.
func serveErrorDocument(ctx *fiber.Ctx, readObject websiteObjectReader, bucket, errorDocKey string, statusCode int) error {
result := readObject(ctx, bucket, errorDocKey)
if result.Err != nil {
return sendError(ctx, result.Err)
}
return serveObject(ctx, result.Object, statusCode)
}
// sendError sends a simple HTML error page.
func sendError(ctx *fiber.Ctx, err error) error {
requestId, hostId := utils.EnsureRequestIDs(ctx)
serr, ok := err.(s3err.S3Error)
if !ok {
debuglogger.InternalError(err)
serr = s3err.GetAPIError(s3err.ErrInternalError)
}
ctx.Response().Header.Set("x-amz-error-code", serr.BaseError().Code)
ctx.Response().Header.Set("x-amz-error-message", serr.BaseError().Description)
if methodErr, ok := serr.(s3err.MethodNotAllowedError); ok && len(methodErr.AllowedMethods) != 0 {
ctx.Response().Header.Set("Allow", methodErr.AllowedMethodsString())
}
ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8)
return ctx.Status(serr.StatusCode()).Send(serr.HTMLBody(requestId, hostId))
}