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,28 +19,17 @@ 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
DSN string
Database string
APIKey string
AdminKey string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime int
UpdatesEnabled bool
UseS3CompatibleStorage bool
RateLimit RateLimitConfig
R2 R2Config
Port string
Driver string
DSN string
Database string
APIKey string
AdminKey string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime int
RateLimit RateLimitConfig
}
var (
@@ -93,18 +81,19 @@ func load() *Config {
connMaxLifetime = 5
}
dbName := os.Getenv("DATABASE_NAME")
if dbName == "" {
panic("DATABASE_NAME environment variable is required")
driver := os.Getenv("DB_DRIVER")
if driver == "" {
driver = "mysql"
}
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())
var dbName string
if driver == "sqlite" {
dbName = "main"
} else {
dbName = os.Getenv("DATABASE_NAME")
if dbName == "" {
panic("DATABASE_NAME environment variable is required")
}
}
if os.Getenv("DB_DSN") == "" {
@@ -112,24 +101,15 @@ func load() *Config {
}
return &Config{
Port: port,
DSN: os.Getenv("DB_DSN"),
Database: dbName,
APIKey: apiKey,
AdminKey: adminKey,
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"),
},
Port: port,
Driver: driver,
DSN: os.Getenv("DB_DSN"),
Database: dbName,
APIKey: apiKey,
AdminKey: adminKey,
MaxOpenConns: maxOpenConns,
MaxIdleConns: maxIdleConns,
ConnMaxLifetime: connMaxLifetime,
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)
if err != nil {
return nil, err
}
var db *sqlx.DB
var err error
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute)
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).
// ---------- 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 {
raw := strings.Split(sql, ";")
out := make([]string, 0, len(raw))
for _, s := range raw {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
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)