diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9084ebf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.direnv +.env +build + +# Client +/client/node_modules +/client/.svelte-kit +/client/src/lib/services +/client/static/openapi + +# Server +/server/client +/server/tmp +/server/internal/services \ No newline at end of file diff --git a/.gitignore b/.gitignore index ff51edf..9084ebf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ -.direnv \ No newline at end of file +.direnv +.env +build + +# Client +/client/node_modules +/client/.svelte-kit +/client/src/lib/services +/client/static/openapi + +# Server +/server/client +/server/tmp +/server/internal/services \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..21ed3d0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +## BUF BUILD +FROM bufbuild/buf:1.50.0 AS buf +WORKDIR /buf + +# Create client and server services +COPY buf.yaml buf.gen.yaml base.openapi.yaml ./ +COPY proto ./proto +RUN buf generate + + +## CLIENT BUILD +FROM node:22-alpine AS client +WORKDIR /client + +# Install client dependencies +COPY client/package.json client/package-lock.json ./ +RUN npm ci + +# Get client source +COPY client . + +# Get buf service +COPY --from=buf /buf/client/src/lib/services ./src/lib/services + +# Build client +RUN npm run build + + +## SERVER BUILD +FROM golang:1.23 AS server +WORKDIR /server + +# Install server dependencies +COPY server/go.mod server/go.sum ./ +RUN go mod download && go mod verify + +# Get server source +COPY server . + +# Get client build +COPY --from=client /client/build ./client + +# Get buf service +COPY --from=buf /buf/server/internal/services ./internal/services + +# Build server +RUN go build -v -o /server/main . + +CMD ["/server/main"] \ No newline at end of file diff --git a/base.openapi.yaml b/base.openapi.yaml new file mode 100644 index 0000000..d588c6b --- /dev/null +++ b/base.openapi.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +servers: + - url: /grpc +info: + title: Trevstack API + version: 1.0.0 + description: API for trevstack + contact: + name: Trev + email: spam@trev.xyz +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +security: + - bearerAuth: [] \ No newline at end of file diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..3c6d31f --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,27 @@ +version: v2 +clean: true +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/spotdemo4/trevstack/server/internal/services + +plugins: + - local: protoc-gen-go + out: server/internal/services + opt: paths=source_relative + + - local: protoc-gen-connect-go + out: server/internal/services + opt: paths=source_relative + + - local: protoc-gen-es + out: client/src/lib/services + opt: target=ts + + - local: protoc-gen-connect-openapi + out: client/static/openapi + strategy: all + opt: + - base=base.openapi.yaml + - path=openapi.yaml diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..afda648 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,4 @@ +# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml +version: v2 +modules: + - path: proto diff --git a/client/.gitignore b/client/.gitignore deleted file mode 100644 index 3b462cb..0000000 --- a/client/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -node_modules - -# Output -.output -.vercel -.netlify -.wrangler -/.svelte-kit -/build - -# OS -.DS_Store -Thumbs.db - -# Env -.env -.env.* -!.env.example -!.env.test - -# Vite -vite.config.js.timestamp-* -vite.config.ts.timestamp-* diff --git a/client/static/favicon.png b/client/static/favicon.png index 825b9e6..3aa4c4b 100644 Binary files a/client/static/favicon.png and b/client/static/favicon.png differ diff --git a/client/vite.config.ts b/client/vite.config.ts index b7f2539..ae1d5f1 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -6,5 +6,14 @@ export default defineConfig({ plugins: [ tailwindcss(), sveltekit() - ] + ], + server: { + proxy: { + '/grpc': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + host: '0.0.0.0', + } }); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..363d6cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + trevstack: + container_name: trevstack + build: + context: . + ports: + - "${PORT}:${PORT}" + environment: + - DB_TYPE=postgres + - DB_USER=${DB_USER} + - DB_PASS=${DB_PASS} + - DB_HOST=trevstack-db + - DB_PORT=5432 + - DB_NAME=${DB_NAME} + - PORT=${PORT} + - KEY=${KEY} + depends_on: + trevstack-db: + condition: service_healthy + links: + - trevstack-db + restart: unless-stopped + + trevstack-db: + container_name: trevstack-db + image: postgres + environment: + - POSTGRES_USER=${DB_USER} + - POSTGRES_PASSWORD=${DB_PASS} + - POSTGRES_DB=${DB_NAME} + volumes: + - trevstackdata:/var/lib/postgresql/data + healthcheck: + test: "pg_isready -U postgres" + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + trevstackdata: + name: trevstackdata + external: true \ No newline at end of file diff --git a/flake.nix b/flake.nix index 71a1674..f3a69d5 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,7 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { nixpkgs, flake-utils, ... }@inputs: + outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { @@ -32,14 +32,15 @@ go gotools gopls - goreleaser air # Protobuf middleware buf protoc-gen-go protoc-gen-connect-go + protoc-gen-es protoc-gen-connect-openapi + inotify-tools # Svelte frontend nodejs_22 @@ -50,7 +51,20 @@ text = '' gitroot=$(git rev-parse --show-toplevel) - (cd "''${gitroot}" && air) & (cd "''${gitroot}" && npm run dev) && fg + + (cd "''${gitroot}/server" && air) & + P1=$! + + (cd "''${gitroot}/client" && npm run dev) & + P2=$! + + protobufwatch & + P3=$! + + trap 'kill $P1 $P2 $P3' SIGINT SIGTERM + wait $P1 + wait $P2 + wait $P3 ''; }) @@ -59,20 +73,80 @@ text = '' gitroot=$(git rev-parse --show-toplevel) - (cd "''${gitroot}" && npm run build) && (cd "''${gitroot}" && go build -o ./build_server/golte .) + + cd "''${gitroot}" + buf lint + buf generate + + cd "''${gitroot}/client" + npm run build + cp -r build ../server/client + + cd "''${gitroot}/server" + go build -o ../build/trevstack . ''; }) (writeShellApplication { - name = "gen"; + name = "protobufwatch"; text = '' gitroot=$(git rev-parse --show-toplevel) - (cd "''${gitroot}" && buf generate) + + cd "''${gitroot}" + inotifywait -mre close_write,moved_to,create proto | while read -r _ _ basename; + do + echo "file changed: $basename" + buf lint + buf generate + echo "regenerated proto services" + done ''; }) ]; }; + + packages.default = pkgs.stdenv.mkDerivation { + pname = "trevstack"; + version = "1.0"; + + buildInputs = with pkgs; [ + # Go backend + go + gotools + gopls + + # Protobuf middleware + buf + protoc-gen-go + protoc-gen-connect-go + protoc-gen-es + protoc-gen-connect-openapi + + # Svelte frontend + nodejs_22 + ]; + + buildPhase = '' + gitroot=$(git rev-parse --show-toplevel) + + cd "''${gitroot}" + buf lint + buf generate + + cd "''${gitroot}/client" + npm run build + cp -r build ../server/client + + cd "''${gitroot}/server" + go build -o ../build/trevstack . + ''; + + installPhase = '' + mkdir -p $out/bin + cp build/trevstack $out/bin + ''; + }; } ); } diff --git a/proto/user/v1/auth.proto b/proto/user/v1/auth.proto new file mode 100644 index 0000000..e073e5b --- /dev/null +++ b/proto/user/v1/auth.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package user.v1; + +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse) {} + rpc SignUp (SignUpRequest) returns (SignUpResponse) {} + rpc Logout (LogoutRequest) returns (LogoutResponse) {} +} + +message LoginRequest { + string username = 1; + string password = 2; +} +message LoginResponse { + string token = 1; +} + +message SignUpRequest { + string username = 1; + string password = 2; +} +message SignUpResponse {} + +message LogoutRequest {} +message LogoutResponse {} \ No newline at end of file diff --git a/proto/user/v1/user.proto b/proto/user/v1/user.proto new file mode 100644 index 0000000..10a5c43 --- /dev/null +++ b/proto/user/v1/user.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package user.v1; + +service UserService { + rpc ChangePassword (ChangePasswordRequest) returns (ChangePasswordResponse) {} + rpc APIKey (APIKeyRequest) returns (APIKeyResponse) {} +} + +message ChangePasswordRequest { + string old_password = 1; + string new_password = 2; + string confirm_password = 3; +} +message ChangePasswordResponse {} + +message APIKeyRequest { + string password = 1; + string confirm_password = 2; +} +message APIKeyResponse { + string key = 1; +} diff --git a/server/.air.toml b/server/.air.toml new file mode 100644 index 0000000..498951f --- /dev/null +++ b/server/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..96d41ad --- /dev/null +++ b/server/go.mod @@ -0,0 +1,38 @@ +module github.com/spotdemo4/trevstack/server + +go 1.23.6 + +require ( + connectrpc.com/connect v1.18.1 + connectrpc.com/cors v0.1.0 + github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/joho/godotenv v1.5.1 + github.com/rs/cors v1.11.1 + golang.org/x/crypto v0.36.0 + golang.org/x/net v0.37.0 + google.golang.org/protobuf v1.36.5 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..48f8e81 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,78 @@ +connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= +connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/cors v0.1.0 h1:f3gTXJyDZPrDIZCQ567jxfD9PAIpopHiRDnJRt3QuOQ= +connectrpc.com/cors v0.1.0/go.mod h1:v8SJZCPfHtGH1zsm+Ttajpozd4cYIUryl4dFB6QEpfg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/server/internal/database/migrate.go b/server/internal/database/migrate.go new file mode 100644 index 0000000..44b3068 --- /dev/null +++ b/server/internal/database/migrate.go @@ -0,0 +1,15 @@ +package database + +import ( + "github.com/spotdemo4/trevstack/server/internal/models" + "gorm.io/gorm" +) + +func Migrate(db *gorm.DB) error { + err := db.AutoMigrate(&models.User{}) + if err != nil { + return err + } + + return nil +} diff --git a/server/internal/database/postgres.go b/server/internal/database/postgres.go new file mode 100644 index 0000000..cc9d290 --- /dev/null +++ b/server/internal/database/postgres.go @@ -0,0 +1,16 @@ +package database + +import ( + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func NewPostgresConnection(user, pass, host, port, name string) (*gorm.DB, error) { + dsn := "host=" + host + " user=" + user + " password=" + pass + " dbname=" + name + " port=" + port + " sslmode=disable TimeZone=UTC" + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, err + } + + return db, nil +} diff --git a/server/internal/database/sqlite.go b/server/internal/database/sqlite.go new file mode 100644 index 0000000..293fc66 --- /dev/null +++ b/server/internal/database/sqlite.go @@ -0,0 +1,33 @@ +package database + +import ( + "os" + "path/filepath" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func NewSQLiteConnection(name string) (*gorm.DB, error) { + // Find config diretory + configDir, err := os.UserConfigDir() + if err != nil { + return nil, err + } + + // Create database directory if not exists + settingsPath := filepath.Join(configDir, "trevstack") + err = os.MkdirAll(settingsPath, 0766) + if err != nil { + return nil, err + } + + // Open database + dbPath := filepath.Join(settingsPath, name) + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return nil, err + } + + return db, nil +} diff --git a/server/internal/handlers/auth.go b/server/internal/handlers/auth.go new file mode 100644 index 0000000..eebfffe --- /dev/null +++ b/server/internal/handlers/auth.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "connectrpc.com/connect" + "github.com/golang-jwt/jwt/v5" + "github.com/spotdemo4/trevstack/server/internal/models" + userv1 "github.com/spotdemo4/trevstack/server/internal/services/user/v1" + "github.com/spotdemo4/trevstack/server/internal/services/user/v1/userv1connect" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type AuthHandler struct { + db *gorm.DB + key []byte +} + +func (s *AuthHandler) Login(ctx context.Context, req *connect.Request[userv1.LoginRequest]) (*connect.Response[userv1.LoginResponse], error) { + // Validate + user := models.User{} + if err := s.db.First(&user, "username = ?", req.Msg.Username).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("invalid username or password")) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Msg.Password)); err != nil { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("invalid username or password")) + } + + // Generate JWT + t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Issuer: "trevstack", + Subject: strconv.FormatUint(uint64(user.ID), 10), + IssuedAt: &jwt.NumericDate{ + Time: time.Now(), + }, + ExpiresAt: &jwt.NumericDate{ + Time: time.Now().Add(time.Hour * 24), + }, + }) + ss, err := t.SignedString(s.key) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Create cookie + cookie := http.Cookie{ + Name: "token", + Value: ss, + Path: "/", + MaxAge: 86400, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + } + + res := connect.NewResponse(&userv1.LoginResponse{ + Token: ss, + }) + res.Header().Set("Set-Cookie", cookie.String()) + return res, nil +} + +func (s *AuthHandler) SignUp(ctx context.Context, req *connect.Request[userv1.SignUpRequest]) (*connect.Response[userv1.SignUpResponse], error) { + // Validate + if err := s.db.First(&models.User{}, "username = ?", req.Msg.Username).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, connect.NewError(connect.CodeInternal, err) + } + } else { + return nil, connect.NewError(connect.CodeAlreadyExists, errors.New("username already exists")) + } + + // Hash password + hash, err := bcrypt.GenerateFromPassword([]byte(req.Msg.Password), bcrypt.DefaultCost) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Create user + user := models.User{ + Username: req.Msg.Username, + Password: string(hash), + } + if err := s.db.Create(&user).Error; err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + res := connect.NewResponse(&userv1.SignUpResponse{}) + return res, nil +} + +func (s *AuthHandler) Logout(ctx context.Context, req *connect.Request[userv1.LogoutRequest]) (*connect.Response[userv1.LogoutResponse], error) { + // Clear cookie + cookie := http.Cookie{ + Name: "token", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + } + + res := connect.NewResponse(&userv1.LogoutResponse{}) + res.Header().Set("Set-Cookie", cookie.String()) + return res, nil +} + +func NewAuthHandler(db *gorm.DB, key string) (string, http.Handler) { + return userv1connect.NewAuthServiceHandler(&AuthHandler{ + db: db, + key: []byte(key), + }) +} diff --git a/server/internal/handlers/user.go b/server/internal/handlers/user.go new file mode 100644 index 0000000..d94bdc3 --- /dev/null +++ b/server/internal/handlers/user.go @@ -0,0 +1,109 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "connectrpc.com/connect" + "github.com/golang-jwt/jwt/v5" + "github.com/spotdemo4/trevstack/server/internal/interceptors" + "github.com/spotdemo4/trevstack/server/internal/models" + userv1 "github.com/spotdemo4/trevstack/server/internal/services/user/v1" + "github.com/spotdemo4/trevstack/server/internal/services/user/v1/userv1connect" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type UserHandler struct { + db *gorm.DB + key []byte +} + +func (s *UserHandler) ChangePassword(ctx context.Context, req *connect.Request[userv1.ChangePasswordRequest]) (*connect.Response[userv1.ChangePasswordResponse], error) { + userid, ok := interceptors.UserFromContext(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + + // Get user + user := models.User{} + if err := s.db.First(&user, "id = ?", userid).Error; err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Validate + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Msg.OldPassword)); err != nil { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("invalid password")) + } + if req.Msg.NewPassword != req.Msg.ConfirmPassword { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("passwords do not match")) + } + + // Hash password + hash, err := bcrypt.GenerateFromPassword([]byte(req.Msg.NewPassword), bcrypt.DefaultCost) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Update password + if err := s.db.Model(&user).Update("password", string(hash)).Error; err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + res := connect.NewResponse(&userv1.ChangePasswordResponse{}) + return res, nil +} + +func (s *UserHandler) APIKey(ctx context.Context, req *connect.Request[userv1.APIKeyRequest]) (*connect.Response[userv1.APIKeyResponse], error) { + userid, ok := interceptors.UserFromContext(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + + // Get user + user := models.User{} + if err := s.db.First(&user, "id = ?", userid).Error; err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Validate + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Msg.Password)); err != nil { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("invalid username or password")) + } + if req.Msg.Password != req.Msg.ConfirmPassword { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("passwords do not match")) + } + + // Generate JWT + t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Issuer: "trevstack", + Subject: strconv.FormatUint(uint64(user.ID), 10), + IssuedAt: &jwt.NumericDate{ + Time: time.Now(), + }, + }) + ss, err := t.SignedString(s.key) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + res := connect.NewResponse(&userv1.APIKeyResponse{ + Key: ss, + }) + return res, nil +} + +func NewUserHandler(db *gorm.DB, key string) (string, http.Handler) { + interceptors := connect.WithInterceptors(interceptors.NewAuthInterceptor(key)) + + return userv1connect.NewUserServiceHandler( + &UserHandler{ + db: db, + key: []byte(key), + }, + interceptors, + ) +} diff --git a/server/internal/interceptors/auth.go b/server/internal/interceptors/auth.go new file mode 100644 index 0000000..d2df950 --- /dev/null +++ b/server/internal/interceptors/auth.go @@ -0,0 +1,187 @@ +package interceptors + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strconv" + + "connectrpc.com/connect" + "github.com/golang-jwt/jwt/v5" +) + +type authInterceptor struct { + key string +} + +func NewAuthInterceptor(key string) *authInterceptor { + return &authInterceptor{ + key: key, + } +} + +func (i *authInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + // Same as previous UnaryInterceptorFunc. + return connect.UnaryFunc(func( + ctx context.Context, + req connect.AnyRequest, + ) (connect.AnyResponse, error) { + // Check if the request is from a client + if req.Spec().IsClient { + return next(ctx, req) + } + + // Check if the request contains a valid cookie token + cookies := getCookies(req.Header().Get("Cookie")) + for _, cookie := range cookies { + if cookie.Name == "token" { + subject, err := validateToken(cookie.Value, i.key) + if err == nil { + ctx, err = i.newContext(ctx, subject) + if err == nil { + return next(ctx, req) + } + } + } + } + + // Check if the request contains a valid authorization bearer token + authorization := req.Header().Get("Authorization") + if authorization != "" && len(authorization) > 7 { + subject, err := validateToken(authorization[7:], i.key) + if err == nil { + ctx, err = i.newContext(ctx, subject) + if err == nil { + return next(ctx, req) + } + } + } + + return nil, connect.NewError( + connect.CodeUnauthenticated, + errors.New("could not authenticate"), + ) + }) +} + +func (*authInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return connect.StreamingClientFunc(func( + ctx context.Context, + spec connect.Spec, + ) connect.StreamingClientConn { + return next(ctx, spec) + }) +} + +func (i *authInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return connect.StreamingHandlerFunc(func( + ctx context.Context, + conn connect.StreamingHandlerConn, + ) error { + // Check if the request contains a valid cookie token + cookies := getCookies(conn.RequestHeader().Get("Cookie")) + for _, cookie := range cookies { + if cookie.Name == "token" { + subject, err := validateToken(cookie.Value, i.key) + if err == nil { + ctx, err = i.newContext(ctx, subject) + if err == nil { + return next(ctx, conn) + } + } + } + } + + // Check if the request contains a valid authorization bearer token + authorization := conn.RequestHeader().Get("Authorization") + if authorization != "" && len(authorization) > 7 { + subject, err := validateToken(authorization[7:], i.key) + if err == nil { + ctx, err = i.newContext(ctx, subject) + if err == nil { + return next(ctx, conn) + } + } + } + + return connect.NewError( + connect.CodeUnauthenticated, + errors.New("could not authenticate"), + ) + }) +} + +func getCookies(rawCookies string) []*http.Cookie { + header := http.Header{} + header.Add("Cookie", rawCookies) + request := http.Request{Header: header} + + return request.Cookies() +} + +func validateToken(tokenString string, key string) (subject string, err error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Don't forget to validate the alg is what you expect: + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + return []byte(key), nil + }) + if err != nil { + return "", err + } + + switch { + case token.Valid: + subject, err := token.Claims.GetSubject() + if err != nil { + return "", err + } + + return subject, nil + + case errors.Is(err, jwt.ErrTokenMalformed): + log.Println("Token is malformed") + return "", err + + case errors.Is(err, jwt.ErrSignatureInvalid): + log.Println("Token signature is invalid") + return "", err + + case errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet): + log.Println("Token is expired or not valid yet") + return "", err + + default: + log.Println("Token is invalid") + return "", err + } +} + +// key is an unexported type for keys defined in this package. +// This prevents collisions with keys defined in other packages. +type key int + +// userKey is the key for user.User values in Contexts. It is +// unexported; clients use user.NewContext and user.FromContext +// instead of using this key directly. +var userKey key + +// NewContext returns a new Context that carries value u. +func (i *authInterceptor) newContext(ctx context.Context, subject string) (context.Context, error) { + id, err := strconv.Atoi(subject) + if err != nil { + return nil, err + } + + return context.WithValue(ctx, userKey, id), nil +} + +// FromContext returns the User value stored in ctx, if any. +func UserFromContext(ctx context.Context) (int, bool) { + u, ok := ctx.Value(userKey).(int) + return u, ok +} diff --git a/server/internal/models/user.go b/server/internal/models/user.go new file mode 100644 index 0000000..ef9903d --- /dev/null +++ b/server/internal/models/user.go @@ -0,0 +1,8 @@ +package models + +type User struct { + ID uint32 `gorm:"primaryKey"` + + Username string + Password string +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..d848eba --- /dev/null +++ b/server/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "embed" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + connectcors "connectrpc.com/cors" + "github.com/joho/godotenv" + "github.com/rs/cors" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + "gorm.io/gorm" + + "github.com/spotdemo4/trevstack/server/internal/database" + "github.com/spotdemo4/trevstack/server/internal/handlers" +) + +//go:embed all:client +var client embed.FS + +type env struct { + DBType string + DBUser string + DBPass string + DBHost string + DBPort string + DBName string + Port string + Key string +} + +func main() { + err := godotenv.Load() + if err != nil { + log.Println("Failed to load .env file, using environment variables") + } + + // Get environment variables for server + env := env{ + DBType: os.Getenv("DB_TYPE"), + DBUser: os.Getenv("DB_USER"), + DBPass: os.Getenv("DB_PASS"), + DBHost: os.Getenv("DB_HOST"), + DBPort: os.Getenv("DB_PORT"), + DBName: os.Getenv("DB_NAME"), + Port: os.Getenv("PORT"), + Key: os.Getenv("KEY"), + } + if env.Port == "" { + env.Port = "8080" + } + if env.Key == "" { + log.Fatal("KEY is required") + } + + // Get environment variables for database + db := &gorm.DB{} + switch env.DBType { + case "postgres": + log.Println("Using Postgres") + + if env.DBUser == "" { + log.Fatal("DB_USER is required") + } + if env.DBPass == "" { + log.Fatal("DB_PASS is required") + } + if env.DBHost == "" { + log.Fatal("DB_HOST is required") + } + if env.DBPort == "" { + log.Fatal("DB_PORT is required") + } + if env.DBName == "" { + log.Fatal("DB_NAME is required") + } + + db, err = database.NewPostgresConnection(env.DBUser, env.DBPass, env.DBHost, env.DBPort, env.DBName) + if err != nil { + log.Fatalf("failed to connect to postgres: %v", err) + } + + case "sqlite": + log.Println("Using SQLite") + + if env.DBName == "" { + log.Fatal("DB_NAME is required") + } + + db, err = database.NewSQLiteConnection(env.DBName) + if err != nil { + log.Fatalf("failed to connect to sqlite: %v", err) + } + + default: + log.Fatal("DB_TYPE must be either postgres or sqlite") + } + + // Init database + if err := database.Migrate(db); err != nil { + log.Fatalf("failed to migrate database: %v", err) + } + + // Serve GRPC Handlers + api := http.NewServeMux() + api.Handle(withCORS(handlers.NewAuthHandler(db, env.Key))) + api.Handle(withCORS(handlers.NewUserHandler(db, env.Key))) + + // Serve web interface + mux := http.NewServeMux() + clientFs, err := fs.Sub(client, "client") + if err != nil { + log.Fatalf("failed to get sub filesystem: %v", err) + } + mux.Handle("/", http.FileServer(http.FS(clientFs))) + mux.Handle("/grpc/", http.StripPrefix("/grpc", api)) + + // Start server + log.Printf("Starting server on :%s", env.Port) + server := &http.Server{ + Addr: fmt.Sprintf(":%s", env.Port), + Handler: h2c.NewHandler(mux, &http2.Server{}), + } + + // Gracefully shutdown on SIGINT or SIGTERM + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigs + log.Printf("Received signal %s", sig) + log.Println("Exiting") + + // Close HTTP server + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := server.Shutdown(ctx); err != nil { + server.Close() + } + cancel() + + // Close database connection + sqlDB, err := db.DB() // Get underlying SQL database + if err == nil { + sqlDB.Close() + } + }() + + server.ListenAndServe() +} + +// withCORS adds CORS support to a Connect HTTP handler. +func withCORS(pattern string, h http.Handler) (string, http.Handler) { + middleware := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: connectcors.AllowedMethods(), + AllowedHeaders: connectcors.AllowedHeaders(), + ExposedHeaders: connectcors.ExposedHeaders(), + }) + return pattern, middleware.Handler(h) +}