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 # Server Settings
PORT=8080 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_DSN=root:secret@tcp(127.0.0.1:3306)/emly?parseTime=true&loc=UTC
DB_MAX_OPEN_CONNS=25 DB_MAX_OPEN_CONNS=25
DB_MAX_IDLE_CONNS=5 DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME=5 DB_CONN_MAX_LIFETIME=5
DATABASE_NAME=emly 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 Keys
API_KEY=key-one API_KEY=key-one
ADMIN_KEY=admin-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) # Rate Limiting — App (unauthenticated: no X-API-Key / X-Admin-Key)
RL_UNAUTH_MAX_REQS=10 RL_UNAUTH_MAX_REQS=10
RL_UNAUTH_WINDOW=5m RL_UNAUTH_WINDOW=5m
@@ -23,12 +40,3 @@ RL_AUTH_MAX_REQS=100
RL_AUTH_WINDOW=1m RL_AUTH_WINDOW=1m
RL_AUTH_MAX_FAILS=20 RL_AUTH_MAX_FAILS=20
RL_AUTH_BAN_DUR=5m 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

5
.gitignore vendored
View File

@@ -38,4 +38,7 @@ go.work.sum
tmp/ tmp/
build/ build/
# Database files
*.db

View File

@@ -13,21 +13,6 @@ services:
DB_MAX_OPEN_CONNS: ${DB_MAX_OPEN_CONNS:-25} DB_MAX_OPEN_CONNS: ${DB_MAX_OPEN_CONNS:-25}
DB_MAX_IDLE_CONNS: ${DB_MAX_IDLE_CONNS:-5} DB_MAX_IDLE_CONNS: ${DB_MAX_IDLE_CONNS:-5}
DB_CONN_MAX_LIFETIME: ${DB_CONN_MAX_LIFETIME:-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: volumes:
- logs:/logs - logs:/logs
logging: logging:

28
go.mod
View File

@@ -3,11 +3,6 @@ module emly-api-go
go 1.26 go 1.26
require ( 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/chi/v5 v5.2.4
github.com/go-chi/httprate v0.14.1 github.com/go-chi/httprate v0.14.1
github.com/go-sql-driver/mysql v1.8.1 github.com/go-sql-driver/mysql v1.8.1
@@ -16,21 +11,16 @@ require (
) )
require ( require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
golang.org/x/sys v0.42.0 // 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 ( 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 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= 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.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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 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 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 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 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 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 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 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 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 ( import (
"os" "os"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -20,28 +19,17 @@ type RateLimitConfig struct {
AuthBanDur time.Duration AuthBanDur time.Duration
} }
type R2Config struct {
AccountID string
AccessKeyID string
SecretAccessKey string
BucketName string
Region string
Endpoint string
}
type Config struct { type Config struct {
Port string Port string
DSN string Driver string
Database string DSN string
APIKey string Database string
AdminKey string APIKey string
MaxOpenConns int AdminKey string
MaxIdleConns int MaxOpenConns int
ConnMaxLifetime int MaxIdleConns int
UpdatesEnabled bool ConnMaxLifetime int
UseS3CompatibleStorage bool RateLimit RateLimitConfig
RateLimit RateLimitConfig
R2 R2Config
} }
var ( var (
@@ -93,18 +81,19 @@ func load() *Config {
connMaxLifetime = 5 connMaxLifetime = 5
} }
dbName := os.Getenv("DATABASE_NAME") driver := os.Getenv("DB_DRIVER")
if dbName == "" { if driver == "" {
panic("DATABASE_NAME environment variable is required") driver = "mysql"
} }
dbNameRegex := regexp.MustCompile("^[a-zA-Z0-9_]+$")
// Test the regex against the dbName, otherwise panic to prevent potential SQL injection var dbName string
validDbName, err := regexp.Match(dbNameRegex.String(), []byte(dbName)) if driver == "sqlite" {
if err != nil { dbName = "main"
panic("failed to validate database name: " + err.Error()) } else {
} dbName = os.Getenv("DATABASE_NAME")
if !validDbName { if dbName == "" {
panic("invalid database name: must match regex " + dbNameRegex.String()) panic("DATABASE_NAME environment variable is required")
}
} }
if os.Getenv("DB_DSN") == "" { if os.Getenv("DB_DSN") == "" {
@@ -112,24 +101,15 @@ func load() *Config {
} }
return &Config{ return &Config{
Port: port, Port: port,
DSN: os.Getenv("DB_DSN"), Driver: driver,
Database: dbName, DSN: os.Getenv("DB_DSN"),
APIKey: apiKey, Database: dbName,
AdminKey: adminKey, APIKey: apiKey,
MaxOpenConns: maxOpenConns, AdminKey: adminKey,
MaxIdleConns: maxIdleConns, MaxOpenConns: maxOpenConns,
ConnMaxLifetime: connMaxLifetime, MaxIdleConns: maxIdleConns,
UpdatesEnabled: strings.ToLower(strings.TrimSpace(os.Getenv("UPDATES_ENABLED"))) == "true", ConnMaxLifetime: connMaxLifetime,
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{ RateLimit: RateLimitConfig{
UnauthMaxReqs: envInt("RL_UNAUTH_MAX_REQS", 10), UnauthMaxReqs: envInt("RL_UNAUTH_MAX_REQS", 10),
UnauthWindow: envDuration("RL_UNAUTH_WINDOW", 5*time.Minute), 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 { func envInt(key string, fallback int) int {
if s := os.Getenv(key); s != "" { if s := os.Getenv(key); s != "" {
if n, err := strconv.Atoi(s); err == nil { if n, err := strconv.Atoi(s); err == nil {

View File

@@ -1,23 +1,41 @@
package database package database
import ( import (
"fmt"
"time" "time"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
"emly-api-go/internal/config" "emly-api-go/internal/config"
) )
func Connect(cfg *config.Config) (*sqlx.DB, error) { func Connect(cfg *config.Config) (*sqlx.DB, error) {
db, err := sqlx.Connect("mysql", cfg.DSN) var db *sqlx.DB
if err != nil { var err error
return nil, err
}
db.SetMaxOpenConns(cfg.MaxOpenConns) switch cfg.Driver {
db.SetMaxIdleConns(cfg.MaxIdleConns) case "sqlite":
db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute) 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 return db, nil
} }

View File

@@ -10,7 +10,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
//go:embed init.sql migrations/*.json migrations/*.sql //go:embed mysql sqlite
var migrationsFS embed.FS var migrationsFS embed.FS
type taskFile struct { type taskFile struct {
@@ -31,62 +31,43 @@ type condition struct {
Index string `json:"index,omitempty"` Index string `json:"index,omitempty"`
} }
// Migrate reads migrations/tasks.json and executes every task whose // Migrate reads the driver-specific migrations and applies them.
// conditions are ALL satisfied (i.e. logical AND). func Migrate(db *sqlx.DB, dbName string, driver string) error {
func Migrate(db *sqlx.DB, dbName string) error { empty, err := schemaIsEmpty(db, dbName, driver)
// If the database has no tables at all, bootstrap with init.sql.
empty, err := schemaIsEmpty(db, dbName)
if err != nil { if err != nil {
return fmt.Errorf("schema: check empty: %w", err) return fmt.Errorf("schema: check empty: %w", err)
} }
if empty { if empty {
log.Println("[migrate] empty schema detected running init.sql") log.Println("[migrate] empty schema detected running init.sql")
initSQL, err := migrationsFS.ReadFile("init.sql") if err := runInitSQL(db, driver); err != nil {
if err != nil { return err
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")
} else { } else {
log.Println("[migrate] checking if tables exist") log.Println("[migrate] checking if tables exist")
// Check if the tables are there or not tableNames := []string{"bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session"}
var tableNames []string
var foundTables []string var foundTables []string
tableNames = append(tableNames, "bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session")
for _, tableName := range tableNames { for _, tableName := range tableNames {
found, err := tableExists(db, dbName, tableName) found, err := tableExists(db, dbName, tableName, driver)
if err != nil { if err != nil {
return fmt.Errorf("schema: check table %s: %w", tableName, err) return fmt.Errorf("schema: check table %s: %w", tableName, err)
} }
if !found { 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 continue
} }
foundTables = append(foundTables, tableName) foundTables = append(foundTables, tableName)
} }
if len(foundTables) != len(tableNames) { if len(foundTables) != len(tableNames) {
log.Printf("[migrate] warning: expected %d tables, found %d", len(tableNames), len(foundTables)) log.Printf("[migrate] warning: expected %d tables, found %d running init.sql", len(tableNames), len(foundTables))
log.Printf("[migrate] info: running init.sql") if err := runInitSQL(db, driver); err != nil {
initSQL, err := migrationsFS.ReadFile("init.sql") return err
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")
} else { } else {
log.Println("[migrate] all expected tables found skipping init.sql") 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 { if err != nil {
return fmt.Errorf("schema: read tasks.json: %w", err) 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 { for _, t := range tf.Tasks {
needed, err := shouldRun(db, dbName, t.Conditions) needed, err := shouldRun(db, dbName, t.Conditions, driver)
if err != nil { if err != nil {
return fmt.Errorf("schema: evaluate conditions for %s: %w", t.ID, err) 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 continue
} }
sqlBytes, err := migrationsFS.ReadFile("migrations/" + t.SQLFile) sqlBytes, err := migrationsFS.ReadFile(driver + "/migrations/" + t.SQLFile)
if err != nil { if err != nil {
return fmt.Errorf("schema: read %s: %w", t.SQLFile, err) return fmt.Errorf("schema: read %s: %w", t.SQLFile, err)
} }
@@ -122,11 +103,25 @@ func Migrate(db *sqlx.DB, dbName string) error {
return nil 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 ---------- // ---------- 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 { for _, c := range conds {
met, err := evaluate(db, dbName, c) met, err := evaluate(db, dbName, c, driver)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -137,81 +132,186 @@ func shouldRun(db *sqlx.DB, dbName string, conds []condition) (bool, error) {
return false, nil 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 { switch c.Type {
case "column_not_exists": 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 return !exists, err
case "column_exists": case "column_exists":
return columnExists(db, dbName, c.Table, c.Column) return columnExists(db, dbName, c.Table, c.Column, driver)
case "index_not_exists": 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 return !exists, err
case "index_exists": case "index_exists":
return indexExists(db, dbName, c.Table, c.Index) return indexExists(db, dbName, c.Table, c.Index, driver)
case "table_not_exists": case "table_not_exists":
exists, err := tableExists(db, dbName, c.Table) exists, err := tableExists(db, dbName, c.Table, driver)
return !exists, err return !exists, err
case "table_exists": case "table_exists":
return tableExists(db, dbName, c.Table) return tableExists(db, dbName, c.Table, driver)
default: default:
return false, fmt.Errorf("unknown condition type: %s", c.Type) 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 var count int
err := db.Get(&count, err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.COLUMNS `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
AND TABLE_NAME = ? dbName, table, column)
AND COLUMN_NAME = ?`, dbName, table, column)
return count > 0, err 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 var count int
err := db.Get(&count, err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.STATISTICS `SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = ? WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?`,
AND TABLE_NAME = ? dbName, table, index)
AND INDEX_NAME = ?`, dbName, table, index)
return count > 0, err 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 var count int
err := db.Get(&count, err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.TABLES `SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
AND TABLE_NAME = ?`, dbName, table) dbName, table)
return count > 0, err 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 var count int
err := db.Get(&count, err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.TABLES `SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`, dbName)
WHERE TABLE_SCHEMA = ?`, dbName)
return count == 0, err return count == 0, err
} }
// splitStatements splits a SQL blob on ";" respecting only top-level // ---------- SQLite condition checks ----------
// semicolons (good enough for simple ALTER / CREATE statements).
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 { func splitStatements(sql string) []string {
raw := strings.Split(sql, ";") var out []string
out := make([]string, 0, len(raw)) var buf strings.Builder
for _, s := range raw { depth := 0
s = strings.TrimSpace(s) n := len(sql)
if s != "" {
out = append(out, s) 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 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" } { "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, sessionID,
) )
if err != nil { if err != nil {
log.Printf("[AUTH] Database error during session validation: %v", err) jsonError(w, http.StatusUnauthorized, "invalid session")
jsonError(w, http.StatusInternalServerError, "internal server error") log.Fatalf("Database error during session validation: %v", err)
return return
} }

View File

@@ -3,7 +3,6 @@ package handlers
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context"
"database/sql" "database/sql"
"embed" "embed"
"encoding/json" "encoding/json"
@@ -22,8 +21,6 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"emly-api-go/internal/models" "emly-api-go/internal/models"
"emly-api-go/internal/storage"
"emly-api-go/internal/timing"
) )
//go:embed templates/report.txt.tmpl //go:embed templates/report.txt.tmpl
@@ -44,13 +41,12 @@ var fileRoles = []struct {
{"config", models.FileRoleConfig, "application/json"}, {"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) { return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil { if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error()) jsonError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return return
} }
timing.Mark(r.Context(), "parse_form")
name := r.FormValue("name") name := r.FormValue("name")
email := r.FormValue("email") 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()) jsonError(w, http.StatusInternalServerError, err.Error())
return return
} }
timing.Mark(r.Context(), "db_insert_report")
reportID, err := result.LastInsertId() reportID, err := result.LastInsertId()
if err != nil { 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)) 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), 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, 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()) jsonError(w, http.StatusInternalServerError, err.Error())
return 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) 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()) jsonError(w, http.StatusInternalServerError, err.Error())
return return
} }
timing.Mark(r.Context(), "db_count")
mainQuery := fmt.Sprintf(` mainQuery := fmt.Sprintf(`
SELECT br.*, COUNT(bf.id) as file_count 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()) jsonError(w, http.StatusInternalServerError, err.Error())
return return
} }
timing.Mark(r.Context(), "db_select")
jsonOK(w, map[string]interface{}{ jsonOK(w, map[string]interface{}{
"data": reports, "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) { return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
if id == "" { if id == "" {
@@ -324,14 +298,12 @@ func GetBugReportZipByID(db *sqlx.DB, dbName string) http.HandlerFunc {
jsonError(w, http.StatusInternalServerError, err.Error()) jsonError(w, http.StatusInternalServerError, err.Error())
return return
} }
timing.Mark(r.Context(), "db_fetch_report")
var files []models.BugReportFile 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 { 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()) jsonError(w, http.StatusInternalServerError, err.Error())
return return
} }
timing.Mark(r.Context(), "db_fetch_files")
var sysInfoStr string var sysInfoStr string
if len(report.SystemInfo) > 0 && string(report.SystemInfo) != "null" { 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()) jsonError(w, http.StatusInternalServerError, err.Error())
return return
} }
timing.Mark(r.Context(), "zip_build")
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"report-%d.zip\"", report.ID)) 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) { return func(w http.ResponseWriter, r *http.Request) {
reportId := chi.URLParam(r, "id") reportId := chi.URLParam(r, "id")
if reportId == "" { if reportId == "" {
@@ -406,44 +377,6 @@ func GetReportFileByFileID(db *sqlx.DB, dbName string, s3conn *storage.S3Connect
return 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 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) 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) { 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()) jsonError(w, http.StatusInternalServerError, err.Error())
return 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 mimeType := file.MimeType
if 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-Type", mimeType)
w.Header().Set("Content-Disposition", "attachment; filename=\""+file.Filename+"\"") 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 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) result, err := db.ExecContext(r.Context(), fmt.Sprintf("DELETE FROM %s.bug_reports WHERE id = ?", dbName), reportId)
if err != nil { if err != nil {
log.Printf("[BUGREPORT] Delete failed: report_id=%s err=%v", reportId, err)
jsonError(w, http.StatusInternalServerError, err.Error()) jsonError(w, http.StatusInternalServerError, err.Error())
return return
} }
rowsAffected, err := result.RowsAffected() rowsAffected, err := result.RowsAffected()
if err != nil { if err != nil {
log.Printf("[BUGREPORT] Delete rows check failed: report_id=%s err=%v", reportId, err)
jsonError(w, http.StatusInternalServerError, err.Error()) jsonError(w, http.StatusInternalServerError, err.Error())
return return
} }
if rowsAffected == 0 { if rowsAffected == 0 {
log.Printf("[BUGREPORT] Delete skipped: report_id=%s not found", reportId)
jsonError(w, http.StatusNotFound, "bug report not found") jsonError(w, http.StatusNotFound, "bug report not found")
return return
} }
log.Printf("[BUGREPORT] Deleted successfully: report_id=%s rows=%d", reportId, rowsAffected)
jsonOK(w, map[string]string{"message": "bug report deleted successfully"}) 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 package routes
import ( import (
v2 "emly-api-go/internal/routes/v2"
"net/http" "net/http"
v1 "emly-api-go/internal/routes/v1" 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/go-chi/chi/v5"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
// RegisterAll mounts every versioned API onto the root router. // 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) { r.Get("/", func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("emly-api-go")) _, err := w.Write([]byte("emly-api-go"))
if err != nil { 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("/v1", v1.NewRouter(db))
r.Mount("/v2", v2.NewRouter(db, s3conn)) r.Mount("/v2", v2.NewRouter(db))
} }

View File

@@ -11,7 +11,7 @@ import (
"github.com/jmoiron/sqlx" "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) { r.Route("/admin", func(r chi.Router) {
// Auth — public, handles its own credential checks. // 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.Post("/{id}/reset-password", handlers.ResetPassword(db))
r.Delete("/{id}", handlers.DeleteUser(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" "time"
"emly-api-go/internal/handlers" "emly-api-go/internal/handlers"
"emly-api-go/internal/storage"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/httprate" "github.com/go-chi/httprate"
"github.com/jmoiron/sqlx" "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) { r.Route("/bug-reports", func(r chi.Router) {
// API key only: submit a report and check count // API key only: submit a report and check count
r.Group(func(r chi.Router) { 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.Use(httprate.LimitByIP(30, time.Minute))
r.Get("/count", handlers.GetReportsCount(db, dbName)) 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 // API key + admin key: full read/write access
@@ -33,10 +32,10 @@ func registerBugReports(r chi.Router, db *sqlx.DB, dbName string, s3conn *storag
r.Get("/{id}", handlers.GetBugReportByID(db, dbName)) r.Get("/{id}", handlers.GetBugReportByID(db, dbName))
r.Get("/{id}/status", handlers.GetReportStatusByID(db, dbName)) r.Get("/{id}/status", handlers.GetReportStatusByID(db, dbName))
r.Get("/{id}/files", handlers.GetReportFilesByReportID(db, dbName)) r.Get("/{id}/files", handlers.GetReportFilesByReportID(db, dbName))
r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db, dbName, s3conn)) r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db, dbName))
r.Get("/{id}/download", handlers.GetBugReportZipByID(db, dbName)) r.Get("/{id}/download", handlers.GetBugReportZipById(db, dbName))
r.Patch("/{id}/status", handlers.PatchBugReportStatus(db, dbName)) r.Patch("/{id}/status", handlers.PatchBugReportStatus(db, dbName))
r.Delete("/{id}", handlers.DeleteBugReportByID(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/config"
"emly-api-go/internal/handlers" "emly-api-go/internal/handlers"
"emly-api-go/internal/storage"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
// NewRouter returns a chi.Router with all /v1 routes mounted. // 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() r := chi.NewRouter()
dbName := config.Load().Database
rl := emlyMiddleware.NewRateLimiter(config.Load()) 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.Get("/health", handlers.Health(db))
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
registerAdmin(r, db, dbName) registerAdmin(r, db)
registerBugReports(r, db, dbName, s3conn) registerBugReports(r, db, config.Load().Database)
}) })
return r return r

View File

@@ -5,14 +5,13 @@ import (
"time" "time"
"emly-api-go/internal/handlers" "emly-api-go/internal/handlers"
"emly-api-go/internal/storage"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/httprate" "github.com/go-chi/httprate"
"github.com/jmoiron/sqlx" "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) { r.Route("/bug-report", func(r chi.Router) {
// API key only: submit a report and check count // API key only: submit a report and check count
r.Group(func(r chi.Router) { 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.Use(httprate.LimitByIP(30, time.Minute))
r.Get("/count", handlers.GetReportsCount(db, dbName)) 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 // 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}", handlers.GetBugReportByID(db, dbName))
r.Get("/{id}/status", handlers.GetReportStatusByID(db, dbName)) r.Get("/{id}/status", handlers.GetReportStatusByID(db, dbName))
r.Get("/{id}/files", handlers.GetReportFilesByReportID(db, dbName)) r.Get("/{id}/files", handlers.GetReportFilesByReportID(db, dbName))
r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db, dbName, s3conn)) r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db, dbName))
r.Get("/{id}/download", handlers.GetBugReportZipByID(db, dbName)) r.Get("/{id}/download", handlers.GetBugReportZipById(db, dbName))
r.Patch("/{id}/status", handlers.PatchBugReportStatus(db, dbName)) r.Patch("/{id}/status", handlers.PatchBugReportStatus(db, dbName))
r.Delete("/{id}", handlers.DeleteBugReportByID(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/config"
"emly-api-go/internal/handlers" "emly-api-go/internal/handlers"
"emly-api-go/internal/storage"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
// NewRouter returns a chi.Router with all /v2 routes mounted. // 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() r := chi.NewRouter()
rl := emlyMiddleware.NewRateLimiter(config.Load()) 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) { r.Route("/api", func(r chi.Router) {
registerAdmin(r, db) registerAdmin(r, db)
registerBugReports(r, db, config.Load().Database, s3conn) registerBugReports(r, db, config.Load().Database)
}) })
return r 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 package main
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -17,7 +16,6 @@ import (
"emly-api-go/internal/database" "emly-api-go/internal/database"
"emly-api-go/internal/database/schema" "emly-api-go/internal/database/schema"
"emly-api-go/internal/routes" "emly-api-go/internal/routes"
"emly-api-go/internal/storage"
emlyMiddleware "emly-api-go/internal/middleware" emlyMiddleware "emly-api-go/internal/middleware"
) )
@@ -44,41 +42,10 @@ func main() {
}(db) }(db)
// Run conditional schema migrations // 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) 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() r := chi.NewRouter()
// Global middlewares // Global middlewares
@@ -87,13 +54,12 @@ func main() {
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second)) r.Use(middleware.Timeout(30 * time.Second))
r.Use(emlyMiddleware.Timing)
rl := emlyMiddleware.NewRateLimiter(cfg) rl := emlyMiddleware.NewRateLimiter(cfg)
r.Use(rl.Handler) r.Use(rl.Handler)
routes.RegisterAll(r, db, s3conn) routes.RegisterAll(r, db)
addr := fmt.Sprintf(":%s", cfg.Port) addr := fmt.Sprintf(":%s", cfg.Port)
log.Printf("server listening on %s", addr) log.Printf("server listening on %s", addr)