Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 42 additions & 16 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import (
)

type Config struct {
FileStorageURL string
DB DBConfig
API APIConfig
Broker BrokerConfig
CORS CORSConfig
JWTSecretKey string
Dump bool
FileStorageURL string
DB DBConfig
API APIConfig
Broker BrokerConfig
CORS CORSConfig
JWTSecretKey string
Dump bool
SignedURLTTLSeconds uint16
SignedURLSecretKey string
}

type DBConfig struct {
Expand Down Expand Up @@ -55,12 +57,13 @@ type BrokerConfig struct {
}

const (
defaultAPIPort = "8080"
defaultAPIRefreshTokenPath = "/api/v1/auth/refresh"
defaultQueueName = "worker_queue"
defaultResponseQueueName = "worker_response_queue"
defaultCORSAllowedOrigins = "http://localhost:3000,http://localhost:5173"
defaultAccessTokenMinutesStr = "180"
defaultAPIPort = "8080"
defaultAPIRefreshTokenPath = "/api/v1/auth/refresh"
defaultQueueName = "worker_queue"
defaultResponseQueueName = "worker_response_queue"
defaultCORSAllowedOrigins = "http://localhost:3000,http://localhost:5173"
defaultAccessTokenMinutesStr = "180"
defaultSignedURLTTLSecondsStr = "300" // 5 minutes
)

// NewConfig creates new Config instance
Expand Down Expand Up @@ -97,6 +100,10 @@ const (
//
// - JWT_ACCESS_TOKEN_MINUTES - access token lifetime in minutes. Default is 180
//
// - SIGNED_URL_TTL_SECONDS - time-to-live for signed file URLs in seconds. Default is 300 (5 minutes)
//
// - SIGNED_URL_SECRET_KEY - secret key for signing file URLs. Default is JWT_SECRET_KEY if not set
//
// - LANGUAGES - comma-separated list of languages with their version,
// e.g. "c:99,c:11,c:18,cpp:11,cpp:14,cpp:17,cpp:20,cpp:23". Default will expand to [DefaultLanguages]
func NewConfig() *Config {
Expand Down Expand Up @@ -209,6 +216,23 @@ func NewConfig() *Config {
More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSNotSupportingCredentials`)
}

signedURLTTLSecondsStr := os.Getenv("SIGNED_URL_TTL_SECONDS")
if signedURLTTLSecondsStr == "" {
log.Warnf("SIGNED_URL_TTL_SECONDS is not set. Using default value %s", defaultSignedURLTTLSecondsStr)
signedURLTTLSecondsStr = defaultSignedURLTTLSecondsStr
}
signedURLTTLSecondsParsed, err := strconv.ParseUint(signedURLTTLSecondsStr, 10, 16)
if err != nil {
log.Panicf("invalid SIGNED_URL_TTL_SECONDS value %s", signedURLTTLSecondsStr)
}
signedURLTTLSeconds := uint16(signedURLTTLSecondsParsed)

signedURLSecretKey := os.Getenv("SIGNED_URL_SECRET_KEY")
if signedURLSecretKey == "" {
log.Warnf("SIGNED_URL_SECRET_KEY is not set. Using JWT_SECRET_KEY as fallback")
signedURLSecretKey = jwtSecretKey
}

return &Config{
DB: DBConfig{
Host: dbHost,
Expand All @@ -234,9 +258,11 @@ More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSNot
AllowedOrigins: corsAllowedOrigins,
AllowCredentials: corsAllowCredentials,
},
FileStorageURL: fileStorageURL,
JWTSecretKey: jwtSecretKey,
Dump: dump,
FileStorageURL: fileStorageURL,
JWTSecretKey: jwtSecretKey,
Dump: dump,
SignedURLTTLSeconds: signedURLTTLSeconds,
SignedURLSecretKey: signedURLSecretKey,
}
}

Expand Down
6 changes: 5 additions & 1 deletion internal/initialization/initialization.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ func NewInitialization(cfg *config.Config) *Initialization {
accessControlRepository := repository.NewAccessControlRepository()

// Services
filestorage, err := filestorage.NewFileStorageService(cfg.FileStorageURL)
filestorage, err := filestorage.NewFileStorageService(
cfg.FileStorageURL,
cfg.SignedURLSecretKey,
cfg.SignedURLTTLSeconds,
)
if err != nil {
log.Panicf("Failed to create file storage service: %s", err.Error())
}
Expand Down
15 changes: 15 additions & 0 deletions package/filestorage/mock_filestorage.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 26 additions & 12 deletions package/filestorage/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,13 @@ type FileStorageService interface {

UploadSolutionFile(taskID, userID int64, newOrder int, filePath string) (*UploadedFile, error)

//
// GetFileURL returns the direct URL to access a file (no signature, no expiration)
GetFileURL(path string) string

// GetSignedFileURL generates a signed URL with expiration for the given file path.
// The ttlSeconds parameter specifies how long the URL should be valid.
GetSignedFileURL(path string, ttlSeconds uint16) (string, error)

GetTestResultStdoutPath(taskID, userID int64, submissionOrder, testCaseOrder int) *UploadedFile
GetTestResultStderrPath(taskID, userID int64, submissionOrder, testCaseOrder int) *UploadedFile
GetTestResultDiffPath(taskID, userID int64, submissionOrder, testCaseOrder int) *UploadedFile
Expand Down Expand Up @@ -313,14 +317,15 @@ func (d *decompressor) decompressZip(archivePath string, newPath string) error {
}

type fileStorageService struct {
decompressor Decompressor
validator ArchiveValidator
storage filestorage.FileStorage
bucketName string
logger *zap.SugaredLogger
decompressor Decompressor
validator ArchiveValidator
storage filestorage.FileStorage
bucketName string
signedURLGenerator *utils.SignedURLGenerator
logger *zap.SugaredLogger
}

func NewFileStorageService(fileStorageURL string) (FileStorageService, error) {
func NewFileStorageService(fileStorageURL string, signedURLSecretKey string, signedURLTTLSeconds uint16) (FileStorageService, error) {
validator := NewArchiveValidator()

// Configure validation rules
Expand Down Expand Up @@ -352,12 +357,16 @@ func NewFileStorageService(fileStorageURL string) (FileStorageService, error) {
if err != nil {
return nil, fmt.Errorf("failed to create file storage: %w", err)
}

signedURLGenerator := utils.NewSignedURLGenerator(signedURLSecretKey, signedURLTTLSeconds)

return &fileStorageService{
decompressor: &decompressor{},
validator: validator,
storage: storage,
bucketName: "maxit",
logger: utils.NewNamedLogger("file-storage"),
decompressor: &decompressor{},
validator: validator,
storage: storage,
bucketName: "maxit",
signedURLGenerator: signedURLGenerator,
logger: utils.NewNamedLogger("file-storage"),
}, nil
}

Expand Down Expand Up @@ -556,6 +565,11 @@ func (f *fileStorageService) GetFileURL(path string) string {
return f.storage.GetFileURL(f.bucketName, path)
}

func (f *fileStorageService) GetSignedFileURL(path string, ttlSeconds uint16) (string, error) {
baseURL := f.storage.GetFileURL(f.bucketName, path)
return f.signedURLGenerator.GenerateSignedURLWithTTL(baseURL, ttlSeconds)
}

func (f *fileStorageService) UploadSolutionFile(taskID, userID int64, order int, filePath string) (*UploadedFile, error) {
file, err := os.Open(filePath)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion package/service/submission_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func setupSubmissionServiceTest(t *testing.T) *testSetup {
userService := mock_service.NewMockUserService(ctrl)
queueService := mock_service.NewMockQueueService(ctrl)
acs := mock_service.NewMockAccessControlService(ctrl)
fs, err := filestorage.NewFileStorageService("dummy")
fs, err := filestorage.NewFileStorageService("dummy", "test-secret", 300)
require.NoError(t, err)

svc := service.NewSubmissionService(
Expand Down
29 changes: 10 additions & 19 deletions package/service/task_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func (ts *taskService) GetAll(db database.Database, _ *schemas.User, paginationP
return result, nil
}

func (ts *taskService) Get(db database.Database, _ *schemas.User, taskID int64) (*schemas.TaskDetailed, error) {
func (ts *taskService) Get(db database.Database, currentUser *schemas.User, taskID int64) (*schemas.TaskDetailed, error) {
// Get the task
task, err := ts.taskRepository.Get(db, taskID)
if err != nil {
Expand All @@ -152,28 +152,19 @@ func (ts *taskService) Get(db database.Database, _ *schemas.User, taskID int64)
return nil, err
}

// switch types.UserRole(currentUser.Role) {
// case types.UserRoleStudent:
// // Check if the task is assigned to the user
// isAssigned, err := ts.taskRepository.IsTaskAssignedToUser(db, taskID, currentUser.ID)
// if err != nil {
// ts.logger.Errorf("Error checking if task is assigned to user: %v", err.Error())
// return nil, err
// }
// if !isAssigned {
// return nil, errors.ErrNotAuthorized
// }
// case types.UserRoleTeacher:
// // Check if the task is created by the user
// if task.CreatedBy != currentUser.ID {
// return nil, errors.ErrNotAuthorized
// }
// }
// Generate signed URL for the description file
// Authorization is enforced by the route handler, so if we reach here, user is authorized
// Use a fixed TTL from config for simplicity and security
descriptionURL, err := ts.filestorage.GetSignedFileURL(task.DescriptionFile.Path, 300)
if err != nil {
ts.logger.Errorf("Error generating signed URL: %v", err.Error())
return nil, fmt.Errorf("failed to generate signed URL: %w", err)
}

result := &schemas.TaskDetailed{
ID: task.ID,
Title: task.Title,
DescriptionURL: ts.filestorage.GetFileURL(task.DescriptionFile.Path),
DescriptionURL: descriptionURL,
CreatedBy: task.CreatedBy,
CreatedByName: task.Author.Name,
CreatedAt: task.CreatedAt,
Expand Down
2 changes: 1 addition & 1 deletion package/service/task_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ func TestGetTask(t *testing.T) {
io := mock_repository.NewMockTestCaseRepository(ctrl)
fr := mock_repository.NewMockFile(ctrl)
config := testutils.NewTestConfig()
fs, err := filestorage.NewFileStorageService(config.FileStorageURL)
fs, err := filestorage.NewFileStorageService(config.FileStorageURL, "test-secret", 300)
require.NoError(t, err)
ts := service.NewTaskService(fs, fr, tr, io, ur, gr, nil, nil, nil)

Expand Down
127 changes: 127 additions & 0 deletions package/utils/signed_url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package utils

import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strconv"
"time"
)

var (
// ErrSignedURLExpired is returned when the signed URL has expired
ErrSignedURLExpired = errors.New("signed URL has expired")
// ErrSignedURLInvalidSignature is returned when the signature validation fails
ErrSignedURLInvalidSignature = errors.New("signed URL has invalid signature")
// ErrSignedURLMissingParams is returned when required parameters are missing
ErrSignedURLMissingParams = errors.New("signed URL is missing required parameters")
)

// SignedURLGenerator generates and validates signed URLs
type SignedURLGenerator struct {
secretKey []byte
ttl time.Duration
}

// NewSignedURLGenerator creates a new SignedURLGenerator
func NewSignedURLGenerator(secretKey string, ttlSeconds uint16) *SignedURLGenerator {
return &SignedURLGenerator{
secretKey: []byte(secretKey),
ttl: time.Duration(ttlSeconds) * time.Second,
}
}

// GenerateSignedURL generates a signed URL with expiration
// The signature and expiration are added as query parameters
// If ttlSeconds is 0, uses the default TTL from the generator
func (g *SignedURLGenerator) GenerateSignedURL(baseURL string) (string, error) {
return g.GenerateSignedURLWithTTL(baseURL, 0)
}

// GenerateSignedURLWithTTL generates a signed URL with custom TTL
// If ttlSeconds is 0, uses the default TTL from the generator
func (g *SignedURLGenerator) GenerateSignedURLWithTTL(baseURL string, ttlSeconds uint16) (string, error) {
parsedURL, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("failed to parse base URL: %w", err)
}

// Use custom TTL if provided, otherwise use default
ttl := g.ttl
if ttlSeconds > 0 {
ttl = time.Duration(ttlSeconds) * time.Second
}

// Calculate expiration time
expiresAt := time.Now().Add(ttl).Unix()

// Get existing query parameters
queryParams := parsedURL.Query()
queryParams.Set("expires", strconv.FormatInt(expiresAt, 10))

// Create the string to sign (without signature)
parsedURL.RawQuery = queryParams.Encode()
stringToSign := parsedURL.String()

// Generate signature
signature := g.generateSignature(stringToSign)

// Add signature to query parameters
queryParams.Set("signature", signature)
parsedURL.RawQuery = queryParams.Encode()

return parsedURL.String(), nil
}

// VerifySignedURL verifies a signed URL's signature and expiration
func (g *SignedURLGenerator) VerifySignedURL(signedURL string) error {
parsedURL, err := url.Parse(signedURL)
if err != nil {
return fmt.Errorf("failed to parse signed URL: %w", err)
}

queryParams := parsedURL.Query()

// Check for required parameters
expiresStr := queryParams.Get("expires")
providedSignature := queryParams.Get("signature")

if expiresStr == "" || providedSignature == "" {
return ErrSignedURLMissingParams
}

// Check expiration
expiresAt, err := strconv.ParseInt(expiresStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid expires parameter: %w", err)
}

if time.Now().Unix() > expiresAt {
return ErrSignedURLExpired
}

// Verify signature
// Remove signature from query params to get the original string to sign
queryParams.Del("signature")
parsedURL.RawQuery = queryParams.Encode()
stringToSign := parsedURL.String()

expectedSignature := g.generateSignature(stringToSign)

if !hmac.Equal([]byte(providedSignature), []byte(expectedSignature)) {
return ErrSignedURLInvalidSignature
}

return nil
}

// generateSignature creates an HMAC-SHA256 signature for the given data
func (g *SignedURLGenerator) generateSignature(data string) string {
h := hmac.New(sha256.New, g.secretKey)
h.Write([]byte(data))
signature := base64.URLEncoding.EncodeToString(h.Sum(nil))
return signature
}
Loading