Compare commits
1 Commits
master
...
fa1f65baf7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa1f65baf7 |
28
.env.example
28
.env.example
@@ -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
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -38,4 +38,7 @@ go.work.sum
|
||||
|
||||
tmp/
|
||||
|
||||
build/
|
||||
build/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
@@ -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
28
go.mod
@@ -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
57
go.sum
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == '_'
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
65
internal/database/schema/sqlite/init.sql
Normal file
65
internal/database/schema/sqlite/init.sql
Normal 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
|
||||
);
|
||||
3
internal/database/schema/sqlite/migrations/tasks.json
Normal file
3
internal/database/schema/sqlite/migrations/tasks.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"tasks": []
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,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}/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))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
38
main.go
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user