Skip to content

Commit 975e26c

Browse files
cole-rgbclaude
andcommitted
Fix linting issues and add web frontend
Linting fixes: - Fix unchecked error returns (errcheck) - Update AWS SDK to use non-deprecated BaseEndpoint option - Use t.Setenv() in tests instead of os.Setenv() Frontend: - Add static HTML/CSS/JS frontend for file management - Serve frontend at root path - Support login, register, create groups, upload/download/delete files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bd16f32 commit 975e26c

12 files changed

Lines changed: 693 additions & 64 deletions

File tree

Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ COPY --from=builder /app/api /api
3131
# Copy migrations
3232
COPY --from=builder /app/migrations /migrations
3333

34+
# Copy static files (frontend)
35+
COPY --from=builder /app/static /static
36+
37+
# Set working directory so relative paths work
38+
WORKDIR /
39+
3440
# Expose port
3541
EXPOSE 8080
3642

cmd/api/main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ func main() {
3535
if err != nil {
3636
log.Fatalf("Failed to connect to database: %v", err)
3737
}
38-
defer db.Close()
38+
defer func() {
39+
if err := db.Close(); err != nil {
40+
log.Printf("Error closing database: %v", err)
41+
}
42+
}()
3943

4044
db.SetMaxOpenConns(cfg.Database.MaxOpenConns)
4145
db.SetMaxIdleConns(cfg.Database.MaxIdleConns)
@@ -97,8 +101,16 @@ func main() {
97101
// Health check
98102
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
99103
w.WriteHeader(http.StatusOK)
100-
w.Write([]byte("OK"))
104+
_, _ = w.Write([]byte("OK"))
105+
})
106+
107+
// Serve static files (frontend)
108+
staticDir := http.Dir("./static")
109+
fileServer := http.FileServer(staticDir)
110+
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
111+
http.ServeFile(w, r, "./static/index.html")
101112
})
113+
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
102114

103115
// API routes
104116
r.Route("/api/v1", func(r chi.Router) {

internal/auth/handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
213213
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
214214
w.Header().Set("Content-Type", "application/json")
215215
w.WriteHeader(status)
216-
json.NewEncoder(w).Encode(data)
216+
_ = json.NewEncoder(w).Encode(data)
217217
}
218218

219219
func respondError(w http.ResponseWriter, message string, status int) {

internal/config/config_test.go

Lines changed: 15 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
11
package config
22

33
import (
4-
"os"
54
"testing"
65
"time"
76
)
87

98
func TestLoad(t *testing.T) {
109
// Set required environment variables
11-
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
12-
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes")
13-
os.Setenv("S3_BUCKET", "test-bucket")
14-
defer func() {
15-
os.Unsetenv("DATABASE_URL")
16-
os.Unsetenv("JWT_SECRET")
17-
os.Unsetenv("S3_BUCKET")
18-
}()
10+
t.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
11+
t.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes")
12+
t.Setenv("S3_BUCKET", "test-bucket")
1913

2014
cfg, err := Load()
2115
if err != nil {
@@ -36,10 +30,8 @@ func TestLoad(t *testing.T) {
3630
}
3731

3832
func TestLoadMissingRequired(t *testing.T) {
39-
// Clear any existing env vars
40-
os.Unsetenv("DATABASE_URL")
41-
os.Unsetenv("JWT_SECRET")
42-
os.Unsetenv("S3_BUCKET")
33+
// t.Setenv automatically clears after test
34+
// Don't set required vars to test missing validation
4335

4436
_, err := Load()
4537
if err == nil {
@@ -48,14 +40,9 @@ func TestLoadMissingRequired(t *testing.T) {
4840
}
4941

5042
func TestValidateJWTSecretLength(t *testing.T) {
51-
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
52-
os.Setenv("JWT_SECRET", "short") // Too short
53-
os.Setenv("S3_BUCKET", "test-bucket")
54-
defer func() {
55-
os.Unsetenv("DATABASE_URL")
56-
os.Unsetenv("JWT_SECRET")
57-
os.Unsetenv("S3_BUCKET")
58-
}()
43+
t.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
44+
t.Setenv("JWT_SECRET", "short") // Too short
45+
t.Setenv("S3_BUCKET", "test-bucket")
5946

6047
_, err := Load()
6148
if err == nil {
@@ -64,49 +51,39 @@ func TestValidateJWTSecretLength(t *testing.T) {
6451
}
6552

6653
func TestGetEnvDefaults(t *testing.T) {
67-
os.Unsetenv("TEST_VAR")
68-
69-
if got := getEnv("TEST_VAR", "default"); got != "default" {
54+
if got := getEnv("TEST_VAR_NONEXISTENT", "default"); got != "default" {
7055
t.Errorf("expected default, got %s", got)
7156
}
7257
}
7358

7459
func TestGetIntEnv(t *testing.T) {
75-
os.Setenv("TEST_INT", "42")
76-
defer os.Unsetenv("TEST_INT")
60+
t.Setenv("TEST_INT", "42")
7761

7862
if got := getIntEnv("TEST_INT", 0); got != 42 {
7963
t.Errorf("expected 42, got %d", got)
8064
}
8165
}
8266

8367
func TestGetBoolEnv(t *testing.T) {
84-
os.Setenv("TEST_BOOL", "true")
85-
defer os.Unsetenv("TEST_BOOL")
68+
t.Setenv("TEST_BOOL", "true")
8669

8770
if got := getBoolEnv("TEST_BOOL", false); !got {
8871
t.Error("expected true")
8972
}
9073
}
9174

9275
func TestGetDurationEnv(t *testing.T) {
93-
os.Setenv("TEST_DURATION", "30s")
94-
defer os.Unsetenv("TEST_DURATION")
76+
t.Setenv("TEST_DURATION", "30s")
9577

9678
if got := getDurationEnv("TEST_DURATION", time.Second); got != 30*time.Second {
9779
t.Errorf("expected 30s, got %v", got)
9880
}
9981
}
10082

10183
func TestDefaultValues(t *testing.T) {
102-
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
103-
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes")
104-
os.Setenv("S3_BUCKET", "test-bucket")
105-
defer func() {
106-
os.Unsetenv("DATABASE_URL")
107-
os.Unsetenv("JWT_SECRET")
108-
os.Unsetenv("S3_BUCKET")
109-
}()
84+
t.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
85+
t.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes")
86+
t.Setenv("S3_BUCKET", "test-bucket")
11087

11188
cfg, err := Load()
11289
if err != nil {

internal/file/handler.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
6565
respondError(w, "File is required", http.StatusBadRequest)
6666
return
6767
}
68-
defer file.Close()
68+
defer func() { _ = file.Close() }()
6969

7070
// Get content type
7171
contentType := header.Header.Get("Content-Type")
@@ -174,15 +174,15 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
174174
}
175175
return
176176
}
177-
defer body.Close()
177+
defer func() { _ = body.Close() }()
178178

179179
// Set headers
180180
w.Header().Set("Content-Type", file.ContentType)
181181
w.Header().Set("Content-Disposition", "attachment; filename=\""+file.Name+"\"")
182182
w.Header().Set("Content-Length", strconv.FormatInt(file.SizeBytes, 10))
183183

184184
// Stream the file
185-
io.Copy(w, body)
185+
_, _ = io.Copy(w, body)
186186
}
187187

188188
// Delete handles file deletion
@@ -221,7 +221,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
221221
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
222222
w.Header().Set("Content-Type", "application/json")
223223
w.WriteHeader(status)
224-
json.NewEncoder(w).Encode(data)
224+
_ = json.NewEncoder(w).Encode(data)
225225
}
226226

227227
func respondError(w http.ResponseWriter, message string, status int) {

internal/file/repository.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ func (r *PostgresRepository) ListByGroupID(ctx context.Context, groupID uuid.UUI
8787
if err != nil {
8888
return nil, err
8989
}
90-
defer rows.Close()
90+
defer func() { _ = rows.Close() }()
9191

9292
var files []*File
9393
for rows.Next() {

internal/file/service.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ func (s *Service) Upload(ctx context.Context, input *UploadFileInput, body io.Re
7272
}
7373

7474
if err := s.repo.Create(ctx, file); err != nil {
75-
// Try to clean up S3 file on failure
76-
s.storage.Delete(ctx, s3Key)
75+
// Try to clean up S3 file on failure (best effort)
76+
_ = s.storage.Delete(ctx, s3Key)
7777
return nil, err
7878
}
7979

@@ -162,7 +162,7 @@ func (s *Service) Delete(ctx context.Context, fileID, userID uuid.UUID) error {
162162
}
163163

164164
// Delete from S3 (best effort)
165-
s.storage.Delete(ctx, file.S3Key)
165+
_ = s.storage.Delete(ctx, file.S3Key)
166166

167167
return nil
168168
}

internal/file/storage.go

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,9 @@ func NewS3Storage(ctx context.Context, cfg *S3Config) (*S3Storage, error) {
4444
var err error
4545

4646
if cfg.Endpoint != "" {
47-
// Custom endpoint (MinIO, localstack)
48-
customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
49-
return aws.Endpoint{
50-
URL: cfg.Endpoint,
51-
HostnameImmutable: true,
52-
}, nil
53-
})
54-
47+
// Custom endpoint (MinIO, localstack) with static credentials
5548
awsCfg, err = config.LoadDefaultConfig(ctx,
5649
config.WithRegion(cfg.Region),
57-
config.WithEndpointResolverWithOptions(customResolver),
5850
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
5951
cfg.AccessKeyID,
6052
cfg.SecretAccessKey,
@@ -72,8 +64,12 @@ func NewS3Storage(ctx context.Context, cfg *S3Config) (*S3Storage, error) {
7264
return nil, fmt.Errorf("failed to load AWS config: %w", err)
7365
}
7466

67+
// Create S3 client with optional custom endpoint
7568
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
7669
o.UsePathStyle = cfg.UsePathStyle
70+
if cfg.Endpoint != "" {
71+
o.BaseEndpoint = aws.String(cfg.Endpoint)
72+
}
7773
})
7874

7975
uploader := manager.NewUploader(client, func(u *manager.Uploader) {

internal/group/handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ func (h *Handler) RemoveMember(w http.ResponseWriter, r *http.Request) {
215215
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
216216
w.Header().Set("Content-Type", "application/json")
217217
w.WriteHeader(status)
218-
json.NewEncoder(w).Encode(data)
218+
_ = json.NewEncoder(w).Encode(data)
219219
}
220220

221221
func respondError(w http.ResponseWriter, message string, status int) {

internal/group/repository.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func (r *PostgresRepository) ListByUserID(ctx context.Context, userID uuid.UUID)
9393
if err != nil {
9494
return nil, err
9595
}
96-
defer rows.Close()
96+
defer func() { _ = rows.Close() }()
9797

9898
var groups []*Group
9999
for rows.Next() {
@@ -172,7 +172,7 @@ func (r *PostgresRepository) ListMembers(ctx context.Context, groupID uuid.UUID)
172172
if err != nil {
173173
return nil, err
174174
}
175-
defer rows.Close()
175+
defer func() { _ = rows.Close() }()
176176

177177
var members []*Membership
178178
for rows.Next() {
@@ -192,7 +192,7 @@ func (r *PostgresRepository) GetUserGroupIDs(ctx context.Context, userID uuid.UU
192192
if err != nil {
193193
return nil, err
194194
}
195-
defer rows.Close()
195+
defer func() { _ = rows.Close() }()
196196

197197
var groupIDs []uuid.UUID
198198
for rows.Next() {

0 commit comments

Comments
 (0)