init 2
This commit is contained in:
parent
46d93a62a1
commit
5657d40c02
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@ -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
|
15
.gitignore
vendored
15
.gitignore
vendored
@ -1 +1,14 @@
|
|||||||
.direnv
|
.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
|
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@ -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"]
|
18
base.openapi.yaml
Normal file
18
base.openapi.yaml
Normal file
@ -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: []
|
27
buf.gen.yaml
Normal file
27
buf.gen.yaml
Normal file
@ -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
|
4
buf.yaml
Normal file
4
buf.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml
|
||||||
|
version: v2
|
||||||
|
modules:
|
||||||
|
- path: proto
|
23
client/.gitignore
vendored
23
client/.gitignore
vendored
@ -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-*
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 662 KiB |
@ -6,5 +6,14 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
sveltekit()
|
sveltekit()
|
||||||
]
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/grpc': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
host: '0.0.0.0',
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@ -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
|
86
flake.nix
86
flake.nix
@ -6,7 +6,7 @@
|
|||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { nixpkgs, flake-utils, ... }@inputs:
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
let
|
let
|
||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
@ -32,14 +32,15 @@
|
|||||||
go
|
go
|
||||||
gotools
|
gotools
|
||||||
gopls
|
gopls
|
||||||
goreleaser
|
|
||||||
air
|
air
|
||||||
|
|
||||||
# Protobuf middleware
|
# Protobuf middleware
|
||||||
buf
|
buf
|
||||||
protoc-gen-go
|
protoc-gen-go
|
||||||
protoc-gen-connect-go
|
protoc-gen-connect-go
|
||||||
|
protoc-gen-es
|
||||||
protoc-gen-connect-openapi
|
protoc-gen-connect-openapi
|
||||||
|
inotify-tools
|
||||||
|
|
||||||
# Svelte frontend
|
# Svelte frontend
|
||||||
nodejs_22
|
nodejs_22
|
||||||
@ -50,7 +51,20 @@
|
|||||||
|
|
||||||
text = ''
|
text = ''
|
||||||
gitroot=$(git rev-parse --show-toplevel)
|
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 = ''
|
text = ''
|
||||||
gitroot=$(git rev-parse --show-toplevel)
|
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 {
|
(writeShellApplication {
|
||||||
name = "gen";
|
name = "protobufwatch";
|
||||||
|
|
||||||
text = ''
|
text = ''
|
||||||
gitroot=$(git rev-parse --show-toplevel)
|
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
|
||||||
|
'';
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
26
proto/user/v1/auth.proto
Normal file
26
proto/user/v1/auth.proto
Normal file
@ -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 {}
|
23
proto/user/v1/user.proto
Normal file
23
proto/user/v1/user.proto
Normal file
@ -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;
|
||||||
|
}
|
52
server/.air.toml
Normal file
52
server/.air.toml
Normal file
@ -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
|
38
server/go.mod
Normal file
38
server/go.mod
Normal file
@ -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
|
||||||
|
)
|
78
server/go.sum
Normal file
78
server/go.sum
Normal file
@ -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=
|
15
server/internal/database/migrate.go
Normal file
15
server/internal/database/migrate.go
Normal file
@ -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
|
||||||
|
}
|
16
server/internal/database/postgres.go
Normal file
16
server/internal/database/postgres.go
Normal file
@ -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
|
||||||
|
}
|
33
server/internal/database/sqlite.go
Normal file
33
server/internal/database/sqlite.go
Normal file
@ -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
|
||||||
|
}
|
122
server/internal/handlers/auth.go
Normal file
122
server/internal/handlers/auth.go
Normal file
@ -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),
|
||||||
|
})
|
||||||
|
}
|
109
server/internal/handlers/user.go
Normal file
109
server/internal/handlers/user.go
Normal file
@ -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,
|
||||||
|
)
|
||||||
|
}
|
187
server/internal/interceptors/auth.go
Normal file
187
server/internal/interceptors/auth.go
Normal file
@ -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
|
||||||
|
}
|
8
server/internal/models/user.go
Normal file
8
server/internal/models/user.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint32 `gorm:"primaryKey"`
|
||||||
|
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
167
server/main.go
Normal file
167
server/main.go
Normal file
@ -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)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user