diff --git a/.gitignore b/.gitignore index adf8f72..facf30f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ + + # ---> Go # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore @@ -21,3 +23,7 @@ # Go workspace file go.work +.vscode +*_templ.go +server +.env \ No newline at end of file diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..f5234ab --- /dev/null +++ b/Justfile @@ -0,0 +1,10 @@ + + +# Generates templ files +gen: + templ generate + +build: + templ generate + go build cmd/server.go + ls -lh server \ No newline at end of file diff --git a/apiclient/articles.go b/apiclient/articles.go new file mode 100644 index 0000000..5f54596 --- /dev/null +++ b/apiclient/articles.go @@ -0,0 +1,57 @@ +package apiclient + +import ( + "encoding/json" + "fmt" + "net/http" + + "git.jamestombleson.com/jtom38/newsbot-api/domain" +) + +const ( + ArticlesBaseRoute = "api/v1/articles" +) + +type Articles interface { + List(jwt string, page int) (domain.ArticleResponse, error) +} + +type articlesClient struct { + serverAddress string + client http.Client +} + +func newArticleService(serverAddress string) articlesClient { + return articlesClient{ + serverAddress: serverAddress, + client: http.Client{}, + } +} + +func (c articlesClient) List(jwt string, page int) (domain.ArticleResponse, error) { + endpoint := fmt.Sprintf("%s/%s?page=%U", c.serverAddress, ArticlesBaseRoute, page) + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return domain.ArticleResponse{}, err + } + + //req.Header.Set(HeaderContentType, ApplicationJson) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) + resp, err := c.client.Do(req) + if err != nil { + return domain.ArticleResponse{}, err + } + defer resp.Body.Close() + + var bind domain.ArticleResponse + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&bind) + if err != nil { + return domain.ArticleResponse{}, err + } + + return bind, nil + + +} diff --git a/apiclient/client.go b/apiclient/client.go new file mode 100644 index 0000000..86ae79f --- /dev/null +++ b/apiclient/client.go @@ -0,0 +1,21 @@ +package apiclient + +const ( + HeaderContentType = "Content-Type" + MIMEApplicationForm = "application/x-www-form-urlencoded" + ApplicationJson = "application/json" +) + +type ApiClient struct { + Articles Articles + Users Users + Sources Sources +} + +func New(serverAddress string) ApiClient { + return ApiClient{ + Articles: newArticleService(serverAddress), + Users: newUserService(serverAddress), + Sources: newSourceService(serverAddress), + } +} diff --git a/apiclient/sources.go b/apiclient/sources.go new file mode 100644 index 0000000..5af6d9f --- /dev/null +++ b/apiclient/sources.go @@ -0,0 +1,77 @@ +package apiclient + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "git.jamestombleson.com/jtom38/newsbot-api/domain" +) + +const ( + SourcesBaseRoute = "api/v1/sources" +) + +type Sources interface { + ListAll(jwt string, page int) (domain.SourcesResponse, error) + GetById(jwt string, id int64) (domain.SourcesResponse, error) +} + +type sourceClient struct { + serverAddress string + client http.Client +} + +func newSourceService(serverAddress string) sourceClient { + return sourceClient{ + serverAddress: serverAddress, + client: http.Client{}, + } +} + +func (c sourceClient) ListAll(jwt string, page int) (domain.SourcesResponse, error) { + var bind domain.SourcesResponse + endpoint := fmt.Sprintf("%s/%s?page=%U", c.serverAddress, SourcesBaseRoute, page) + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return bind, err + } + + req.Header.Set(HeaderContentType, ApplicationJson) + req.Header.Set(HeaderAuthorization, fmt.Sprintf("Bearer %s", jwt)) + resp, err := c.client.Do(req) + if err != nil { + return bind, err + } + defer resp.Body.Close() + + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&bind) + if err != nil { + return bind, err + } + + if (resp.StatusCode != 200) { + return bind, errors.New(bind.Message) + } + + return bind, nil +} + +func (c sourceClient) GetById(jwt string, id int64) (domain.SourcesResponse, error) { + bind := domain.SourcesResponse{} + endpoint := fmt.Sprintf("%s/%s/%d", c.serverAddress, SourcesBaseRoute, id) + + statusCode, err := Get(c.client, endpoint, jwt, &bind) + if err != nil { + return bind, err + } + + if (statusCode != 200) { + return bind, errors.New(bind.Message) + } + + return bind, nil +} diff --git a/apiclient/users.go b/apiclient/users.go new file mode 100644 index 0000000..37ec219 --- /dev/null +++ b/apiclient/users.go @@ -0,0 +1,94 @@ +package apiclient + +import ( + "fmt" + "net/http" + "net/url" + + "git.jamestombleson.com/jtom38/newsbot-api/domain" +) + +const ( + UserBaseRoute = "api/v1/users" +) + +type Users interface { + Login(username, password string) (domain.LoginResponse, error) + SignUp(username, password string) (domain.BaseResponse, error) + RefreshJwtToken(username, refreshToken string) (domain.LoginResponse, error) + RefreshSessionToken(jwtToken string) (domain.BaseResponse, error) +} + +type userClient struct { + serverAddress string + client http.Client +} + +func newUserService(serverAddress string) userClient { + return userClient{ + serverAddress: serverAddress, + client: http.Client{}, + } +} + +func (a userClient) Login(username, password string) (domain.LoginResponse, error) { + endpoint := fmt.Sprintf("%s/%s/login", a.serverAddress, UserBaseRoute) + + param := url.Values{} + param.Set("username", username) + param.Set("password", password) + + // Create the struct we are expecting back from the API so we can have it populated + var bind = domain.LoginResponse{} + err := PostUrlForm(a.client, endpoint, param, &bind) + if err != nil { + return domain.LoginResponse{}, err + } + + return bind, nil +} + +func (a userClient) SignUp(username, password string) (domain.BaseResponse, error) { + endpoint := fmt.Sprintf("%s/%s/register", a.serverAddress, UserBaseRoute) + + param := url.Values{} + param.Set("username", username) + param.Set("password", password) + + // Create the struct we are expecting back from the API so we can have it populated + var bind = domain.BaseResponse{} + err := PostUrlForm(a.client, endpoint, param, &bind) + if err != nil { + return domain.BaseResponse{}, err + } + + return bind, nil +} + +func (a userClient) RefreshJwtToken(username, refreshToken string) (domain.LoginResponse, error) { + endpoint := fmt.Sprintf("%s/%s/refresh/token", a.serverAddress, UserBaseRoute) + body := domain.RefreshTokenRequest{ + Username: username, + RefreshToken: refreshToken, + } + + var bind = domain.LoginResponse{} + err := PostBodyUrl(a.client, endpoint, body, &bind) + if err != nil { + return domain.LoginResponse{}, err + } + + return bind, nil +} + +func (a userClient) RefreshSessionToken(jwtToken string) (domain.BaseResponse, error) { + endpoint := fmt.Sprintf("%s/%s/refresh/sessionToken", a.serverAddress, UserBaseRoute) + + var bind = domain.BaseResponse{} + err := PostUrlAuthorized(a.client, endpoint, jwtToken, &bind) + if err != nil { + return domain.BaseResponse{}, err + } + + return bind, nil +} diff --git a/apiclient/util.go b/apiclient/util.go new file mode 100644 index 0000000..8be09a0 --- /dev/null +++ b/apiclient/util.go @@ -0,0 +1,103 @@ +package apiclient + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +const ( + HeaderAuthorization = "Authorization" +) + +func PostUrlForm(client http.Client, endpoint string, param url.Values, t any) error { + payload := bytes.NewBufferString(param.Encode()) + req, err := http.NewRequest(http.MethodPost, endpoint, payload) + if err != nil { + return err + } + + req.Header.Set(HeaderContentType, MIMEApplicationForm) + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&t) + if err != nil { + return err + } + + return nil +} + +func PostUrlAuthorized(client http.Client, endpoint, jwtToken string, t any) error { + req, err := http.NewRequest(http.MethodPost, endpoint, nil) + if err != nil { + return err + } + req.Header.Add(HeaderAuthorization, fmt.Sprintf("%s %s", "Bearer", jwtToken)) + + response, err := client.Do(req) + //response, err := http.Post(endpoint, ApplicationJson, nil) + if err != nil { + return err + } + + defer response.Body.Close() + decoder := json.NewDecoder(response.Body) + err = decoder.Decode(&t) + if err != nil { + return err + } + + return nil +} + +func PostBodyUrl(client http.Client, endpoint string, body any, t any) error { + jsonBody, err := json.Marshal(body) + if err != nil { + return err + } + + response, err := http.Post(endpoint, ApplicationJson, bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + + defer response.Body.Close() + decoder := json.NewDecoder(response.Body) + err = decoder.Decode(&t) + if err != nil { + return err + } + + return nil +} + +func Get(client http.Client, endpoint, jwt string, t any) (int, error) { + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return -1, err + } + + req.Header.Set(HeaderContentType, ApplicationJson) + req.Header.Set(HeaderAuthorization, fmt.Sprintf("Bearer %s", jwt)) + resp, err := client.Do(req) + if err != nil { + return -1, err + } + defer resp.Body.Close() + + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&t) + if err != nil { + return -1, err + } + + return resp.StatusCode, nil +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..3675fc2 --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "fmt" + + "git.jamestombleson.com/jtom38/newsbot-portal/apiclient" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/config" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/handlers" +) + +func main() { + ctx := context.Background() + + cfg := config.New() + apiClient := apiclient.New(cfg.ServerAddress) + server := handlers.NewServer(ctx, cfg, apiClient) + + fmt.Println("The server is online and waiting for requests.") + fmt.Printf("http://%v:8082\r\n", cfg.ServerAddress) + + server.Router.Start(":8082") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e595ac3 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module git.jamestombleson.com/jtom38/newsbot-portal + +go 1.22.1 + +require ( + git.jamestombleson.com/jtom38/newsbot-api v0.0.0-20240603002809-9237369e5a76 + github.com/a-h/templ v0.2.680 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/joho/godotenv v1.5.1 + github.com/labstack/echo/v4 v4.12.0 +) + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..94ff39f --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +git.jamestombleson.com/jtom38/newsbot-api v0.0.0-20240603002809-9237369e5a76 h1:B9t5fcfVerMjqnXXPUmYwdmUk76EoEL8x9IRehqg2c4= +git.jamestombleson.com/jtom38/newsbot-api v0.0.0-20240603002809-9237369e5a76/go.mod h1:A3UdJyQ/IEy3utEwJiC4nbi0ohfgrUNRLTei2iZhLLA= +github.com/a-h/templ v0.2.680 h1:TflYFucxp5rmOxAXB9Xy3+QHTk8s8xG9+nCT/cLzjeE= +github.com/a-h/templ v0.2.680/go.mod h1:NQGQOycaPKBxRB14DmAaeIpcGC1AOBPJEMO4ozS7m90= +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/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..364bb2c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,47 @@ +package config + +import ( + "log" + "os" + + "github.com/joho/godotenv" +) + +type Configs struct { + ServerAddress string + JwtSecret string +} + +func New() Configs { + refreshEnv() + c := getEnvConfig() + return c +} + +func getEnvConfig() Configs { + return Configs{ + ServerAddress: os.Getenv("ServerAddress"), + JwtSecret: os.Getenv("JwtSecret"), + } +} + +// Use this when your ConfigClient has been opened for awhile and you want to ensure you have the most recent env changes. +func refreshEnv() { + // Check to see if we have the env file on the system + _, err := os.Stat(".env") + + // We have the file, load it. + if err == nil { + _, err := os.Open(".env") + if err == nil { + loadEnvFile() + } + } +} + +func loadEnvFile() { + err := godotenv.Load() + if err != nil { + log.Fatalln(err) + } +} diff --git a/internal/domain/context.go b/internal/domain/context.go new file mode 100644 index 0000000..c61859e --- /dev/null +++ b/internal/domain/context.go @@ -0,0 +1,5 @@ +package domain + +type contextKey string + +var UserNameContext contextKey = "username" \ No newline at end of file diff --git a/internal/domain/cookie.go b/internal/domain/cookie.go new file mode 100644 index 0000000..9954b4a --- /dev/null +++ b/internal/domain/cookie.go @@ -0,0 +1,7 @@ +package domain + +const ( + CookieToken = "newsbot.token" + CookieRefreshToken = "newsbot.refreshToken" + CookieUser = "newsbot.user" +) diff --git a/internal/handlers/articles.go b/internal/handlers/articles.go new file mode 100644 index 0000000..91d7479 --- /dev/null +++ b/internal/handlers/articles.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "net/http" + + "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/articles" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" + "github.com/labstack/echo/v4" +) + +func (h *Handler) ArticlesList(c echo.Context) error { + _, err := ValidateJwt(c, h.config.JwtSecret, h.config.ServerAddress) + if err != nil { + return Render(c, http.StatusOK, layout.Error(err)) + } + + userToken, err := c.Cookie(domain.CookieToken) + if err != nil { + return Render(c, http.StatusBadRequest, layout.Error(err)) + } + + resp, err := h.api.Articles.List(userToken.Value, 0) + if err != nil { + return Render(c, http.StatusBadRequest, layout.Error(err)) + } + + vm := models.ListArticlesViewModel{} + + for _, article := range resp.Payload { + source, err := h.api.Sources.GetById(userToken.Value, article.SourceID) + if err != nil { + return Render(c, http.StatusBadRequest, layout.Error(err)) + } + + item := models.ListArticleSourceModel { + Article: article, + Source: source.Payload[0], + } + vm.Items = append(vm.Items, item) + + } + + return Render(c, http.StatusOK, articles.List(vm)) +} diff --git a/internal/handlers/debug.go b/internal/handlers/debug.go new file mode 100644 index 0000000..baaac33 --- /dev/null +++ b/internal/handlers/debug.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "log" + "net/http" + + "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/debug" + "github.com/labstack/echo/v4" +) + +func (h *Handler) DebugCookies(c echo.Context) error { + model := models.DebugCookiesViewModel{} + + allCookies := c.Cookies() + log.Print(allCookies) + + user, err := c.Cookie(domain.CookieUser) + if err == nil { + model.Username = user.Value + } + + token, err := c.Cookie(domain.CookieToken) + if err == nil { + model.Token = token.Value + } + + refresh, err := c.Cookie(domain.CookieRefreshToken) + if err == nil { + model.RefreshToken = refresh.Value + } + + return Render(c, http.StatusOK, debug.Cookies(model)) +} diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go new file mode 100644 index 0000000..9ef3e35 --- /dev/null +++ b/internal/handlers/handler.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "context" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + + "git.jamestombleson.com/jtom38/newsbot-portal/apiclient" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/config" +) + +const ( + ErrParameterIdMissing = "The requested parameter ID was not found." + ErrParameterMissing = "The requested parameter was not found found:" + ErrUnableToParseId = "Unable to parse the requested ID" + + ErrRecordMissing = "The requested record was not found" + ErrFailedToCreateRecord = "The record was not created due to a database problem" + ErrFailedToUpdateRecord = "The requested record was not updated due to a database problem" + + ErrUserUnknown = "User is unknown" + ErrYouDontOwnTheRecord = "The record requested does not belong to you" + + ResponseMessageSuccess = "Success" +) + +var ( + ErrIdValueMissing string = "id value is missing" + ErrValueNotUuid string = "a value given was expected to be a uuid but was not correct." + ErrNoRecordFound string = "no record was found." + ErrUnableToConvertToJson string = "Unable to convert to json" +) + +type Handler struct { + Router *echo.Echo + config config.Configs + api apiclient.ApiClient +} + +func NewServer(ctx context.Context, configs config.Configs, apiClient apiclient.ApiClient) *Handler { + s := &Handler{ + config: configs, + api: apiClient, + } + + router := echo.New() + router.Pre(middleware.RemoveTrailingSlash()) + router.Pre(middleware.Logger()) + router.Pre(middleware.Recover()) + router.Use(middleware.Static("/internal/static")) + + router.GET("/", s.HomeIndex) + router.GET("/about", s.HomeAbout) + + debug := router.Group("/debug") + debug.GET("/cookies", s.DebugCookies) + + articles := router.Group("/articles") + articles.GET("", s.ArticlesList) + + sources := router.Group("/sources") + sources.GET("", s.ListAllSources) + + users := router.Group("/users") + users.GET("/login", s.UserLogin) + users.POST("/login", s.UserAfterLogin) + users.GET("/signup", s.UserSignUp) + users.POST("/signup", s.UserAfterSignUp) + users.GET("/logout", s.UsersLogout) + users.GET("/profile", s.UserProfile) + + s.Router = router + return s +} + +// If the token is not valid then an json error will be returned. +// If the token has the wrong scope, a json error will be returned. +// If the token passes all the checks, it is valid and is returned back to the caller. +//func (s *Handler) ValidateJwtToken(c echo.Context, requiredScope string) (JwtToken, error) { +// token, err := s.getJwtTokenFromContext(c) +// if err != nil { +// s.WriteMessage(c, ErrJwtMissing, http.StatusUnauthorized) +// } +// +// err = token.hasExpired() +// if err != nil { +// return JwtToken{}, errors.New(ErrJwtExpired) +// //s.WriteMessage(c, ErrJwtExpired, http.StatusUnauthorized) +// } +// +// err = token.hasScope(requiredScope) +// if err != nil { +// return JwtToken{}, errors.New(ErrJwtScopeMissing) +// //s.WriteMessage(c, ErrJwtScopeMissing, http.StatusUnauthorized) +// } +// +// if token.Iss != s.config.ServerAddress { +// return JwtToken{}, errors.New(ErrJwtInvalidIssuer) +// //s.WriteMessage(c, ErrJwtInvalidIssuer, http.StatusUnauthorized) +// } +// +// return token, nil +//} + +//func (s *Handler) GetUserIdFromJwtToken(c echo.Context) int64 { +// token, err := s.getJwtTokenFromContext(c) +// if err != nil { +// s.WriteMessage(c, ErrJwtMissing, http.StatusUnauthorized) +// } +// +// return token.GetUserId() +//} diff --git a/internal/handlers/home.go b/internal/handlers/home.go new file mode 100644 index 0000000..b47879f --- /dev/null +++ b/internal/handlers/home.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "net/http" + + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/home" + "github.com/labstack/echo/v4" +) + +func (h *Handler) HomeIndex(c echo.Context) error { + return Render(c, http.StatusOK, home.Index()) +} + +func (h *Handler) HomeAbout(c echo.Context) error { + return Render(c, http.StatusOK, home.About()) +} \ No newline at end of file diff --git a/internal/handlers/sources.go b/internal/handlers/sources.go new file mode 100644 index 0000000..f912e61 --- /dev/null +++ b/internal/handlers/sources.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "net/http" + + "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/sources" + "github.com/labstack/echo/v4" +) + +func (h *Handler) ListAllSources(c echo.Context) error { + _, err := ValidateJwt(c, h.config.JwtSecret, h.config.ServerAddress) + if err != nil { + return Render(c, http.StatusOK, layout.Error(err)) + } + + userToken, err := c.Cookie(domain.CookieToken) + if err != nil { + return Render(c, http.StatusBadRequest, layout.Error(err)) + } + + resp, err := h.api.Sources.ListAll(userToken.Value, 0) + if err != nil { + return Render(c, http.StatusOK, layout.Error(err)) + } + + return Render(c, http.StatusOK, sources.ListAll(models.ListAllSourcesViewModel{ + Items: resp.Payload, + IsError: resp.IsError, + Message: resp.Message, + })) +} diff --git a/internal/handlers/users.go b/internal/handlers/users.go new file mode 100644 index 0000000..b76311c --- /dev/null +++ b/internal/handlers/users.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "fmt" + "net/http" + + "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/users" + "github.com/labstack/echo/v4" +) + +func (h *Handler) UserLogin(c echo.Context) error { + return Render(c, http.StatusOK, users.Login()) +} + +func (h *Handler) UserAfterLogin(c echo.Context) error { + user := c.FormValue("username") + password := c.FormValue("password") + + resp, err := h.api.Users.Login(user, password) + if err != nil { + return Render(c, http.StatusBadRequest, users.AfterLogin(err.Error(), false)) + } + + if user == "" { + user = "admin" + } + + SetCookie(c, domain.CookieToken, resp.Token, "/") + SetCookie(c, domain.CookieRefreshToken, resp.RefreshToken, "/") + SetCookie(c, domain.CookieUser, user, "/") + + return Render(c, http.StatusOK, users.AfterLogin("Login Successful!", true)) +} + +func (h *Handler) UserSignUp(c echo.Context) error { + return Render(c, http.StatusOK, users.SignUp()) +} + +func (h *Handler) UserAfterSignUp(c echo.Context) error { + user := c.FormValue("username") + password := c.FormValue("password") + + resp, err := h.api.Users.SignUp(user, password) + if err != nil { + return Render(c, http.StatusBadRequest, users.AfterLogin(err.Error(), false)) + } + if resp.Message != "OK" { + msg := fmt.Sprintf("Failed to create account. Message: %s", resp.Message) + return Render(c, http.StatusBadRequest, users.AfterLogin(msg, false)) + } + return Render(c, http.StatusOK, users.AfterSignUp("Registration Successful!", true)) +} + +func (h *Handler) UserProfile(c echo.Context) error { + return Render(c, http.StatusOK, users.Profile()) +} + +func (h *Handler) ForceLogout(c echo.Context) error { + _, err := ValidateJwt(c, h.config.JwtSecret, h.config.ServerAddress) + if err != nil { + return Render(c, http.StatusOK, layout.Error(err)) + } + + h.api.Users.RefreshSessionToken(GetJwtToken(c)) + return nil +} + +func (h *Handler) UsersLogout(c echo.Context) error { + SetCookie(c, domain.CookieUser, "", "/") + SetCookie(c, domain.CookieRefreshToken, "", "/") + SetCookie(c, domain.CookieToken, "", "/") + + return Render(c, http.StatusOK, users.Logout()) +} diff --git a/internal/handlers/util.go b/internal/handlers/util.go new file mode 100644 index 0000000..0eaea40 --- /dev/null +++ b/internal/handlers/util.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "time" + + "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" + "github.com/a-h/templ" + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" +) + +func SetCookie(c echo.Context, key, value, path string) { + cookie := new(http.Cookie) + cookie.Name = key + cookie.Value = value + if path != "" { + cookie.Path = path + } + c.SetCookie(cookie) +} + +type jwtToken struct { + Exp time.Time `json:"exp"` + Iss string `json:"iss"` + Authorized bool `json:"authorized"` + UserName string `json:"username"` + UserId int64 `json:"userId"` + Scopes []string `json:"scopes"` + SessionToken string `json:"sessionToken"` + jwt.RegisteredClaims +} + +func ValidateJwt(ctx echo.Context, sharedSecret, issuer string) (jwtToken, error) { + cookie, err := ctx.Cookie(domain.CookieToken) + if err != nil { + return jwtToken{}, err + } + + if cookie.Value == "" { + return jwtToken{}, errors.New("JWT Bearer Token is missing") + } + + token, err := jwt.ParseWithClaims(cookie.Value, &jwtToken{}, func(token *jwt.Token) (interface{}, error) { + return []byte(sharedSecret), nil + }) + if err != nil { + return jwtToken{}, err + } + + if !token.Valid { + return jwtToken{}, errors.New("invalid jwt token") + } + + claims := token.Claims.(*jwtToken) + if !claims.Exp.After(time.Now()) { + return jwtToken{}, errors.New("the jwt token has expired") + } + //if claims.Iss != issuer { + // return jwtToken{}, errors.New("the issuer was invalid") + //} + + return *claims, nil +} + +func GetJwtToken(c echo.Context) string { + cookie, err := c.Cookie(domain.CookieToken) + if err != nil { + return "" + } + return cookie.Value +} + +func Render(ctx echo.Context, statusCode int, t templ.Component) error { + ctx.Response().Writer.WriteHeader(statusCode) + ctx.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML) + + // take the request context and make it a var + request := ctx.Request().Context() + + //Check to see if we the echo context has the cookie we are looking for, if so, create a new context based on what we had and add the value + username, err := ctx.Cookie(domain.CookieUser) + if err == nil { + request = context.WithValue(request, domain.UserNameContext, username.Value) + } else { + request = context.WithValue(request, domain.UserNameContext, "") + } + + return t.Render(request, ctx.Response().Writer) +} diff --git a/internal/models/articles.go b/internal/models/articles.go new file mode 100644 index 0000000..d934049 --- /dev/null +++ b/internal/models/articles.go @@ -0,0 +1,12 @@ +package models + +import "git.jamestombleson.com/jtom38/newsbot-api/domain" + +type ListArticlesViewModel struct { + Items []ListArticleSourceModel +} + +type ListArticleSourceModel struct { + Article domain.ArticleDto + Source domain.SourceDto +} diff --git a/internal/models/debug.go b/internal/models/debug.go new file mode 100644 index 0000000..0621d83 --- /dev/null +++ b/internal/models/debug.go @@ -0,0 +1,7 @@ +package models + +type DebugCookiesViewModel struct { + Username string + Token string + RefreshToken string +} diff --git a/internal/models/layout.go b/internal/models/layout.go new file mode 100644 index 0000000..efdc03d --- /dev/null +++ b/internal/models/layout.go @@ -0,0 +1,6 @@ +package models + +type LayoutIsLoggedInViewModel struct { + IsLoggedIn bool + Username string +} diff --git a/internal/models/sources.go b/internal/models/sources.go new file mode 100644 index 0000000..ac44124 --- /dev/null +++ b/internal/models/sources.go @@ -0,0 +1,9 @@ +package models + +import "git.jamestombleson.com/jtom38/newsbot-api/domain" + +type ListAllSourcesViewModel struct { + IsError bool + Message string + Items []domain.SourceDto +} diff --git a/internal/static/Newsbot.svg b/internal/static/Newsbot.svg new file mode 100644 index 0000000..8a2f76e --- /dev/null +++ b/internal/static/Newsbot.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/internal/static/css/main.css b/internal/static/css/main.css new file mode 100644 index 0000000..d3a17ff --- /dev/null +++ b/internal/static/css/main.css @@ -0,0 +1,4 @@ +.pin-bottom { + position: absolute; + bottom: 0; +} \ No newline at end of file diff --git a/internal/views/articles/filter.templ b/internal/views/articles/filter.templ new file mode 100644 index 0000000..e2433da --- /dev/null +++ b/internal/views/articles/filter.templ @@ -0,0 +1,31 @@ +package articles + +templ filterBar() { + + +} diff --git a/internal/views/articles/list.templ b/internal/views/articles/list.templ new file mode 100644 index 0000000..10cff5d --- /dev/null +++ b/internal/views/articles/list.templ @@ -0,0 +1,17 @@ +package articles + +import ( + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" + "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/bulma" +) + +templ List(model models.ListArticlesViewModel) { + @layout.WithTemplate() { + @filterBar() + for _, item := range model.Items { + @bulma.ArticleCardWithThumbnail(item.Article.Title, item.Article.Thumbnail, item.Article.Url, item.Article.PubDate.String(), item.Source.DisplayName ) + + } + } +} diff --git a/internal/views/bulma/card.templ b/internal/views/bulma/card.templ new file mode 100644 index 0000000..9b226a2 --- /dev/null +++ b/internal/views/bulma/card.templ @@ -0,0 +1,22 @@ +package bulma + +templ ArticleCardWithThumbnail(title, thumbnailUrl, url, datePosted, sourceName string) { +
+
+
+ +
+
+
+
+
+ { title } +
+
+
+ { datePosted }
+ { sourceName } +
+
+
+} diff --git a/internal/views/bulma/hero.templ b/internal/views/bulma/hero.templ new file mode 100644 index 0000000..ce5dd39 --- /dev/null +++ b/internal/views/bulma/hero.templ @@ -0,0 +1,10 @@ +package bulma + +templ Hero(title, subtitle string) { +
+
+

{ title }

+

{ subtitle }

+
+
+} diff --git a/internal/views/bulma/section.templ b/internal/views/bulma/section.templ new file mode 100644 index 0000000..1726976 --- /dev/null +++ b/internal/views/bulma/section.templ @@ -0,0 +1,11 @@ +package bulma + +templ Section(title, subtitle string) { +
+

Section

+

+ A simple container to divide your page into sections, like + the one you're currently reading. +

+
+} diff --git a/internal/views/debug/cookies.templ b/internal/views/debug/cookies.templ new file mode 100644 index 0000000..0f7d5e1 --- /dev/null +++ b/internal/views/debug/cookies.templ @@ -0,0 +1,16 @@ +package debug + +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" + +templ Cookies(vm models.DebugCookiesViewModel) { + @layout.WithTemplate() { +

+ Token: { vm.Token } +

+

+ RefreshToken: { vm.RefreshToken } +

+ UserName: { vm.Username } + } +} diff --git a/internal/views/home/about.templ b/internal/views/home/about.templ new file mode 100644 index 0000000..306ee41 --- /dev/null +++ b/internal/views/home/about.templ @@ -0,0 +1,15 @@ +package home + +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" + +templ About() { + @layout.WithTemplate(){ +

About this project

+ +
+ Newsbot started a small project to send out notifications to discord servers. + I wanted to be able to keep the small communitiy aware of new posts about a game we all played. + That feature still exists because I think that keeping a communitiy aware and engaged is important and not everyone follows the same news. +
+ } +} \ No newline at end of file diff --git a/internal/views/home/index.templ b/internal/views/home/index.templ new file mode 100644 index 0000000..b90be24 --- /dev/null +++ b/internal/views/home/index.templ @@ -0,0 +1,32 @@ +package home + +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/bulma" + +templ Index() { + @layout.WithTemplate() { + @bulma.Hero("Welcome to Newsbot!", "Your new home for your news.") + +
+

+ News bot is a self hostable solution to aggregating your news. + You can run `Newsbot` as an API or interact with it with this site. +

+
+ + +
+

Why Newsbot

+ I started to build this tool to help me avoid sitting on the big platform websites. + I wanted a tool that would work for me, not them. + + This tool started as a notification system that would let me redirect RSS posts over to Discord servers. + It still has those roots but now its starting to scale up to a full Aggregation platform. + +
+

+ This project is a passion project of mine as I +

+ + } +} diff --git a/internal/views/layout/error.templ b/internal/views/layout/error.templ new file mode 100644 index 0000000..e1b00d6 --- /dev/null +++ b/internal/views/layout/error.templ @@ -0,0 +1,8 @@ +package layout + +templ Error(err error) { + @WithTemplate() { +

Error

+

{ err.Error() }

+ } +} diff --git a/internal/views/layout/footer.templ b/internal/views/layout/footer.templ new file mode 100644 index 0000000..88627e8 --- /dev/null +++ b/internal/views/layout/footer.templ @@ -0,0 +1,9 @@ +package layout + +templ footer() { + +} diff --git a/internal/views/layout/header.templ b/internal/views/layout/header.templ new file mode 100644 index 0000000..942e76f --- /dev/null +++ b/internal/views/layout/header.templ @@ -0,0 +1,19 @@ +package layout + +templ header() { + + + + + + + + + + +} \ No newline at end of file diff --git a/internal/views/layout/navbar.templ b/internal/views/layout/navbar.templ new file mode 100644 index 0000000..b5162d9 --- /dev/null +++ b/internal/views/layout/navbar.templ @@ -0,0 +1,40 @@ +package layout + +templ navBar() { + +} diff --git a/internal/views/layout/util.go b/internal/views/layout/util.go new file mode 100644 index 0000000..0ab0899 --- /dev/null +++ b/internal/views/layout/util.go @@ -0,0 +1,14 @@ +package layout + +import ( + "context" + + "git.jamestombleson.com/jtom38/newsbot-portal/internal/domain" +) + +func getUsername(ctx context.Context) string { + if theme, ok := ctx.Value(domain.UserNameContext).(string); ok { + return theme + } + return "" +} \ No newline at end of file diff --git a/internal/views/layout/withTemplate.templ b/internal/views/layout/withTemplate.templ new file mode 100644 index 0000000..28da166 --- /dev/null +++ b/internal/views/layout/withTemplate.templ @@ -0,0 +1,18 @@ +package layout + +templ WithTemplate() { + + + + @header() + + + @navBar() +
+
+ { children... } + @footer() +
+ + +} diff --git a/internal/views/sources/listAll.templ b/internal/views/sources/listAll.templ new file mode 100644 index 0000000..f1872a5 --- /dev/null +++ b/internal/views/sources/listAll.templ @@ -0,0 +1,13 @@ +package sources + +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/models" + +templ ListAll(model models.ListAllSourcesViewModel) { + @layout.WithTemplate() { + for _, item := range model.Items { + { item.DisplayName } - { item.Source } +
+ } + } +} diff --git a/internal/views/users/afterLogin.templ b/internal/views/users/afterLogin.templ new file mode 100644 index 0000000..8025e9a --- /dev/null +++ b/internal/views/users/afterLogin.templ @@ -0,0 +1,15 @@ +package users + +// This is returned after the user logs into the application. +// It just returns a partial view because it will overlap with the existing template. +templ AfterLogin(message string, success bool) { + if success { +
+ { message } +
+ } else { +
+ { message } +
+ } +} diff --git a/internal/views/users/afterSignUp.templ b/internal/views/users/afterSignUp.templ new file mode 100644 index 0000000..dfe3fd5 --- /dev/null +++ b/internal/views/users/afterSignUp.templ @@ -0,0 +1,15 @@ +package users + +// This is returned after the user creates an account. +// It just returns a partial view because it will overlap with the existing template. +templ AfterSignUp(message string, success bool) { + if success { +
+ { message } +
+ } else { +
+ { message } +
+ } +} diff --git a/internal/views/users/login.templ b/internal/views/users/login.templ new file mode 100644 index 0000000..678750c --- /dev/null +++ b/internal/views/users/login.templ @@ -0,0 +1,23 @@ +package users + +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" + +templ Login() { + @layout.WithTemplate() { +
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+ } +} diff --git a/internal/views/users/logout.templ b/internal/views/users/logout.templ new file mode 100644 index 0000000..8bfb52b --- /dev/null +++ b/internal/views/users/logout.templ @@ -0,0 +1,10 @@ +package users + +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/bulma" + +templ Logout() { + @layout.WithTemplate(){ + @bulma.Hero("You are out of here!", "Please login again when you are ready.") + } +} \ No newline at end of file diff --git a/internal/views/users/profile.templ b/internal/views/users/profile.templ new file mode 100644 index 0000000..8edd35e --- /dev/null +++ b/internal/views/users/profile.templ @@ -0,0 +1,15 @@ +package users + +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/bulma" + +templ Profile() { + @layout.WithTemplate() { + @bulma.Hero("Profile", "Here you can update your profile 😀") + + +

This will force all active sessions to stop working and require a new login.

+ } +} diff --git a/internal/views/users/signup.templ b/internal/views/users/signup.templ new file mode 100644 index 0000000..03c8a7e --- /dev/null +++ b/internal/views/users/signup.templ @@ -0,0 +1,23 @@ +package users + +import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout" + +templ SignUp() { + @layout.WithTemplate() { +
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+ } +}