mirror of
https://github.com/versity/versitygw.git
synced 2026-07-02 16:54:25 +00:00
f08f76fea4
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.
295 lines
8.6 KiB
Go
295 lines
8.6 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 s3response
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/versity/versitygw/debuglogger"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
const maxRoutingRules = 50
|
|
|
|
// WebsiteConfiguration represents the S3 bucket website configuration.
|
|
type WebsiteConfiguration struct {
|
|
XMLName xml.Name `xml:"WebsiteConfiguration"`
|
|
IndexDocument *IndexDocument `xml:"IndexDocument,omitempty"`
|
|
ErrorDocument *ErrorDocument `xml:"ErrorDocument,omitempty"`
|
|
RedirectAllRequestsTo *RedirectAllRequestsTo `xml:"RedirectAllRequestsTo,omitempty"`
|
|
RoutingRules []RoutingRule `xml:"RoutingRules>RoutingRule,omitempty"`
|
|
}
|
|
|
|
// IndexDocument specifies the default object served for directory-like requests.
|
|
type IndexDocument struct {
|
|
Suffix string `xml:"Suffix"`
|
|
}
|
|
|
|
// ErrorDocument specifies the object served when an error occurs.
|
|
type ErrorDocument struct {
|
|
Key string `xml:"Key"`
|
|
}
|
|
|
|
// RedirectAllRequestsTo redirects all requests to another host.
|
|
type RedirectAllRequestsTo struct {
|
|
HostName string `xml:"HostName"`
|
|
Protocol string `xml:"Protocol,omitempty"`
|
|
}
|
|
|
|
// RoutingRule specifies a redirect rule with an optional condition.
|
|
type RoutingRule struct {
|
|
Condition *RoutingRuleCondition `xml:"Condition,omitempty"`
|
|
Redirect *Redirect `xml:"Redirect"`
|
|
}
|
|
|
|
// RoutingRuleCondition specifies when a routing rule applies.
|
|
type RoutingRuleCondition struct {
|
|
HttpErrorCodeReturnedEquals string `xml:"HttpErrorCodeReturnedEquals,omitempty"`
|
|
KeyPrefixEquals string `xml:"KeyPrefixEquals,omitempty"`
|
|
}
|
|
|
|
// Redirect specifies where to redirect matching requests.
|
|
type Redirect struct {
|
|
HostName string `xml:"HostName,omitempty"`
|
|
HttpRedirectCode string `xml:"HttpRedirectCode,omitempty"`
|
|
Protocol string `xml:"Protocol,omitempty"`
|
|
ReplaceKeyPrefixWith string `xml:"ReplaceKeyPrefixWith,omitempty"`
|
|
ReplaceKeyWith string `xml:"ReplaceKeyWith,omitempty"`
|
|
}
|
|
|
|
// Validate checks the website configuration for S3-compatible validity.
|
|
func (c *WebsiteConfiguration) Validate() error {
|
|
if c.RedirectAllRequestsTo != nil {
|
|
if c.IndexDocument != nil || c.ErrorDocument != nil || len(c.RoutingRules) > 0 {
|
|
debuglogger.Logf("website redirect conflicts with config")
|
|
return s3err.GetAPIError(s3err.ErrMalformedXML)
|
|
}
|
|
if c.RedirectAllRequestsTo.HostName == "" {
|
|
debuglogger.Logf("website redirect hostname is empty")
|
|
return s3err.GetAPIError(s3err.ErrMalformedXML)
|
|
}
|
|
if err := validateProtocol(c.RedirectAllRequestsTo.Protocol); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if c.IndexDocument == nil {
|
|
debuglogger.Logf("website index document is missing")
|
|
return s3err.GetAPIError(s3err.ErrMalformedXML)
|
|
}
|
|
if c.IndexDocument.Suffix == "" {
|
|
debuglogger.Logf("website index suffix is empty")
|
|
return s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, c.IndexDocument.Suffix)
|
|
}
|
|
if strings.Contains(c.IndexDocument.Suffix, "/") {
|
|
debuglogger.Logf("website index suffix contains slash")
|
|
return s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, c.IndexDocument.Suffix)
|
|
}
|
|
|
|
if c.ErrorDocument != nil && c.ErrorDocument.Key == "" {
|
|
debuglogger.Logf("website error document key is empty")
|
|
return s3err.GetInvalidArgumentErr(s3err.InvalidArgErrorDocumentKey, "")
|
|
}
|
|
|
|
if len(c.RoutingRules) > maxRoutingRules {
|
|
debuglogger.Logf("too many website routing rules: %d", len(c.RoutingRules))
|
|
return s3err.GetWebsiteRoutingRulesLimitedErr(len(c.RoutingRules))
|
|
}
|
|
|
|
for _, rule := range c.RoutingRules {
|
|
if err := rule.Validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Validate checks a single routing rule for validity.
|
|
func (r *RoutingRule) Validate() error {
|
|
if err := r.Redirect.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := r.Condition.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *RoutingRuleCondition) Validate() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
|
|
if c.HttpErrorCodeReturnedEquals == "" && c.KeyPrefixEquals == "" {
|
|
debuglogger.Logf("website routing rule condition is empty")
|
|
return s3err.GetAPIError(s3err.ErrMalformedXML)
|
|
}
|
|
|
|
return isValidHTTPCode(c.HttpErrorCodeReturnedEquals, validateErrorCode)
|
|
}
|
|
|
|
func (r *Redirect) Validate() error {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
|
|
if r.HostName == "" &&
|
|
r.HttpRedirectCode == "" &&
|
|
r.Protocol == "" &&
|
|
r.ReplaceKeyPrefixWith == "" &&
|
|
r.ReplaceKeyWith == "" {
|
|
debuglogger.Logf("website routing rule redirect is empty")
|
|
return s3err.GetAPIError(s3err.ErrMalformedXML)
|
|
}
|
|
|
|
if r.ReplaceKeyWith != "" && r.ReplaceKeyPrefixWith != "" {
|
|
debuglogger.Logf("website redirect has both key replacements")
|
|
return s3err.GetAPIError(s3err.ErrBothReplaceKeyAndPrefix)
|
|
}
|
|
|
|
if err := validateProtocol(r.Protocol); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := isValidHTTPCode(r.HttpRedirectCode, validateRedirectCode); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type httpCodeValidator func(code int) error
|
|
|
|
func isValidHTTPCode(input string, validateCode httpCodeValidator) error {
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
|
|
code, err := strconv.Atoi(input)
|
|
if err != nil {
|
|
return s3err.GetAPIError(s3err.ErrMalformedXML)
|
|
}
|
|
|
|
return validateCode(code)
|
|
}
|
|
|
|
// isValidErrorCode checks if the provided code is a valid
|
|
// HTTP error code: S3 considers 400-417 and 500-505 as valid
|
|
func validateErrorCode(code int) error {
|
|
if (code >= 400 && code <= 417) || (code >= 500 && code <= 505) {
|
|
return nil
|
|
}
|
|
|
|
debuglogger.Logf("invalid website error code: %d", code)
|
|
return s3err.GetInvalidHTTPErrorCodeErr(code)
|
|
}
|
|
|
|
// validateRedirectCode check if the provided code
|
|
// is a valid HTTP redirect code
|
|
func validateRedirectCode(code int) error {
|
|
switch code {
|
|
case 301, 302, 303, 304, 305, 307, 308:
|
|
return nil
|
|
}
|
|
|
|
debuglogger.Logf("invalid website redirect code: %d", code)
|
|
return s3err.GetInvalidRedirectCodeErr(code)
|
|
}
|
|
|
|
func validateProtocol(protocol string) error {
|
|
if protocol != "" && protocol != "http" && protocol != "https" {
|
|
debuglogger.Logf("invalid website redirect protocol: %q", protocol)
|
|
return s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectProtocol)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParseWebsiteConfigOutput parses raw bytes into a WebsiteConfiguration.
|
|
func ParseWebsiteConfigOutput(data []byte) (*WebsiteConfiguration, error) {
|
|
var config WebsiteConfiguration
|
|
err := xml.Unmarshal(data, &config)
|
|
if err != nil {
|
|
debuglogger.Logf("failed to parse website config: %v", err)
|
|
return nil, fmt.Errorf("failed to parse website config: %w", err)
|
|
}
|
|
|
|
return &config, nil
|
|
}
|
|
|
|
// MatchPrefetchRoutingRule returns the first rule that can be evaluated before
|
|
// attempting an object read. Only prefix-only conditions participate in this
|
|
// phase.
|
|
func (c *WebsiteConfiguration) MatchPrefetchRoutingRule(key string) *RoutingRule {
|
|
for i := range c.RoutingRules {
|
|
rule := &c.RoutingRules[i]
|
|
condition := rule.Condition
|
|
if condition == nil ||
|
|
condition.KeyPrefixEquals == "" ||
|
|
condition.HttpErrorCodeReturnedEquals != "" {
|
|
continue
|
|
}
|
|
|
|
if condition.KeyPrefixEquals != "" && strings.HasPrefix(key, condition.KeyPrefixEquals) {
|
|
return rule
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MatchPostErrorRoutingRule returns the first rule that matches after a 4xx
|
|
// object-read error. Prefix-only rules are skipped because they have already
|
|
// been evaluated in the pre-fetch phase.
|
|
func (c *WebsiteConfiguration) MatchPostErrorRoutingRule(key string, statusCode int) *RoutingRule {
|
|
for i := range c.RoutingRules {
|
|
rule := &c.RoutingRules[i]
|
|
condition := rule.Condition
|
|
if condition != nil && condition.HttpErrorCodeReturnedEquals == "" {
|
|
continue
|
|
}
|
|
|
|
if condition.Matches(key, statusCode) {
|
|
return rule
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Matches reports whether all configured condition fields match.
|
|
func (c *RoutingRuleCondition) Matches(key string, statusCode int) bool {
|
|
if c == nil {
|
|
return true
|
|
}
|
|
|
|
if c.KeyPrefixEquals != "" && !strings.HasPrefix(key, c.KeyPrefixEquals) {
|
|
return false
|
|
}
|
|
|
|
if c.HttpErrorCodeReturnedEquals != "" &&
|
|
strconv.Itoa(statusCode) != c.HttpErrorCodeReturnedEquals {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|