Merge pull request 'features/bootstrapping' (#1) from features/bootstrapping into main
Reviewed-on: #1
This commit is contained in:
commit
40af1cf5f5
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
10
Justfile
Normal 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
57
apiclient/articles.go
Normal 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
21
apiclient/client.go
Normal 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
77
apiclient/sources.go
Normal 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
94
apiclient/users.go
Normal 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
103
apiclient/util.go
Normal 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
23
cmd/server.go
Normal 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
25
go.mod
Normal 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
45
go.sum
Normal 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
47
internal/config/config.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
5
internal/domain/context.go
Normal file
5
internal/domain/context.go
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
var UserNameContext contextKey = "username"
|
7
internal/domain/cookie.go
Normal file
7
internal/domain/cookie.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
const (
|
||||||
|
CookieToken = "newsbot.token"
|
||||||
|
CookieRefreshToken = "newsbot.refreshToken"
|
||||||
|
CookieUser = "newsbot.user"
|
||||||
|
)
|
46
internal/handlers/articles.go
Normal file
46
internal/handlers/articles.go
Normal 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))
|
||||||
|
}
|
35
internal/handlers/debug.go
Normal file
35
internal/handlers/debug.go
Normal 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))
|
||||||
|
}
|
113
internal/handlers/handler.go
Normal file
113
internal/handlers/handler.go
Normal 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
16
internal/handlers/home.go
Normal 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())
|
||||||
|
}
|
34
internal/handlers/sources.go
Normal file
34
internal/handlers/sources.go
Normal 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,
|
||||||
|
}))
|
||||||
|
}
|
76
internal/handlers/users.go
Normal file
76
internal/handlers/users.go
Normal 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
92
internal/handlers/util.go
Normal 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)
|
||||||
|
}
|
12
internal/models/articles.go
Normal file
12
internal/models/articles.go
Normal 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
7
internal/models/debug.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type DebugCookiesViewModel struct {
|
||||||
|
Username string
|
||||||
|
Token string
|
||||||
|
RefreshToken string
|
||||||
|
}
|
6
internal/models/layout.go
Normal file
6
internal/models/layout.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type LayoutIsLoggedInViewModel struct {
|
||||||
|
IsLoggedIn bool
|
||||||
|
Username string
|
||||||
|
}
|
9
internal/models/sources.go
Normal file
9
internal/models/sources.go
Normal 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
|
||||||
|
}
|
6
internal/static/Newsbot.svg
Normal file
6
internal/static/Newsbot.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.5 KiB |
4
internal/static/css/main.css
Normal file
4
internal/static/css/main.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.pin-bottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
31
internal/views/articles/filter.templ
Normal file
31
internal/views/articles/filter.templ
Normal 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>
|
||||||
|
}
|
17
internal/views/articles/list.templ
Normal file
17
internal/views/articles/list.templ
Normal 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 )
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
internal/views/bulma/card.templ
Normal file
22
internal/views/bulma/card.templ
Normal 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>
|
||||||
|
}
|
10
internal/views/bulma/hero.templ
Normal file
10
internal/views/bulma/hero.templ
Normal 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>
|
||||||
|
}
|
11
internal/views/bulma/section.templ
Normal file
11
internal/views/bulma/section.templ
Normal 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>
|
||||||
|
}
|
16
internal/views/debug/cookies.templ
Normal file
16
internal/views/debug/cookies.templ
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
15
internal/views/home/about.templ
Normal file
15
internal/views/home/about.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
32
internal/views/home/index.templ
Normal file
32
internal/views/home/index.templ
Normal 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>
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
8
internal/views/layout/error.templ
Normal file
8
internal/views/layout/error.templ
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
templ Error(err error) {
|
||||||
|
@WithTemplate() {
|
||||||
|
<h1 class="title">Error</h1>
|
||||||
|
<h2 class="subtitle">{ err.Error() } </h2>
|
||||||
|
}
|
||||||
|
}
|
9
internal/views/layout/footer.templ
Normal file
9
internal/views/layout/footer.templ
Normal 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>
|
||||||
|
-->
|
||||||
|
}
|
19
internal/views/layout/header.templ
Normal file
19
internal/views/layout/header.templ
Normal 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>
|
||||||
|
}
|
40
internal/views/layout/navbar.templ
Normal file
40
internal/views/layout/navbar.templ
Normal 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>
|
||||||
|
}
|
14
internal/views/layout/util.go
Normal file
14
internal/views/layout/util.go
Normal 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 ""
|
||||||
|
}
|
18
internal/views/layout/withTemplate.templ
Normal file
18
internal/views/layout/withTemplate.templ
Normal 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>
|
||||||
|
}
|
13
internal/views/sources/listAll.templ
Normal file
13
internal/views/sources/listAll.templ
Normal 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/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
internal/views/users/afterLogin.templ
Normal file
15
internal/views/users/afterLogin.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
15
internal/views/users/afterSignUp.templ
Normal file
15
internal/views/users/afterSignUp.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
23
internal/views/users/login.templ
Normal file
23
internal/views/users/login.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
10
internal/views/users/logout.templ
Normal file
10
internal/views/users/logout.templ
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
15
internal/views/users/profile.templ
Normal file
15
internal/views/users/profile.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
23
internal/views/users/signup.templ
Normal file
23
internal/views/users/signup.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user