From 56635fc1458a14d0e075b6292c9d4408425b5252 Mon Sep 17 00:00:00 2001 From: Mitchell Thompson Date: Sun, 27 Oct 2024 07:12:56 -0400 Subject: [PATCH] first commit --- Dockerfile | 21 ++++++ cmd/server/main.go | 64 +++++++++++++++++ docker-compose.yml | 50 ++++++++++++++ go.mod | 37 ++++++++++ internal/auth/auth.go | 15 ++++ internal/auth/devAuth.go | 55 +++++++++++++++ internal/auth/samlAuth.go | 125 ++++++++++++++++++++++++++++++++++ internal/config/config.go | 120 ++++++++++++++++++++++++++++++++ internal/handlers/handlers.go | 67 ++++++++++++++++++ internal/models/models.go | 41 +++++++++++ internal/secrets/secrets.go | 53 ++++++++++++++ internal/server/server.go | 63 +++++++++++++++++ internal/storage/storage.go | 68 ++++++++++++++++++ templates/index.html | 43 ++++++++++++ 14 files changed, 822 insertions(+) create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/devAuth.go create mode 100644 internal/auth/samlAuth.go create mode 100644 internal/config/config.go create mode 100644 internal/handlers/handlers.go create mode 100644 internal/models/models.go create mode 100644 internal/secrets/secrets.go create mode 100644 internal/server/server.go create mode 100644 internal/storage/storage.go create mode 100644 templates/index.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8c1ec16 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o main cmd/server/main.go + +FROM alpine:latest + +WORKDIR /app + +COPY --from=builder /app/main . +COPY templates/ templates/ + +EXPOSE 8080 + +CMD ["./main"] \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..fa39fde --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "SimpleTutorialHosting/internal/config" + "SimpleTutorialHosting/internal/server" + "SimpleTutorialHosting/internal/storage" + "context" + "log" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + + cfg, err := config.Load(ctx) + if err != nil { + log.Fatalf("Failed to load config: %v\n", err) + } + + store, err := storage.NewS3Client(ctx, cfg.BucketName) + if err != nil { + log.Fatalf("Failed to create S3 client: %v\n", err) + } + + srv, err := server.New(cfg, store) + if err != nil { + log.Fatalf("Failed to create server: %v\n", err) + } + + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + go func() { + if err := srv.Start(); err != nil { + log.Printf("Server error: %v\n", err) + cancel() + } + }() + + log.Printf("Server started on port %d\n", cfg.Port) + + select { + case sig := <-done: + log.Printf("Received signal: %v\n", sig) + case <-ctx.Done(): + log.Printf("Context cancelled: %v\n", ctx.Err()) + } + + log.Print("Shutting down server") + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("Error during shutdown: %v\n", err) + os.Exit(1) + } + + log.Print("Server exited properly") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aadbb43 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + - AWS_ACCESS_KEY_ID=test + - AWS_SECRET_ACCESS_KEY=test + - AWS_REGION=us-east-1 + - S3_BUCKET_NAME=tutorial-videos + - AWS_ENDPOINT_URL=http://localstack:4566 + - AUTH_MODE=dev + - AWS_S3_FORCE_PATH_STYLE=true + depends_on: + localstack: + condition: service_healthy + volumes: + - ./templates:/app/templates + networks: + - tutorial-network + localstack: + image: localstack/localstack:latest + ports: + - "4566:4566" + environment: + - SERVICES=s3 + - DOCKER_HOST=unix:///var/run/docker.sock + - DEFAULT_REGION=us-east-1 + - AWS_ACCESS_KEY_ID=test + - AWS_SECRET_ACCESS_KEY=test + - DATA_DIR=/tmp/localstack/data + - PERSISTENCE=1 + volumes: + - ./localstack-init:/etc/localstack/init/ready.d + - /var/run/docker.sock:/var/run/docker.sock + #- ./.localstack:/tmp/localstack + healthcheck: + test: ["CMD", "awslocal", "s3", "ls"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - tutorial-network +networks: + tutorial-network: + driver: bridge \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a12bed1 --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module SimpleTutorialHosting + +go 1.23.2 + +require ( + github.com/aws/aws-sdk-go-v2 v1.32.2 + github.com/aws/aws-sdk-go-v2/config v1.28.0 + github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.2 + github.com/crewjam/saml v0.4.14 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.41 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 // indirect + github.com/aws/smithy-go v1.22.0 // indirect + github.com/beevik/etree v1.1.0 // indirect + github.com/crewjam/httperr v0.2.0 // indirect + github.com/golang-jwt/jwt/v4 v4.4.3 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/russellhaering/goxmldsig v1.3.0 // indirect + golang.org/x/crypto v0.14.0 // indirect +) diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..fb2debd --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,15 @@ +package auth + +import ( + "SimpleTutorialHosting/internal/models" + "net/http" +) + +type contextKey string + +const UserContextKey contextKey = "user" + +type Authenticator interface { + Authenticate(http.Handler) http.Handler + GetUser(r *http.Request) (*models.User, error) +} diff --git a/internal/auth/devAuth.go b/internal/auth/devAuth.go new file mode 100644 index 0000000..ca1006b --- /dev/null +++ b/internal/auth/devAuth.go @@ -0,0 +1,55 @@ +package auth + +import ( + "SimpleTutorialHosting/internal/models" + "context" + "errors" + "net/http" +) + +type DevAuthenticator struct { + users map[string]models.User +} + +func NewDevAuthenticator(cfg *models.DevAuthConfig) (*DevAuthenticator, error) { + if cfg == nil { + return nil, errors.New("no dev auth config") + } + + userMap := make(map[string]models.User) + for _, user := range cfg.Users { + userMap[user.Username] = user + } + return &DevAuthenticator{ + users: userMap, + }, nil +} + +func (a *DevAuthenticator) Authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + user, exists := a.users[username] + if !exists || password != "devpass" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), UserContextKey, &user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (a *DevAuthenticator) GetUser(r *http.Request) (*models.User, error) { + user, ok := r.Context().Value(UserContextKey).(*models.User) + if !ok { + return nil, errors.New("no user in context") + } + + return user, nil +} diff --git a/internal/auth/samlAuth.go b/internal/auth/samlAuth.go new file mode 100644 index 0000000..c8582a8 --- /dev/null +++ b/internal/auth/samlAuth.go @@ -0,0 +1,125 @@ +package auth + +import ( + "SimpleTutorialHosting/internal/models" + "context" + "crypto/rsa" + "errors" + "fmt" + "github.com/crewjam/saml/samlsp" + "net/http" + "net/url" +) + +type SAMLAuthenticator struct { + middleware *samlsp.Middleware + adminGroup string + viewerGroup string +} + +func NewSAMLAuthenticator(cfg *models.SAMLConfig) (*SAMLAuthenticator, error) { + if cfg == nil { + return nil, errors.New("saml config is nil") + } + + rootURL, err := url.Parse(cfg.RootURL) + if err != nil { + return nil, fmt.Errorf("invalid root url: %w", err) + } + + idpMetadata, err := samlsp.ParseMetadata(cfg.IDPMetadata) + if err != nil { + return nil, fmt.Errorf("invalid idp metadata: %w", err) + } + + opts := samlsp.Options{ + URL: *rootURL, + Key: cfg.KeyPair.PrivateKey.(*rsa.PrivateKey), + Certificate: cfg.KeyPair.Leaf, + IDPMetadata: idpMetadata, + SignRequest: true, + AllowIDPInitiated: true, + } + + middleware, err := samlsp.New(opts) + if err != nil { + return nil, err + } + + return &SAMLAuthenticator{ + middleware: middleware, + adminGroup: cfg.AdminGroup, + viewerGroup: cfg.ViewerGroup, + }, nil +} + +func (a *SAMLAuthenticator) GetMiddleware() http.Handler { + mux := http.NewServeMux() + + // Handle SAML routes + mux.HandleFunc("/saml/metadata", func(w http.ResponseWriter, r *http.Request) { + a.middleware.ServeMetadata(w, r) + }) + + mux.HandleFunc("/saml/acs", func(w http.ResponseWriter, r *http.Request) { + a.middleware.ServeACS(w, r) + }) + + mux.HandleFunc("/saml/sso", func(w http.ResponseWriter, r *http.Request) { + a.middleware.ServeHTTP(w, r) + }) + + return mux +} + +func (a *SAMLAuthenticator) Authenticate(next http.Handler) http.Handler { + return a.middleware.RequireAccount( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, err := a.GetUser(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), UserContextKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }), + ) +} + +func (a *SAMLAuthenticator) GetUser(r *http.Request) (*models.User, error) { + session := samlsp.SessionFromContext(r.Context()) + if session == nil { + return nil, errors.New("no session in context") + } + + groups := samlsp.AttributeFromContext(r.Context(), "groups") + + var role models.Role + switch groups { + case a.adminGroup: + role = models.RoleAdmin + case a.viewerGroup: + role = models.RoleViewer + } + + if role == "" { + return nil, errors.New("user has no valid role") + } + + email := samlsp.AttributeFromContext(r.Context(), "email") + if email == "" { + return nil, errors.New("no email attribute") + } + + displayName := samlsp.AttributeFromContext(r.Context(), "displayName") + if displayName == "" { + return nil, errors.New("no displayName attribute") + } + + return &models.User{ + ID: email, + Username: displayName, + Role: role, + }, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2cc41de --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,120 @@ +package config + +import ( + "SimpleTutorialHosting/internal/models" + "SimpleTutorialHosting/internal/secrets" + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "os" +) + +type Config struct { + Port int + BucketName string + Auth AuthConfig +} + +type AuthConfig struct { + Mode string + Dev *models.DevAuthConfig + SAML *models.SAMLConfig +} + +func Load(ctx context.Context) (*Config, error) { + bucketName := os.Getenv("S3_BUCKET_NAME") + if bucketName == "" { + return nil, fmt.Errorf("S3_BUCKET_NAME is not set") + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + authMode := os.Getenv("AUTH_MODE") + if authMode == "" { + authMode = "dev" + } + + var authConfig AuthConfig + authConfig.Mode = authMode + + switch authMode { + case "dev": + if err := loadDevAuth(&authConfig); err != nil { + return nil, err + } + case "saml": + if err := loadSAMLAuth(ctx, &authConfig); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("invalid AUTH_MODE: %s", authMode) + } + + return &Config{ + Port: 8080, + BucketName: bucketName, + Auth: authConfig, + }, nil +} + +func loadDevAuth(config *AuthConfig) error { + devUserJSON := os.Getenv("DEV_USERS") + if devUserJSON == "" { + config.Dev = &models.DevAuthConfig{ + Users: []models.User{ + { + ID: "admin", + Username: "admin", + Role: models.RoleAdmin, + }, + { + ID: "viewer", + Username: "viewer", + Role: models.RoleViewer, + }, + }, + } + } + return nil +} + +func loadSAMLAuth(ctx context.Context, config *AuthConfig) error { //todo + secretID := os.Getenv("SAML_SECRET_ID") + if secretID == "" { + return fmt.Errorf("SAML_SECRET_ID is not set") + } + + secretManager, err := secrets.NewSecretManager(ctx) + if err != nil { + return fmt.Errorf("failed to create secret manager: %w", err) + } + + samlSecrets, err := secretManager.GetSAMLConfig(ctx, secretID) + if err != nil { + return fmt.Errorf("failed to get SAML secrets: %w", err) + } + + cert, err := tls.X509KeyPair([]byte(samlSecrets.Certificate), []byte(samlSecrets.PrivateKey)) + if err != nil { + return fmt.Errorf("failed to load X509 key pair: %w", err) + } + + cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + config.SAML = &models.SAMLConfig{ + RootURL: samlSecrets.RootURL, + KeyPair: cert, + IDPMetadata: []byte(samlSecrets.IDPMetadata), + AdminGroup: samlSecrets.AdminGroup, + ViewerGroup: samlSecrets.ViewerGroup, + } + + return nil +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..4ce70d4 --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "SimpleTutorialHosting/internal/storage" + "fmt" + "html/template" + "io" + "log" + "net/http" +) + +type Handlers struct { + store *storage.S3Client + tmpl *template.Template +} + +func New(store *storage.S3Client) (*Handlers, error) { + tmpl, err := template.ParseFiles("templates/index.html") + if err != nil { + return nil, err + } + + return &Handlers{ + store: store, + tmpl: tmpl, + }, nil +} + +func (h *Handlers) HandleIndex(w http.ResponseWriter, r *http.Request) { + config, err := h.store.GetConfig(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Printf("Error getting config: %v", err) + return + } + + h.tmpl.Execute(w, config) +} + +func (h *Handlers) HandleStreamVideo(w http.ResponseWriter, r *http.Request) { + videoKey := r.URL.Query().Get("key") + if videoKey == "" { + http.Error(w, "No key provided", http.StatusBadRequest) + return + } + + result, err := h.store.StreamVideo(r.Context(), videoKey, r.Header.Get("Range")) + if err != nil { + http.Error(w, "Failed to get video", http.StatusInternalServerError) + log.Printf("Error streaming video: %v", err) + return + } + defer result.Body.Close() + + w.Header().Set("Content-Type", *result.ContentType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", result.ContentLength)) + + if r.Header.Get("Range") != "" { + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Range", *result.ContentRange) + w.WriteHeader(http.StatusPartialContent) + } + + if _, err := io.Copy(w, result.Body); err != nil { + log.Printf("Error copying video: %v", err) + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..d056c37 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,41 @@ +package models + +import "crypto/tls" + +type Video struct { + Title string `json:"title"` + Key string `json:"key"` + Description string `json:"description"` + Order int `json:"order"` +} + +type Config struct { + Videos []Video `json:"videos"` +} + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleViewer Role = "viewer" +) + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Role Role `json:"role"` +} + +type DevAuthConfig struct { + Users []User `json:"users"` +} + +type SAMLConfig struct { + RootURL string `json:"root_url"` + KeyPair tls.Certificate `json:"-"` + Certificate string `json:"certificate"` + PrivateKey string `json:"private_key"` + IDPMetadata []byte `json:"idp_metadata"` + AdminGroup string `json:"admin_group"` + ViewerGroup string `json:"viewer_group"` +} diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go new file mode 100644 index 0000000..88850c2 --- /dev/null +++ b/internal/secrets/secrets.go @@ -0,0 +1,53 @@ +package secrets + +import ( + "context" + "encoding/json" + "fmt" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +type SAMLSecrets struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` + IDPMetadata string `json:"idpMetadata"` + RootURL string `json:"rootURL"` + AdminGroup string `json:"adminGroup"` + ViewerGroup string `json:"viewerGroup"` +} + +type SecretManager struct { + client *secretsmanager.Client +} + +func NewSecretManager(ctx context.Context) (*SecretManager, error) { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("unable to load AWS config: %w", err) + } + + client := secretsmanager.NewFromConfig(cfg) + + return &SecretManager{ + client: client, + }, nil +} + +func (s *SecretManager) GetSAMLConfig(ctx context.Context, secretId string) (*SAMLSecrets, error) { + input := &secretsmanager.GetSecretValueInput{ + SecretId: &secretId, + } + + result, err := s.client.GetSecretValue(ctx, input) + if err != nil { + return nil, fmt.Errorf("unable to get secret: %w", err) + } + + var secrets SAMLSecrets + if err := json.Unmarshal([]byte(*result.SecretString), &secrets); err != nil { + return nil, fmt.Errorf("unable to unmarshal secret: %w", err) + } + + return &secrets, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..35a9ec7 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,63 @@ +package server + +import ( + "SimpleTutorialHosting/internal/auth" + "SimpleTutorialHosting/internal/config" + "SimpleTutorialHosting/internal/handlers" + "SimpleTutorialHosting/internal/storage" + "context" + "fmt" + "net/http" + "strconv" +) + +type Server struct { + server *http.Server +} + +func New(cfg *config.Config, store *storage.S3Client) (*Server, error) { + h, err := handlers.New(store) + if err != nil { + return nil, fmt.Errorf("failed to create handlers: %w", err) + } + + var authenticator auth.Authenticator + switch cfg.Auth.Mode { + case "dev": + authenticator, err = auth.NewDevAuthenticator(cfg.Auth.Dev) + case "saml": + authenticator, err = auth.NewSAMLAuthenticator(cfg.Auth.SAML) + default: + return nil, fmt.Errorf("invalid auth mode: %s", cfg.Auth.Mode) + } + + if err != nil { + return nil, fmt.Errorf("failed to create authenticator: %w", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/", h.HandleIndex) + mux.HandleFunc("/stream", h.HandleStreamVideo) + + if cfg.Auth.Mode == "saml" { + samlAuth := authenticator.(*auth.SAMLAuthenticator) + mux.Handle("/saml/", samlAuth.GetMiddleware()) + } + + srv := &http.Server{ + Addr: ":" + strconv.Itoa(cfg.Port), + Handler: mux, + } + + return &Server{ + server: srv, + }, nil +} + +func (s *Server) Start() error { + return s.server.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.server.Shutdown(ctx) +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..518b01c --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,68 @@ +package storage + +import ( + "SimpleTutorialHosting/internal/models" + "context" + "encoding/json" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type S3Client struct { + client *s3.Client + bucketName string +} + +func NewS3Client(ctx context.Context, bucketName string) (*S3Client, error) { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, err + } + + client := s3.NewFromConfig(cfg) + return &S3Client{ + client: client, + bucketName: bucketName, + }, nil +} + +func (s *S3Client) Upload(ctx context.Context, key string, body []byte) { + //todo +} + +func (s *S3Client) GetConfig(ctx context.Context) (models.Config, error) { + var config models.Config + + result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &s.bucketName, + Key: aws.String("config.json"), //todo + }) + if err != nil { + return config, err + } + defer result.Body.Close() + + if err := json.NewDecoder(result.Body).Decode(&config); err != nil { + return config, err + } + + return config, nil +} + +func (s *S3Client) SaveConfig(ctx context.Context, config models.Config) { + //todo +} + +func (s *S3Client) StreamVideo(ctx context.Context, key string, rangeHeader string) (*s3.GetObjectOutput, error) { + input := &s3.GetObjectInput{ + Bucket: &s.bucketName, + Key: &key, + } + + if rangeHeader != "" { + input.Range = &rangeHeader + } + + return s.client.GetObject(ctx, input) +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..72869c8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,43 @@ + + + + + + Tutorial Videos + + + +
+ {{ range .Videos }} +
+

{{.Title}}

+ + {{if .Description}} +

{{.Description}}

+ {{end}} +
+ {{end}} +
+ + \ No newline at end of file