Merge pull request 'features/bootstrapping' (#1) from features/bootstrapping into main

Reviewed-on: #1
This commit is contained in:
jtom38 2024-06-02 19:55:24 -07:00
commit 40af1cf5f5
47 changed files with 1352 additions and 0 deletions

6
.gitignore vendored
View File

@ -1,3 +1,5 @@
# ---> Go # ---> Go
# If you prefer the allow list template instead of the deny list, see community template: # 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 # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
@ -21,3 +23,7 @@
# Go workspace file # Go workspace file
go.work go.work
.vscode
*_templ.go
server
.env

10
Justfile Normal file
View File

@ -0,0 +1,10 @@
# Generates templ files
gen:
templ generate
build:
templ generate
go build cmd/server.go
ls -lh server

57
apiclient/articles.go Normal file
View File

@ -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
}

21
apiclient/client.go Normal file
View File

@ -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),
}
}

77
apiclient/sources.go Normal file
View File

@ -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
}

94
apiclient/users.go Normal file
View File

@ -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
}

103
apiclient/util.go Normal file
View File

@ -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
}

23
cmd/server.go Normal file
View File

@ -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")
}

25
go.mod Normal file
View File

@ -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
)

45
go.sum Normal file
View File

@ -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=

47
internal/config/config.go Normal file
View File

@ -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)
}
}

View File

@ -0,0 +1,5 @@
package domain
type contextKey string
var UserNameContext contextKey = "username"

View File

@ -0,0 +1,7 @@
package domain
const (
CookieToken = "newsbot.token"
CookieRefreshToken = "newsbot.refreshToken"
CookieUser = "newsbot.user"
)

View File

@ -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))
}

View File

@ -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))
}

View File

@ -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()
//}

16
internal/handlers/home.go Normal file
View File

@ -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())
}

View File

@ -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,
}))
}

View File

@ -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())
}

92
internal/handlers/util.go Normal file
View File

@ -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)
}

View File

@ -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
}

7
internal/models/debug.go Normal file
View File

@ -0,0 +1,7 @@
package models
type DebugCookiesViewModel struct {
Username string
Token string
RefreshToken string
}

View File

@ -0,0 +1,6 @@
package models
type LayoutIsLoggedInViewModel struct {
IsLoggedIn bool
Username string
}

View File

@ -0,0 +1,9 @@
package models
import "git.jamestombleson.com/jtom38/newsbot-api/domain"
type ListAllSourcesViewModel struct {
IsError bool
Message string
Items []domain.SourceDto
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,4 @@
.pin-bottom {
position: absolute;
bottom: 0;
}

View File

@ -0,0 +1,31 @@
package articles
templ filterBar() {
<!-- Main container -->
<nav class="level">
<!-- Left side -->
<div class="level-left">
<div class="level-item">
<p class="subtitle is-5"><strong>123</strong> posts</p>
</div>
<div class="level-item">
<div class="field has-addons">
<p class="control">
<input class="input" type="text" placeholder="Find a post"/>
</p>
<p class="control">
<button class="button">Search</button>
</p>
</div>
</div>
</div>
<!-- Right side -->
<div class="level-right">
<p class="level-item"><strong>All</strong></p>
<p class="level-item"><a>Published</a></p>
<p class="level-item"><a>Drafts</a></p>
<p class="level-item"><a>Deleted</a></p>
<p class="level-item"><a class="button is-success">New</a></p>
</div>
</nav>
}

View File

@ -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 )
}
}
}

View File

@ -0,0 +1,22 @@
package bulma
templ ArticleCardWithThumbnail(title, thumbnailUrl, url, datePosted, sourceName string) {
<div class="card">
<div class="card-image">
<figure class="is-4by3">
<img src={ thumbnailUrl }/>
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-content">
<a href={ templ.SafeURL(url) }>{ title }</a>
</div>
</div>
<div class="content">
{ datePosted }<br/>
{ sourceName }
</div>
</div>
</div>
}

View File

@ -0,0 +1,10 @@
package bulma
templ Hero(title, subtitle string) {
<section class="hero">
<div class="hero-body">
<p class="title">{ title }</p>
<p class="subtitle">{ subtitle }</p>
</div>
</section>
}

View File

@ -0,0 +1,11 @@
package bulma
templ Section(title, subtitle string) {
<section class="section">
<h1 class="title">Section</h1>
<h2 class="subtitle">
A simple container to divide your page into <strong>sections</strong>, like
the one you're currently reading.
</h2>
</section>
}

View File

@ -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() {
<p>
Token: { vm.Token }
</p>
<p>
RefreshToken: { vm.RefreshToken }
</p>
UserName: { vm.Username }
}
}

View File

@ -0,0 +1,15 @@
package home
import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout"
templ About() {
@layout.WithTemplate(){
<h1 class="title"> About this project</h1>
<section class="section">
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.
</section>
}
}

View File

@ -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.")
<section class="section">
<p>
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.
</p>
</section>
<div class="block">
<h2 class="title">Why Newsbot</h2>
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.
</div>
<p>
This project is a passion project of mine as I
</p>
}
}

View File

@ -0,0 +1,8 @@
package layout
templ Error(err error) {
@WithTemplate() {
<h1 class="title">Error</h1>
<h2 class="subtitle">{ err.Error() } </h2>
}
}

View File

@ -0,0 +1,9 @@
package layout
templ footer() {
<!--
<div class="has-text-centered pin-botton">
<strong>Bulma</strong> by <a href="https://jgthms.com">Jeremy Thomas</a>.
</div>
-->
}

View File

@ -0,0 +1,19 @@
package layout
templ header() {
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.0/css/bulma.min.css"/>
<link rel="stylesheet" href="/css/main.css"/>
<script src="https://unpkg.com/htmx.org@1.9.11" integrity="sha384-0gxUXCCR8yv9FM2b+U3FDbsKthCI66oH5IA9fHppQq9DDMHuMauqq1ZHBpJxQ0J0" crossorigin="anonymous"></script>
<meta charset="utf-8"/>
<meta property="og:title" content=""/>
<meta property="og:url" content=""/>
<meta property="og:image" content=""/>
<meta property="og:description" content=""/>
<meta property="og:type" content=""/>
<style type="text/css">
.pin-bottom {
position: absolute;
bottom: 0;
}
</style>
}

View File

@ -0,0 +1,40 @@
package layout
templ navBar() {
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">Newsbot</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/articles">Articles</a>
<a class="navbar-item" href="/sources">Sources</a>
<a class="navbar-item">{ getUsername(ctx) }</a>
</div>
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
if getUsername(ctx) == "" {
<a class="button is-primary" href="/users/signup"><strong>Sign up</strong></a>
<a class="button is-light" href="/users/login">Log in</a>
} else {
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">{ getUsername(ctx) }</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="/users/profile">Profile</a>
<a class="navbar-item" href="/users/logout">Logout</a>
</div>
</div>
}
</div>
</div>
</div>
</div>
</nav>
}

View File

@ -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 ""
}

View File

@ -0,0 +1,18 @@
package layout
templ WithTemplate() {
<!DOCTYPE html>
<html lang="en">
<head>
@header()
</head>
<body>
@navBar()
<br/>
<div class="container is-widescreen">
{ children... }
@footer()
</div>
</body>
</html>
}

View File

@ -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 {
<a href={ templ.SafeURL(item.Url) } target="_blank" rel="noopener noreferrer">{ item.DisplayName } - { item.Source } </a>
<br/>
}
}
}

View File

@ -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 {
<div class="notification is-success">
{ message }
</div>
} else {
<div class="notification is-error">
{ message }
</div>
}
}

View File

@ -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 {
<div class="notification is-success">
{ message }
</div>
} else {
<div class="notification is-error">
{ message }
</div>
}
}

View File

@ -0,0 +1,23 @@
package users
import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout"
templ Login() {
@layout.WithTemplate() {
<form hx-post="/users/login">
<div class="field">
<label class="label">Username</label>
<div class="control">
<input class="input" type="text" name="username" id="username"/>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" type="password" name="password" id="exampleInputPassword1"/>
</div>
</div>
<button type="submit" class="button is-primary">Submit</button>
</form>
}
}

View File

@ -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.")
}
}

View File

@ -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 😀")
<button type="button" class="button">
<a href="/users/forcelogout">Logout Everywhere</a>
Logout Everywhere</button>
<p class="subtitle">This will force all active sessions to stop working and require a new login.</p>
}
}

View File

@ -0,0 +1,23 @@
package users
import "git.jamestombleson.com/jtom38/newsbot-portal/internal/views/layout"
templ SignUp() {
@layout.WithTemplate() {
<form hx-post="/users/signup">
<div class="field">
<label class="label">Username</label>
<div class="control">
<input class="input" type="text" name="username" id="username"/>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" type="password" name="password" id="exampleInputPassword1"/>
</div>
</div>
<button type="submit" class="button is-primary">Submit</button>
</form>
}
}