Compare commits

..

1 Commits

Author SHA1 Message Date
Flavio Fois
fa1f65baf7 add support for SQLite as an alternative database backend
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 33s
Implement SQLite support using the pure Go `modernc.org/sqlite` driver and update the migration system to handle driver-specific schemas. Users can now choose between MySQL and SQLite by setting the `DB_DRIVER` environment variable.
2026-03-29 17:46:27 +02:00
27 changed files with 378 additions and 948 deletions

View File

@@ -1,17 +1,34 @@
# Server Settings
PORT=8080
# DB Settings (usato per sviluppo locale; in Docker il DSN è costruito dal compose)
# Infrastruttura Docker (Traefik + MySQL)
API_DOMAIN=api.esempio.com
ACME_EMAIL=tua@email.com
MYSQL_ROOT_PASSWORD=password-sicura
# DB Settings
# DB_DRIVER: "mysql" (default) o "sqlite"
DB_DRIVER=mysql
# MySQL
DB_DSN=root:secret@tcp(127.0.0.1:3306)/emly?parseTime=true&loc=UTC
DB_MAX_OPEN_CONNS=25
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME=5
DATABASE_NAME=emly
# SQLite (usare invece di MySQL: DB_DRIVER=sqlite, DB_DSN=./data.db, DATABASE_NAME non necessario)
# DB_DSN=./data.db
# API Keys
API_KEY=key-one
ADMIN_KEY=admin-key-one
# Rate Limiting — Traefik edge (condiviso tra repliche)
TRAEFIK_RL_AVERAGE=30
TRAEFIK_RL_BURST=10
TRAEFIK_RL_PERIOD=1m
# Rate Limiting — App (unauthenticated: no X-API-Key / X-Admin-Key)
RL_UNAUTH_MAX_REQS=10
RL_UNAUTH_WINDOW=5m
@@ -23,12 +40,3 @@ RL_AUTH_MAX_REQS=100
RL_AUTH_WINDOW=1m
RL_AUTH_MAX_FAILS=20
RL_AUTH_BAN_DUR=5m
# Cloudflare R2 Storage
USE_S3_COMPATIBLE_STORAGE=false
CF_ACCOUNT_ID=your-cloudflare-account-id
CF_R2_ACCESS_KEY_ID=your-r2-access-key-id
CF_R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
CF_R2_BUCKET_NAME=your-bucket-name
CF_R2_REGION=auto
CF_R2_ENDPOINT=https://your-endpoint.r2.cloudflarestorage.com

3
.gitignore vendored
View File

@@ -39,3 +39,6 @@ go.work.sum
tmp/
build/
# Database files
*.db

View File

@@ -13,21 +13,6 @@ services:
DB_MAX_OPEN_CONNS: ${DB_MAX_OPEN_CONNS:-25}
DB_MAX_IDLE_CONNS: ${DB_MAX_IDLE_CONNS:-5}
DB_CONN_MAX_LIFETIME: ${DB_CONN_MAX_LIFETIME:-5}
RL_UNAUTH_MAX_REQS: ${RL_UNAUTH_MAX_REQS:-10}
RL_UNAUTH_WINDOW: ${RL_UNAUTH_WINDOW:-5m}
RL_UNAUTH_MAX_FAILS: ${RL_UNAUTH_MAX_FAILS:-5}
RL_UNAUTH_BAN_DUR: ${RL_UNAUTH_BAN_DUR:-15m}
RL_AUTH_MAX_REQS: ${RL_AUTH_MAX_REQS:-100}
RL_AUTH_WINDOW: ${RL_AUTH_WINDOW:-1m}
RL_AUTH_MAX_FAILS: ${RL_AUTH_MAX_FAILS:-20}
RL_AUTH_BAN_DUR: ${RL_AUTH_BAN_DUR:-5m}
USE_S3_COMPATIBLE_STORAGE: ${USE_S3_COMPATIBLE_STORAGE:-false}
CF_ACCOUNT_ID: ${CF_ACCOUNT_ID}
CF_R2_ACCESS_KEY_ID: ${CF_R2_ACCESS_KEY_ID}
CF_R2_SECRET_ACCESS_KEY: ${CF_R2_SECRET_ACCESS_KEY}
CF_R2_BUCKET_NAME: ${CF_R2_BUCKET_NAME}
CF_R2_REGION: ${CF_R2_REGION:-auto}
CF_R2_ENDPOINT: ${CF_R2_ENDPOINT}
volumes:
- logs:/logs
logging:

28
go.mod
View File

@@ -3,11 +3,6 @@ module emly-api-go
go 1.26
require (
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.18
github.com/aws/aws-sdk-go-v2/credentials v1.19.17
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.19
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
github.com/go-chi/chi/v5 v5.2.4
github.com/go-chi/httprate v0.14.1
github.com/go-sql-driver/mysql v1.8.1
@@ -16,21 +11,16 @@ require (
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)
require (

57
go.sum
View File

@@ -1,45 +1,9 @@
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q=
github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.19 h1:VH0xfFwHfPYhu+EcxyCcw3VTZskpbA+/s0pTXwhSsL8=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.19/go.mod h1:S/XkAXcnCpzwsjC9EU0BakuvreXfSTUADHb7rC7jvaQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
@@ -47,17 +11,34 @@ github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5c
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=

View File

@@ -2,7 +2,6 @@ package config
import (
"os"
"regexp"
"strconv"
"strings"
"sync"
@@ -20,17 +19,9 @@ type RateLimitConfig struct {
AuthBanDur time.Duration
}
type R2Config struct {
AccountID string
AccessKeyID string
SecretAccessKey string
BucketName string
Region string
Endpoint string
}
type Config struct {
Port string
Driver string
DSN string
Database string
APIKey string
@@ -38,10 +29,7 @@ type Config struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime int
UpdatesEnabled bool
UseS3CompatibleStorage bool
RateLimit RateLimitConfig
R2 R2Config
}
var (
@@ -93,18 +81,19 @@ func load() *Config {
connMaxLifetime = 5
}
dbName := os.Getenv("DATABASE_NAME")
driver := os.Getenv("DB_DRIVER")
if driver == "" {
driver = "mysql"
}
var dbName string
if driver == "sqlite" {
dbName = "main"
} else {
dbName = os.Getenv("DATABASE_NAME")
if dbName == "" {
panic("DATABASE_NAME environment variable is required")
}
dbNameRegex := regexp.MustCompile("^[a-zA-Z0-9_]+$")
// Test the regex against the dbName, otherwise panic to prevent potential SQL injection
validDbName, err := regexp.Match(dbNameRegex.String(), []byte(dbName))
if err != nil {
panic("failed to validate database name: " + err.Error())
}
if !validDbName {
panic("invalid database name: must match regex " + dbNameRegex.String())
}
if os.Getenv("DB_DSN") == "" {
@@ -113,6 +102,7 @@ func load() *Config {
return &Config{
Port: port,
Driver: driver,
DSN: os.Getenv("DB_DSN"),
Database: dbName,
APIKey: apiKey,
@@ -120,16 +110,6 @@ func load() *Config {
MaxOpenConns: maxOpenConns,
MaxIdleConns: maxIdleConns,
ConnMaxLifetime: connMaxLifetime,
UpdatesEnabled: strings.ToLower(strings.TrimSpace(os.Getenv("UPDATES_ENABLED"))) == "true",
UseS3CompatibleStorage: strings.ToLower(strings.TrimSpace(os.Getenv("USE_S3_COMPATIBLE_STORAGE"))) == "true",
R2: R2Config{
AccountID: os.Getenv("CF_ACCOUNT_ID"),
AccessKeyID: os.Getenv("CF_R2_ACCESS_KEY_ID"),
SecretAccessKey: os.Getenv("CF_R2_SECRET_ACCESS_KEY"),
BucketName: os.Getenv("CF_R2_BUCKET_NAME"),
Region: envString("CF_R2_REGION", "auto"),
Endpoint: os.Getenv("CF_R2_ENDPOINT"),
},
RateLimit: RateLimitConfig{
UnauthMaxReqs: envInt("RL_UNAUTH_MAX_REQS", 10),
UnauthWindow: envDuration("RL_UNAUTH_WINDOW", 5*time.Minute),
@@ -143,13 +123,6 @@ func load() *Config {
}
}
func envString(key, fallback string) string {
if s := os.Getenv(key); s != "" {
return s
}
return fallback
}
func envInt(key string, fallback int) int {
if s := os.Getenv(key); s != "" {
if n, err := strconv.Atoi(s); err == nil {

View File

@@ -1,23 +1,41 @@
package database
import (
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
"emly-api-go/internal/config"
)
func Connect(cfg *config.Config) (*sqlx.DB, error) {
db, err := sqlx.Connect("mysql", cfg.DSN)
var db *sqlx.DB
var err error
switch cfg.Driver {
case "sqlite":
db, err = sqlx.Connect("sqlite", cfg.DSN)
if err != nil {
return nil, err
}
// Enable foreign key support (disabled by default in SQLite)
if _, err = db.Exec("PRAGMA foreign_keys = ON"); err != nil {
return nil, fmt.Errorf("sqlite: enable foreign_keys: %w", err)
}
case "mysql":
db, err = sqlx.Connect("mysql", cfg.DSN)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute)
default:
return nil, fmt.Errorf("unsupported DB_DRIVER %q: must be mysql or sqlite", cfg.Driver)
}
return db, nil
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/jmoiron/sqlx"
)
//go:embed init.sql migrations/*.json migrations/*.sql
//go:embed mysql sqlite
var migrationsFS embed.FS
type taskFile struct {
@@ -31,62 +31,43 @@ type condition struct {
Index string `json:"index,omitempty"`
}
// Migrate reads migrations/tasks.json and executes every task whose
// conditions are ALL satisfied (i.e. logical AND).
func Migrate(db *sqlx.DB, dbName string) error {
// If the database has no tables at all, bootstrap with init.sql.
empty, err := schemaIsEmpty(db, dbName)
// Migrate reads the driver-specific migrations and applies them.
func Migrate(db *sqlx.DB, dbName string, driver string) error {
empty, err := schemaIsEmpty(db, dbName, driver)
if err != nil {
return fmt.Errorf("schema: check empty: %w", err)
}
if empty {
log.Println("[migrate] empty schema detected running init.sql")
initSQL, err := migrationsFS.ReadFile("init.sql")
if err != nil {
return fmt.Errorf("schema: read init.sql: %w", err)
if err := runInitSQL(db, driver); err != nil {
return err
}
for _, stmt := range splitStatements(string(initSQL)) {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt)
}
}
log.Println("[migrate] init.sql applied base schema created")
} else {
log.Println("[migrate] checking if tables exist")
// Check if the tables are there or not
var tableNames []string
tableNames := []string{"bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session"}
var foundTables []string
tableNames = append(tableNames, "bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session")
for _, tableName := range tableNames {
found, err := tableExists(db, dbName, tableName)
found, err := tableExists(db, dbName, tableName, driver)
if err != nil {
return fmt.Errorf("schema: check table %s: %w", tableName, err)
}
if !found {
log.Printf("[migrate] warning: expected table %s not found schema may be in an inconsistent state", tableName)
log.Printf("[migrate] warning: expected table %s not found", tableName)
continue
}
foundTables = append(foundTables, tableName)
}
if len(foundTables) != len(tableNames) {
log.Printf("[migrate] warning: expected %d tables, found %d", len(tableNames), len(foundTables))
log.Printf("[migrate] info: running init.sql")
initSQL, err := migrationsFS.ReadFile("init.sql")
if err != nil {
return fmt.Errorf("schema: read init.sql: %w", err)
log.Printf("[migrate] warning: expected %d tables, found %d running init.sql", len(tableNames), len(foundTables))
if err := runInitSQL(db, driver); err != nil {
return err
}
for _, stmt := range splitStatements(string(initSQL)) {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt)
}
}
log.Println("[migrate] init.sql applied base schema created")
} else {
log.Println("[migrate] all expected tables found skipping init.sql")
}
}
raw, err := migrationsFS.ReadFile("migrations/tasks.json")
raw, err := migrationsFS.ReadFile(driver + "/migrations/tasks.json")
if err != nil {
return fmt.Errorf("schema: read tasks.json: %w", err)
}
@@ -97,7 +78,7 @@ func Migrate(db *sqlx.DB, dbName string) error {
}
for _, t := range tf.Tasks {
needed, err := shouldRun(db, dbName, t.Conditions)
needed, err := shouldRun(db, dbName, t.Conditions, driver)
if err != nil {
return fmt.Errorf("schema: evaluate conditions for %s: %w", t.ID, err)
}
@@ -106,7 +87,7 @@ func Migrate(db *sqlx.DB, dbName string) error {
continue
}
sqlBytes, err := migrationsFS.ReadFile("migrations/" + t.SQLFile)
sqlBytes, err := migrationsFS.ReadFile(driver + "/migrations/" + t.SQLFile)
if err != nil {
return fmt.Errorf("schema: read %s: %w", t.SQLFile, err)
}
@@ -122,11 +103,25 @@ func Migrate(db *sqlx.DB, dbName string) error {
return nil
}
func runInitSQL(db *sqlx.DB, driver string) error {
initSQL, err := migrationsFS.ReadFile(driver + "/init.sql")
if err != nil {
return fmt.Errorf("schema: read init.sql: %w", err)
}
for _, stmt := range splitStatements(string(initSQL)) {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt)
}
}
log.Println("[migrate] init.sql applied base schema created")
return nil
}
// ---------- Condition evaluator ----------
func shouldRun(db *sqlx.DB, dbName string, conds []condition) (bool, error) {
func shouldRun(db *sqlx.DB, dbName string, conds []condition, driver string) (bool, error) {
for _, c := range conds {
met, err := evaluate(db, dbName, c)
met, err := evaluate(db, dbName, c, driver)
if err != nil {
return false, err
}
@@ -137,81 +132,186 @@ func shouldRun(db *sqlx.DB, dbName string, conds []condition) (bool, error) {
return false, nil
}
func evaluate(db *sqlx.DB, dbName string, c condition) (bool, error) {
func evaluate(db *sqlx.DB, dbName string, c condition, driver string) (bool, error) {
switch c.Type {
case "column_not_exists":
exists, err := columnExists(db, dbName, c.Table, c.Column)
exists, err := columnExists(db, dbName, c.Table, c.Column, driver)
return !exists, err
case "column_exists":
return columnExists(db, dbName, c.Table, c.Column)
return columnExists(db, dbName, c.Table, c.Column, driver)
case "index_not_exists":
exists, err := indexExists(db, dbName, c.Table, c.Index)
exists, err := indexExists(db, dbName, c.Table, c.Index, driver)
return !exists, err
case "index_exists":
return indexExists(db, dbName, c.Table, c.Index)
return indexExists(db, dbName, c.Table, c.Index, driver)
case "table_not_exists":
exists, err := tableExists(db, dbName, c.Table)
exists, err := tableExists(db, dbName, c.Table, driver)
return !exists, err
case "table_exists":
return tableExists(db, dbName, c.Table)
return tableExists(db, dbName, c.Table, driver)
default:
return false, fmt.Errorf("unknown condition type: %s", c.Type)
}
}
func columnExists(db *sqlx.DB, dbName, table, column string) (bool, error) {
// ---------- MySQL condition checks ----------
func columnExistsMySQL(db *sqlx.DB, dbName, table, column string) (bool, error) {
var count int
err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?
AND COLUMN_NAME = ?`, dbName, table, column)
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
dbName, table, column)
return count > 0, err
}
func indexExists(db *sqlx.DB, dbName, table, index string) (bool, error) {
func indexExistsMySQL(db *sqlx.DB, dbName, table, index string) (bool, error) {
var count int
err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?
AND INDEX_NAME = ?`, dbName, table, index)
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?`,
dbName, table, index)
return count > 0, err
}
func tableExists(db *sqlx.DB, dbName, table string) (bool, error) {
func tableExistsMySQL(db *sqlx.DB, dbName, table string) (bool, error) {
var count int
err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?`, dbName, table)
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
dbName, table)
return count > 0, err
}
func schemaIsEmpty(db *sqlx.DB, dbName string) (bool, error) {
func schemaIsEmptyMySQL(db *sqlx.DB, dbName string) (bool, error) {
var count int
err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?`, dbName)
`SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`, dbName)
return count == 0, err
}
// splitStatements splits a SQL blob on ";" respecting only top-level
// semicolons (good enough for simple ALTER / CREATE statements).
func splitStatements(sql string) []string {
raw := strings.Split(sql, ";")
out := make([]string, 0, len(raw))
for _, s := range raw {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
// ---------- SQLite condition checks ----------
func columnExistsSQLite(db *sqlx.DB, table, column string) (bool, error) {
var count int
// pragma_table_info is a table-valued function available since SQLite 3.16.0
err := db.Get(&count,
fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table),
column)
return count > 0, err
}
func indexExistsSQLite(db *sqlx.DB, table, index string) (bool, error) {
var count int
err := db.Get(&count,
`SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND tbl_name=? AND name=?`,
table, index)
return count > 0, err
}
func tableExistsSQLite(db *sqlx.DB, table string) (bool, error) {
var count int
err := db.Get(&count,
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table)
return count > 0, err
}
func schemaIsEmptySQLite(db *sqlx.DB) (bool, error) {
var count int
err := db.Get(&count, `SELECT COUNT(*) FROM sqlite_master WHERE type='table'`)
return count == 0, err
}
// ---------- Driver-dispatched wrappers ----------
func columnExists(db *sqlx.DB, dbName, table, column, driver string) (bool, error) {
if driver == "sqlite" {
return columnExistsSQLite(db, table, column)
}
return columnExistsMySQL(db, dbName, table, column)
}
func indexExists(db *sqlx.DB, dbName, table, index, driver string) (bool, error) {
if driver == "sqlite" {
return indexExistsSQLite(db, table, index)
}
return indexExistsMySQL(db, dbName, table, index)
}
func tableExists(db *sqlx.DB, dbName, table, driver string) (bool, error) {
if driver == "sqlite" {
return tableExistsSQLite(db, table)
}
return tableExistsMySQL(db, dbName, table)
}
func schemaIsEmpty(db *sqlx.DB, dbName, driver string) (bool, error) {
if driver == "sqlite" {
return schemaIsEmptySQLite(db)
}
return schemaIsEmptyMySQL(db, dbName)
}
// splitStatements splits a SQL blob on top-level ";" only, respecting
// BEGIN...END blocks (e.g. triggers) so their inner semicolons are not split.
func splitStatements(sql string) []string {
var out []string
var buf strings.Builder
depth := 0
n := len(sql)
for i := 0; i < n; {
c := sql[i]
// Collect whole identifier tokens to detect BEGIN / END keywords.
if isIdentStart(c) {
j := i
for j < n && isIdentChar(sql[j]) {
j++
}
word := strings.ToUpper(sql[i:j])
switch word {
case "BEGIN":
depth++
case "END":
if depth > 0 {
depth--
}
}
buf.WriteString(sql[i:j])
i = j
continue
}
if c == ';' && depth == 0 {
if stmt := strings.TrimSpace(buf.String()); stmt != "" {
out = append(out, stmt)
}
buf.Reset()
i++
continue
}
buf.WriteByte(c)
i++
}
if stmt := strings.TrimSpace(buf.String()); stmt != "" {
out = append(out, stmt)
}
return out
}
func isIdentStart(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
}
func isIdentChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
}

View File

@@ -19,13 +19,5 @@
{ "type": "column_not_exists", "table": "user", "column": "enabled" }
]
}
,{
"id": "3_updates",
"sql_file": "3_updates.sql",
"description": "Create update_releases table for API-managed software updates.",
"conditions": [
{ "type": "table_not_exists", "table": "update_releases" }
]
}
]
}

View File

@@ -0,0 +1,65 @@
CREATE TABLE IF NOT EXISTS bug_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
description TEXT NOT NULL,
hwid TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
os_user TEXT NOT NULL DEFAULT '',
submitter_ip TEXT NOT NULL DEFAULT '',
system_info TEXT NULL,
status TEXT NOT NULL DEFAULT 'new' CHECK(status IN ('new','in_review','resolved','closed')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_status ON bug_reports(status);
CREATE INDEX IF NOT EXISTS idx_hwid ON bug_reports(hwid);
CREATE INDEX IF NOT EXISTS idx_hostname ON bug_reports(hostname);
CREATE INDEX IF NOT EXISTS idx_os_user ON bug_reports(os_user);
CREATE INDEX IF NOT EXISTS idx_created_at ON bug_reports(created_at);
CREATE TRIGGER IF NOT EXISTS trg_bug_reports_updated_at
AFTER UPDATE ON bug_reports
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE bug_reports SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
CREATE TABLE IF NOT EXISTS bug_report_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id INTEGER NOT NULL,
file_role TEXT NOT NULL CHECK(file_role IN ('screenshot','mail_file','localstorage','config','system_info')),
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
file_size INTEGER NOT NULL DEFAULT 0,
data BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES bug_reports(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_report_id ON bug_report_files(report_id);
CREATE TABLE IF NOT EXISTS rate_limit_hwid (
hwid TEXT PRIMARY KEY,
window_start DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
displayname TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,3 @@
{
"tasks": []
}

View File

@@ -198,8 +198,8 @@ func ValidateSession(db *sqlx.DB) http.HandlerFunc {
sessionID,
)
if err != nil {
log.Printf("[AUTH] Database error during session validation: %v", err)
jsonError(w, http.StatusInternalServerError, "internal server error")
jsonError(w, http.StatusUnauthorized, "invalid session")
log.Fatalf("Database error during session validation: %v", err)
return
}

View File

@@ -3,7 +3,6 @@ package handlers
import (
"archive/zip"
"bytes"
"context"
"database/sql"
"embed"
"encoding/json"
@@ -22,8 +21,6 @@ import (
"github.com/jmoiron/sqlx"
"emly-api-go/internal/models"
"emly-api-go/internal/storage"
"emly-api-go/internal/timing"
)
//go:embed templates/report.txt.tmpl
@@ -44,13 +41,12 @@ var fileRoles = []struct {
{"config", models.FileRoleConfig, "application/json"},
}
func CreateBugReport(db *sqlx.DB, dbName string, s3conn *storage.S3Connector) http.HandlerFunc {
func CreateBugReport(db *sqlx.DB, dbName string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
timing.Mark(r.Context(), "parse_form")
name := r.FormValue("name")
email := r.FormValue("email")
@@ -88,7 +84,6 @@ func CreateBugReport(db *sqlx.DB, dbName string, s3conn *storage.S3Connector) ht
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
timing.Mark(r.Context(), "db_insert_report")
reportID, err := result.LastInsertId()
if err != nil {
@@ -125,7 +120,7 @@ func CreateBugReport(db *sqlx.DB, dbName string, s3conn *storage.S3Connector) ht
log.Printf("[BUGREPORT] File uploaded: role=%s size=%d bytes", fr.role, len(data))
fileResult, err := db.ExecContext(r.Context(),
_, err = db.ExecContext(r.Context(),
fmt.Sprintf("INSERT INTO %s.bug_report_files (report_id, file_role, filename, mime_type, file_size, data) VALUES (?, ?, ?, ?, ?, ?)", dbName),
reportID, fr.role, filename, mimeType, len(data), data,
)
@@ -133,25 +128,6 @@ func CreateBugReport(db *sqlx.DB, dbName string, s3conn *storage.S3Connector) ht
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
timing.Mark(r.Context(), "db_insert_file_"+string(fr.role))
if s3conn != nil {
fileID, err := fileResult.LastInsertId()
if err != nil {
log.Printf("[S3] could not get file insert id for report %d role %s: %v", reportID, fr.role, err)
} else {
s3Key := fmt.Sprintf("emly-api-files/bug-reports/%d/files/%s", reportID, filename)
if _, err := s3conn.UploadFile(
context.Background(), s3Key,
bytes.NewReader(data), mimeType,
map[string]string{"filename": filename, "id": strconv.FormatInt(fileID, 10)},
); err != nil {
log.Printf("[S3] upload failed for key %s: %v", s3Key, err)
} else {
timing.Mark(r.Context(), "s3_upload_file_"+string(fr.role))
}
}
}
}
log.Printf("[BUGREPORT] Created successfully with id=%d", reportID)
@@ -201,7 +177,6 @@ func GetAllBugReports(db *sqlx.DB, dbName string) http.HandlerFunc {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
timing.Mark(r.Context(), "db_count")
mainQuery := fmt.Sprintf(`
SELECT br.*, COUNT(bf.id) as file_count
@@ -218,7 +193,6 @@ func GetAllBugReports(db *sqlx.DB, dbName string) http.HandlerFunc {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
timing.Mark(r.Context(), "db_select")
jsonOK(w, map[string]interface{}{
"data": reports,
@@ -306,7 +280,7 @@ func GetReportFilesByReportID(db *sqlx.DB, dbName string) http.HandlerFunc {
}
}
func GetBugReportZipByID(db *sqlx.DB, dbName string) http.HandlerFunc {
func GetBugReportZipById(db *sqlx.DB, dbName string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
@@ -324,14 +298,12 @@ func GetBugReportZipByID(db *sqlx.DB, dbName string) http.HandlerFunc {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
timing.Mark(r.Context(), "db_fetch_report")
var files []models.BugReportFile
if err := db.SelectContext(r.Context(), &files, fmt.Sprintf("SELECT * FROM %s.bug_report_files WHERE report_id = ?", dbName), id); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
timing.Mark(r.Context(), "db_fetch_files")
var sysInfoStr string
if len(report.SystemInfo) > 0 && string(report.SystemInfo) != "null" {
@@ -381,7 +353,6 @@ func GetBugReportZipByID(db *sqlx.DB, dbName string) http.HandlerFunc {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
timing.Mark(r.Context(), "zip_build")
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"report-%d.zip\"", report.ID))
@@ -393,7 +364,7 @@ func GetBugReportZipByID(db *sqlx.DB, dbName string) http.HandlerFunc {
}
}
func GetReportFileByFileID(db *sqlx.DB, dbName string, s3conn *storage.S3Connector) http.HandlerFunc {
func GetReportFileByFileID(db *sqlx.DB, dbName string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reportId := chi.URLParam(r, "id")
if reportId == "" {
@@ -406,44 +377,6 @@ func GetReportFileByFileID(db *sqlx.DB, dbName string, s3conn *storage.S3Connect
return
}
var filename string
if err := db.GetContext(r.Context(), &filename, fmt.Sprintf("SELECT filename FROM %s.bug_report_files WHERE report_id = ? AND id = ?", dbName), reportId, fileId); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
timing.Mark(r.Context(), "db_fetch_filename_by_id")
// Try S3 first.
if s3conn != nil {
s3Key := fmt.Sprintf("emly-api-files/bug-reports/%s/files/%s", reportId, filename)
rc, info, err := s3conn.GetFile(r.Context(), s3Key)
if err == nil {
defer rc.Close()
timing.Mark(r.Context(), "s3_hit")
log.Println("[S3] cache hit for key", s3Key)
mimeType := info.ContentType
if mimeType == "" {
mimeType = "application/octet-stream"
}
filename := info.Metadata["filename"]
if filename == "" {
filename = fileId
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
_, _ = io.Copy(w, rc)
return
}
if storage.IsNotFound(err) {
log.Printf("[S3] file %s not found on s3", fileId)
}
if !storage.IsNotFound(err) {
log.Printf("[S3] unexpected error fetching key %s: %v", s3Key, err)
}
}
// Fallback: query DB.
var file models.BugReportFile
err := db.GetContext(r.Context(), &file, fmt.Sprintf("SELECT filename, mime_type, data FROM %s.bug_report_files WHERE report_id = ? AND id = ?", dbName), reportId, fileId)
if errors.Is(err, sql.ErrNoRows) {
@@ -454,25 +387,6 @@ func GetReportFileByFileID(db *sqlx.DB, dbName string, s3conn *storage.S3Connect
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
timing.Mark(r.Context(), "db_select")
// Lazy-upload to S3 so future requests are served from there.
if s3conn != nil {
s3Key := fmt.Sprintf("emly-api-files/bug-reports/%s/files/%s", reportId, fileId)
dataCopy := make([]byte, len(file.Data))
copy(dataCopy, file.Data)
mime := file.MimeType
fname := file.Filename
go func() {
if _, err := s3conn.UploadFile(
context.Background(), s3Key,
bytes.NewReader(dataCopy), mime,
map[string]string{"filename": fname},
); err != nil {
log.Printf("[S3] lazy upload failed for key %s: %v", s3Key, err)
}
}()
}
mimeType := file.MimeType
if mimeType == "" {
@@ -480,7 +394,10 @@ func GetReportFileByFileID(db *sqlx.DB, dbName string, s3conn *storage.S3Connect
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", "attachment; filename=\""+file.Filename+"\"")
_, _ = w.Write(file.Data)
_, err = w.Write(file.Data)
if err != nil {
return
}
}
}
@@ -544,28 +461,21 @@ func DeleteBugReportByID(db *sqlx.DB, dbName string) http.HandlerFunc {
return
}
log.Printf("[BUGREPORT] Delete requested: report_id=%s", reportId)
result, err := db.ExecContext(r.Context(), fmt.Sprintf("DELETE FROM %s.bug_reports WHERE id = ?", dbName), reportId)
if err != nil {
log.Printf("[BUGREPORT] Delete failed: report_id=%s err=%v", reportId, err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("[BUGREPORT] Delete rows check failed: report_id=%s err=%v", reportId, err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
if rowsAffected == 0 {
log.Printf("[BUGREPORT] Delete skipped: report_id=%s not found", reportId)
jsonError(w, http.StatusNotFound, "bug report not found")
return
}
log.Printf("[BUGREPORT] Deleted successfully: report_id=%s rows=%d", reportId, rowsAffected)
jsonOK(w, map[string]string{"message": "bug report deleted successfully"})
}
}

View File

@@ -1,62 +0,0 @@
package middleware
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"emly-api-go/internal/timing"
)
// Timing is a middleware that measures per-request step durations.
//
// It injects a *timing.Timer into the request context so that handlers can
// record named checkpoints with timing.Mark(r.Context(), "step_name").
// After the handler returns, it logs a single line of the form:
//
// [TIMING] METHOD /path step1=1.2ms step2=18ms total=20ms
//
// Each step duration is measured from the previous checkpoint (or request
// start for the first one), so the values add up to the total.
func Timing(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, t := timing.NewContext(r.Context())
next.ServeHTTP(w, r.WithContext(ctx))
total := time.Since(t.Start)
checkpoints := t.Checkpoints()
if len(checkpoints) == 0 {
// No checkpoints: just log the total so every request is visible.
log.Printf("[TIMING] %s %s total=%s", r.Method, r.URL.Path, round(total))
return
}
parts := make([]string, 0, len(checkpoints)+1)
prev := t.Start
for _, cp := range checkpoints {
parts = append(parts, fmt.Sprintf("%s=%s", cp.Name, round(cp.At.Sub(prev))))
prev = cp.At
}
// Remainder after the last checkpoint.
if tail := total - prev.Sub(t.Start); tail > 0 {
parts = append(parts, fmt.Sprintf("response=%s", round(tail)))
}
parts = append(parts, fmt.Sprintf("total=%s", round(total)))
log.Printf("[TIMING] %s %s %s", r.Method, r.URL.Path, strings.Join(parts, " "))
})
}
func round(d time.Duration) string {
switch {
case d < time.Microsecond:
return fmt.Sprintf("%dns", d.Nanoseconds())
case d < time.Millisecond:
return fmt.Sprintf("%.2fµs", float64(d.Nanoseconds())/1e3)
default:
return fmt.Sprintf("%.2fms", float64(d.Nanoseconds())/1e6)
}
}

View File

@@ -1,18 +1,20 @@
package routes
import (
v2 "emly-api-go/internal/routes/v2"
"net/http"
v1 "emly-api-go/internal/routes/v1"
v2 "emly-api-go/internal/routes/v2"
"emly-api-go/internal/storage"
"github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx"
)
// RegisterAll mounts every versioned API onto the root router.
func RegisterAll(r chi.Router, db *sqlx.DB, s3conn *storage.S3Connector) {
// To add a new API version, create internal/routes/v2 and add:
//
// r.Mount("/v2", v2.NewRouter(db))
func RegisterAll(r chi.Router, db *sqlx.DB) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("emly-api-go"))
if err != nil {
@@ -20,6 +22,6 @@ func RegisterAll(r chi.Router, db *sqlx.DB, s3conn *storage.S3Connector) {
}
})
r.Mount("/v1", v1.NewRouter(db, s3conn))
r.Mount("/v2", v2.NewRouter(db, s3conn))
r.Mount("/v1", v1.NewRouter(db))
r.Mount("/v2", v2.NewRouter(db))
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/jmoiron/sqlx"
)
func registerAdmin(r chi.Router, db *sqlx.DB, dbName string) {
func registerAdmin(r chi.Router, db *sqlx.DB) {
r.Route("/admin", func(r chi.Router) {
// Auth — public, handles its own credential checks.
@@ -40,14 +40,5 @@ func registerAdmin(r chi.Router, db *sqlx.DB, dbName string) {
r.Post("/{id}/reset-password", handlers.ResetPassword(db))
r.Delete("/{id}", handlers.DeleteUser(db))
})
// Backward-compatible alias for admin-prefixed bug report delete path.
r.Route("/bug-reports", func(r chi.Router) {
r.Use(apimw.APIKeyAuth(db))
r.Use(apimw.AdminKeyAuth(db))
r.Use(httprate.LimitByIP(30, time.Minute))
r.Delete("/{id}", handlers.DeleteBugReportByID(db, dbName))
})
})
}

View File

@@ -5,14 +5,13 @@ import (
"time"
"emly-api-go/internal/handlers"
"emly-api-go/internal/storage"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"github.com/jmoiron/sqlx"
)
func registerBugReports(r chi.Router, db *sqlx.DB, dbName string, s3conn *storage.S3Connector) {
func registerBugReports(r chi.Router, db *sqlx.DB, dbName string) {
r.Route("/bug-reports", func(r chi.Router) {
// API key only: submit a report and check count
r.Group(func(r chi.Router) {
@@ -20,7 +19,7 @@ func registerBugReports(r chi.Router, db *sqlx.DB, dbName string, s3conn *storag
r.Use(httprate.LimitByIP(30, time.Minute))
r.Get("/count", handlers.GetReportsCount(db, dbName))
r.Post("/", handlers.CreateBugReport(db, dbName, s3conn))
r.Post("/", handlers.CreateBugReport(db, dbName))
})
// API key + admin key: full read/write access
@@ -33,8 +32,8 @@ func registerBugReports(r chi.Router, db *sqlx.DB, dbName string, s3conn *storag
r.Get("/{id}", handlers.GetBugReportByID(db, dbName))
r.Get("/{id}/status", handlers.GetReportStatusByID(db, dbName))
r.Get("/{id}/files", handlers.GetReportFilesByReportID(db, dbName))
r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db, dbName, s3conn))
r.Get("/{id}/download", handlers.GetBugReportZipByID(db, dbName))
r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db, dbName))
r.Get("/{id}/download", handlers.GetBugReportZipById(db, dbName))
r.Patch("/{id}/status", handlers.PatchBugReportStatus(db, dbName))
r.Delete("/{id}", handlers.DeleteBugReportByID(db, dbName))
})

View File

@@ -6,16 +6,16 @@ import (
"emly-api-go/internal/config"
"emly-api-go/internal/handlers"
"emly-api-go/internal/storage"
"github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx"
)
// NewRouter returns a chi.Router with all /v1 routes mounted.
func NewRouter(db *sqlx.DB, s3conn *storage.S3Connector) http.Handler {
// Add new API versions by creating an analogous package (e.g. v2) and
// mounting it alongside this one in internal/routes/routes.go.
func NewRouter(db *sqlx.DB) http.Handler {
r := chi.NewRouter()
dbName := config.Load().Database
rl := emlyMiddleware.NewRateLimiter(config.Load())
@@ -32,8 +32,8 @@ func NewRouter(db *sqlx.DB, s3conn *storage.S3Connector) http.Handler {
r.Get("/health", handlers.Health(db))
r.Route("/api", func(r chi.Router) {
registerAdmin(r, db, dbName)
registerBugReports(r, db, dbName, s3conn)
registerAdmin(r, db)
registerBugReports(r, db, config.Load().Database)
})
return r

View File

@@ -5,14 +5,13 @@ import (
"time"
"emly-api-go/internal/handlers"
"emly-api-go/internal/storage"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"github.com/jmoiron/sqlx"
)
func registerBugReports(r chi.Router, db *sqlx.DB, dbName string, s3conn *storage.S3Connector) {
func registerBugReports(r chi.Router, db *sqlx.DB, dbName string) {
r.Route("/bug-report", func(r chi.Router) {
// API key only: submit a report and check count
r.Group(func(r chi.Router) {
@@ -20,7 +19,7 @@ func registerBugReports(r chi.Router, db *sqlx.DB, dbName string, s3conn *storag
r.Use(httprate.LimitByIP(30, time.Minute))
r.Get("/count", handlers.GetReportsCount(db, dbName))
r.Post("/", handlers.CreateBugReport(db, dbName, s3conn))
r.Post("/", handlers.CreateBugReport(db, dbName))
})
// API key + admin key: full read/write access
@@ -33,8 +32,8 @@ func registerBugReports(r chi.Router, db *sqlx.DB, dbName string, s3conn *storag
r.Get("/{id}", handlers.GetBugReportByID(db, dbName))
r.Get("/{id}/status", handlers.GetReportStatusByID(db, dbName))
r.Get("/{id}/files", handlers.GetReportFilesByReportID(db, dbName))
r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db, dbName, s3conn))
r.Get("/{id}/download", handlers.GetBugReportZipByID(db, dbName))
r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db, dbName))
r.Get("/{id}/download", handlers.GetBugReportZipById(db, dbName))
r.Patch("/{id}/status", handlers.PatchBugReportStatus(db, dbName))
r.Delete("/{id}", handlers.DeleteBugReportByID(db, dbName))
})

View File

@@ -6,14 +6,15 @@ import (
"emly-api-go/internal/config"
"emly-api-go/internal/handlers"
"emly-api-go/internal/storage"
"github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx"
)
// NewRouter returns a chi.Router with all /v2 routes mounted.
func NewRouter(db *sqlx.DB, s3conn *storage.S3Connector) http.Handler {
// NewRouter returns a chi.Router with all /v1 routes mounted.
// Add new API versions by creating an analogous package (e.g. v2) and
// mounting it alongside this one in internal/routes/routes.go.
func NewRouter(db *sqlx.DB) http.Handler {
r := chi.NewRouter()
rl := emlyMiddleware.NewRateLimiter(config.Load())
@@ -32,7 +33,7 @@ func NewRouter(db *sqlx.DB, s3conn *storage.S3Connector) http.Handler {
r.Route("/api", func(r chi.Router) {
registerAdmin(r, db)
registerBugReports(r, db, config.Load().Database, s3conn)
registerBugReports(r, db, config.Load().Database)
})
return r

View File

@@ -1,122 +0,0 @@
package storage
import (
"bytes"
"context"
"database/sql"
"emly-api-go/internal/models"
"errors"
"fmt"
"log"
"sync"
"time"
"github.com/jmoiron/sqlx"
)
func MigrateReportFilesToS3(db *sqlx.DB, s3conn *S3Connector, dbName string) error {
var wg sync.WaitGroup
errCh := make(chan error, 128) // buffer ragionevole
reportsRows, err := db.Query("SELECT id, created_at, updated_at FROM emly_bugreports_dev.bug_reports ORDER BY created_at DESC")
if err != nil {
return err
}
defer reportsRows.Close()
var totalReports, totalFiles, skipped, uploaded int
for reportsRows.Next() {
var reportId int
var createdAt, updatedAt time.Time
if err := reportsRows.Scan(
&reportId, &createdAt, &updatedAt,
); err != nil {
return err
}
totalReports++
log.Printf("[migrate] processing report %d", reportId)
filesRows, err := db.Query(
"SELECT id, report_id, filename FROM emly_bugreports_dev.bug_report_files WHERE report_id = ?",
reportId,
)
if err != nil {
return err
}
for filesRows.Next() {
var fileID int
var fileReportID int
var fileName string
if err := filesRows.Scan(&fileID, &fileReportID, &fileName); err != nil {
filesRows.Close()
return err
}
var file models.BugReportFile
err := db.GetContext(context.Background(), &file, fmt.Sprintf("SELECT filename, mime_type, data FROM %s.bug_report_files WHERE report_id = ? AND id = ?", dbName), reportId, fileID)
if errors.Is(err, sql.ErrNoRows) {
log.Printf("[migrate] report %d / file %d: not found in bug_report_files, skipping", reportId, fileID)
skipped++
continue
}
if err != nil {
filesRows.Close()
return fmt.Errorf("report %d / file %d: %w", reportId, fileID, err)
}
if s3conn != nil {
s3Key := fmt.Sprintf("emly-api-files/bug-reports/%d/files/%s", reportId, fileName)
dataCopy := make([]byte, len(file.Data))
copy(dataCopy, file.Data)
mime := file.MimeType
fname := file.Filename
totalFiles++
log.Printf("[migrate] report %d / file %d (%s, %d bytes): uploading to s3://%s", reportId, fileID, fname, len(dataCopy), s3Key)
wg.Add(1)
go func(key, mimeType, filename string, payload []byte, rid, fid int) {
defer wg.Done()
_, upErr := s3conn.UploadFile(
context.Background(),
key,
bytes.NewReader(payload),
mimeType,
map[string]string{"filename": filename},
)
if upErr != nil {
errCh <- fmt.Errorf("report %d / file %d (%s): %w", rid, fid, key, upErr)
log.Printf("[migrate] [ERROR] upload failed for s3://%s: %v", key, upErr)
return
}
log.Printf("[migrate] upload complete: s3://%s", key)
}(s3Key, mime, fname, dataCopy, reportId, fileID)
uploaded++
}
}
if err := filesRows.Close(); err != nil {
return err
}
}
wg.Wait()
close(errCh)
var uploadErrCount int
for e := range errCh {
uploadErrCount++
log.Printf("[migrate] [ERROR] %v", e)
}
log.Printf("[migrate] done — reports: %d, files queued: %d, skipped: %d, upload errors: %d",
totalReports, uploaded, skipped, uploadErrCount)
if uploadErrCount > 0 {
return fmt.Errorf("migration completed with %d upload errors", uploadErrCount)
}
return nil
}

View File

@@ -1,322 +0,0 @@
package storage
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"emly-api-go/internal/config"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
type S3Connector struct {
client *s3.Client
uploader *manager.Uploader
downloader *manager.Downloader
bucket string
}
type FileInfo struct {
Key string
Size int64
LastModified time.Time
ETag string
ContentType string
Metadata map[string]string
}
// IsNotFound reports whether err represents a missing object (404 / NoSuchKey).
func IsNotFound(err error) bool {
if err == nil {
return false
}
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return true
}
// Fallback for S3-compatible stores (e.g. Cloudflare R2) that surface
// the error code via the generic APIError interface.
var ae interface{ ErrorCode() string }
if errors.As(err, &ae) {
switch ae.ErrorCode() {
case "NoSuchKey", "NotFound", "404":
return true
}
}
return false
}
type FolderInfo struct {
Prefix string
}
func NewCloudflareR2Connector(cfg config.R2Config) (*S3Connector, error) {
if cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" || cfg.BucketName == "" {
return nil, fmt.Errorf("missing required R2 config fields (CF_R2_ACCESS_KEY_ID, CF_R2_SECRET_ACCESS_KEY, CF_R2_BUCKET_NAME)")
}
endpoint := cfg.Endpoint
if endpoint == "" {
if cfg.AccountID == "" {
return nil, fmt.Errorf("either CF_R2_ENDPOINT or CF_ACCOUNT_ID must be set")
}
endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID)
}
region := cfg.Region
if region == "" {
region = "auto"
}
awsCfg, err := awsconfig.LoadDefaultConfig(context.TODO(),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")),
awsconfig.WithRegion(region),
)
if err != nil {
return nil, fmt.Errorf("failed to load R2 config: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
return &S3Connector{
client: client,
uploader: manager.NewUploader(client),
downloader: manager.NewDownloader(client),
bucket: cfg.BucketName,
}, nil
}
// Ping verifies connectivity by calling HeadBucket on the configured bucket.
func (c *S3Connector) Ping(ctx context.Context) error {
_, err := c.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(c.bucket),
})
if err != nil {
return fmt.Errorf("R2 ping failed for bucket %q: %w", c.bucket, err)
}
return nil
}
// UploadFile uploads body to key in the bucket and returns the public URL.
// metadata is optional; pass nil if not needed.
func (c *S3Connector) UploadFile(ctx context.Context, key string, body io.Reader, contentType string, metadata map[string]string) (string, error) {
result, err := c.uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
Body: body,
ContentType: aws.String(contentType),
Metadata: metadata,
})
if err != nil {
return "", fmt.Errorf("upload %q: %w", key, err)
}
return result.Location, nil
}
// GetFile returns the object body at key. Caller must close it.
func (c *S3Connector) GetFile(ctx context.Context, key string) (io.ReadCloser, *FileInfo, error) {
out, err := c.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, nil, fmt.Errorf("get %q: %w", key, err)
}
info := &FileInfo{
Key: key,
Size: aws.ToInt64(out.ContentLength),
ETag: strings.Trim(aws.ToString(out.ETag), `"`),
ContentType: aws.ToString(out.ContentType),
Metadata: out.Metadata,
}
if out.LastModified != nil {
info.LastModified = *out.LastModified
}
return out.Body, info, nil
}
// DownloadFile downloads key into dst and returns bytes written.
func (c *S3Connector) DownloadFile(ctx context.Context, key string, dst io.WriterAt) (int64, error) {
n, err := c.downloader.Download(ctx, dst, &s3.GetObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
})
if err != nil {
return 0, fmt.Errorf("download %q: %w", key, err)
}
return n, nil
}
// DeleteFile deletes the object at key.
func (c *S3Connector) DeleteFile(ctx context.Context, key string) error {
_, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("delete %q: %w", key, err)
}
return nil
}
// DeleteFiles deletes up to 1000 objects in one request.
func (c *S3Connector) DeleteFiles(ctx context.Context, keys []string) error {
if len(keys) == 0 {
return nil
}
objects := make([]types.ObjectIdentifier, len(keys))
for i, k := range keys {
objects[i] = types.ObjectIdentifier{Key: aws.String(k)}
}
_, err := c.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(c.bucket),
Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)},
})
if err != nil {
return fmt.Errorf("batch delete: %w", err)
}
return nil
}
// RenameFile copies src to dst then deletes src (R2 has no native rename).
func (c *S3Connector) RenameFile(ctx context.Context, srcKey, dstKey string) error {
_, err := c.client.CopyObject(ctx, &s3.CopyObjectInput{
Bucket: aws.String(c.bucket),
CopySource: aws.String(c.bucket + "/" + srcKey),
Key: aws.String(dstKey),
})
if err != nil {
return fmt.Errorf("copy %q → %q: %w", srcKey, dstKey, err)
}
return c.DeleteFile(ctx, srcKey)
}
// ListFiles returns all objects directly under prefix (non-recursive).
func (c *S3Connector) ListFiles(ctx context.Context, prefix string) ([]FileInfo, error) {
prefix = normalizePrefix(prefix)
var files []FileInfo
pager := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{
Bucket: aws.String(c.bucket),
Prefix: aws.String(prefix),
Delimiter: aws.String("/"),
})
for pager.HasMorePages() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, fmt.Errorf("list files under %q: %w", prefix, err)
}
for _, obj := range page.Contents {
key := aws.ToString(obj.Key)
if strings.HasSuffix(key, "/") {
continue // skip folder placeholders
}
fi := FileInfo{
Key: key,
Size: aws.ToInt64(obj.Size),
ETag: strings.Trim(aws.ToString(obj.ETag), `"`),
}
if obj.LastModified != nil {
fi.LastModified = *obj.LastModified
}
files = append(files, fi)
}
}
return files, nil
}
// ListFolders returns the immediate sub-folders under prefix.
func (c *S3Connector) ListFolders(ctx context.Context, prefix string) ([]FolderInfo, error) {
prefix = normalizePrefix(prefix)
var folders []FolderInfo
pager := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{
Bucket: aws.String(c.bucket),
Prefix: aws.String(prefix),
Delimiter: aws.String("/"),
})
for pager.HasMorePages() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, fmt.Errorf("list folders under %q: %w", prefix, err)
}
for _, cp := range page.CommonPrefixes {
folders = append(folders, FolderInfo{Prefix: aws.ToString(cp.Prefix)})
}
}
return folders, nil
}
// CreateFolder writes a zero-byte placeholder object to make the folder visible.
func (c *S3Connector) CreateFolder(ctx context.Context, folderPath string) error {
key := normalizePrefix(folderPath)
if key == "" {
return fmt.Errorf("folder path cannot be empty")
}
_, err := c.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
ContentLength: aws.Int64(0),
})
if err != nil {
return fmt.Errorf("create folder %q: %w", key, err)
}
return nil
}
// DeleteFolder removes all objects under folderPath in batches of 1000.
func (c *S3Connector) DeleteFolder(ctx context.Context, folderPath string) error {
prefix := normalizePrefix(folderPath)
var keys []string
pager := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{
Bucket: aws.String(c.bucket),
Prefix: aws.String(prefix),
})
for pager.HasMorePages() {
page, err := pager.NextPage(ctx)
if err != nil {
return fmt.Errorf("list for delete %q: %w", prefix, err)
}
for _, obj := range page.Contents {
keys = append(keys, aws.ToString(obj.Key))
}
}
for i := 0; i < len(keys); i += 1000 {
end := i + 1000
if end > len(keys) {
end = len(keys)
}
if err := c.DeleteFiles(ctx, keys[i:end]); err != nil {
return err
}
}
return nil
}
// normalizePrefix ensures prefix ends with "/" (returns "" for root).
func normalizePrefix(p string) string {
p = strings.TrimPrefix(p, "/")
if p == "" {
return ""
}
if !strings.HasSuffix(p, "/") {
p += "/"
}
return p
}

View File

@@ -1,50 +0,0 @@
package timing
import (
"context"
"time"
)
type contextKey struct{}
// Checkpoint is a named point in time recorded during request processing.
type Checkpoint struct {
Name string
At time.Time
}
// Timer records the request start time and named checkpoints.
type Timer struct {
Start time.Time
checkpoints []Checkpoint
}
// Mark records a checkpoint with the given name.
func (t *Timer) Mark(name string) {
t.checkpoints = append(t.checkpoints, Checkpoint{Name: name, At: time.Now()})
}
// Checkpoints returns all recorded checkpoints in order.
func (t *Timer) Checkpoints() []Checkpoint {
return t.checkpoints
}
// NewContext attaches a new Timer to ctx and returns both.
func NewContext(ctx context.Context) (context.Context, *Timer) {
t := &Timer{Start: time.Now()}
return context.WithValue(ctx, contextKey{}, t), t
}
// FromContext retrieves the Timer from ctx, or nil if not present.
func FromContext(ctx context.Context) *Timer {
t, _ := ctx.Value(contextKey{}).(*Timer)
return t
}
// Mark records a checkpoint in the Timer stored in ctx, if any.
// It is a no-op when ctx carries no Timer (e.g. in tests).
func Mark(ctx context.Context, name string) {
if t := FromContext(ctx); t != nil {
t.Mark(name)
}
}

38
main.go
View File

@@ -1,7 +1,6 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
@@ -17,7 +16,6 @@ import (
"emly-api-go/internal/database"
"emly-api-go/internal/database/schema"
"emly-api-go/internal/routes"
"emly-api-go/internal/storage"
emlyMiddleware "emly-api-go/internal/middleware"
)
@@ -44,41 +42,10 @@ func main() {
}(db)
// Run conditional schema migrations
if err := schema.Migrate(db, cfg.Database); err != nil {
if err := schema.Migrate(db, cfg.Database, cfg.Driver); err != nil {
log.Fatalf("schema migration failed: %v", err)
}
var s3conn *storage.S3Connector
if cfg.UseS3CompatibleStorage {
conn, err := storage.NewCloudflareR2Connector(cfg.R2)
if err != nil {
log.Fatalf("R2 connector init failed: %v", err)
}
if err := conn.Ping(context.Background()); err != nil {
log.Fatalf("R2 connection test failed: %v", err)
}
log.Printf("R2 storage connected (bucket: %s)", cfg.R2.BucketName)
s3conn = conn
}
argsWithoutProg := os.Args[1:]
for _, arg := range argsWithoutProg {
log.Printf("arg: %s", arg)
if arg == "--migrate-files" {
if cfg.UseS3CompatibleStorage && s3conn != nil {
log.Printf("migrate report files from db to s3...")
if err := storage.MigrateReportFilesToS3(db, s3conn, cfg.Database); err != nil {
log.Fatalf("migrating report files failed: %v", err)
}
log.Printf("migrate report files from db to s3 completed successfully")
continue
} else {
log.Printf("migrate report files from db to s3 skipped (R2 not enabled)")
}
}
}
r := chi.NewRouter()
// Global middlewares
@@ -87,13 +54,12 @@ func main() {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(emlyMiddleware.Timing)
rl := emlyMiddleware.NewRateLimiter(cfg)
r.Use(rl.Handler)
routes.RegisterAll(r, db, s3conn)
routes.RegisterAll(r, db)
addr := fmt.Sprintf(":%s", cfg.Port)
log.Printf("server listening on %s", addr)