Merge pull request #41 from creekorful/develop

Release 0.6.0
pull/134/head
Aloïs Micard 4 years ago committed by GitHub
commit 5db2a4f52d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,8 +5,6 @@ on:
branches:
- master
pull_request:
branches:
- master
env:
GO111MODULE: on
@ -16,20 +14,19 @@ jobs:
strategy:
matrix:
os: [ ubuntu-latest ]
go: [ 1.14 ]
go: [ 1.15 ]
name: ${{ matrix.os }} @ Go ${{ matrix.go }}
runs-on: ${{ matrix.os }}
steps:
- name: Set up Go ${{ matrix.go }}
- name: Install Go ${{ matrix.go }}
uses: actions/setup-go@v1
with:
go-version: ${{ matrix.go }}
- name: Set GOPATH and PATH
- name: Set environment
run: |
echo "::set-env name=GOPATH::$(dirname $GITHUB_WORKSPACE)"
echo "::add-path::$(dirname $GITHUB_WORKSPACE)/bin"
shell: bash
echo "GOPATH=$(dirname $GITHUB_WORKSPACE)" >> $GITHUB_ENV
echo "$(dirname $GITHUB_WORKSPACE)/bin" >> $GITHUB_PATH
- name: Checkout Code
uses: actions/checkout@v2

@ -25,10 +25,12 @@ const (
// ResourceDto represent a resource as given by the API
type ResourceDto struct {
URL string `json:"url"`
Body string `json:"body"`
Title string `json:"title"`
Time time.Time `json:"time"`
URL string `json:"url"`
Body string `json:"body"`
Time time.Time `json:"time"`
Title string `json:"title"`
Meta map[string]string `json:"meta"`
Description string `json:"description"`
}
// CredentialsDto represent the credential when logging in the API
@ -43,13 +45,11 @@ type Client interface {
paginationPage, paginationSize int) ([]ResourceDto, int64, error)
AddResource(res ResourceDto) (ResourceDto, error)
ScheduleURL(url string) error
Authenticate(credentials CredentialsDto) (string, error)
}
type client struct {
httpClient *resty.Client
baseURL string
token string
}
func (c *client) SearchResources(url, keyword string,
@ -57,7 +57,6 @@ func (c *client) SearchResources(url, keyword string,
targetEndpoint := fmt.Sprintf("%s/v1/resources?", c.baseURL)
req := c.httpClient.R()
req.SetAuthToken(c.token)
if url != "" {
b64URL := base64.URLEncoding.EncodeToString([]byte(url))
@ -103,7 +102,6 @@ func (c *client) AddResource(res ResourceDto) (ResourceDto, error) {
targetEndpoint := fmt.Sprintf("%s/v1/resources", c.baseURL)
req := c.httpClient.R()
req.SetAuthToken(c.token)
req.SetBody(res)
var resourceDto ResourceDto
@ -117,7 +115,6 @@ func (c *client) ScheduleURL(url string) error {
targetEndpoint := fmt.Sprintf("%s/v1/urls", c.baseURL)
req := c.httpClient.R()
req.SetAuthToken(c.token)
req.SetHeader("Content-Type", "application/json")
req.SetBody(fmt.Sprintf("\"%s\"", url))
@ -125,23 +122,11 @@ func (c *client) ScheduleURL(url string) error {
return err
}
func (c *client) Authenticate(credentials CredentialsDto) (string, error) {
targetEndpoint := fmt.Sprintf("%s/v1/sessions", c.baseURL)
req := c.httpClient.R()
req.SetBody(credentials)
var token string
req.SetResult(&token)
_, err := req.Post(targetEndpoint)
return token, err
}
// NewAuthenticatedClient create a new Client & authenticate it against the API
func NewAuthenticatedClient(baseURL string, credentials CredentialsDto) (Client, error) {
// NewClient create a new API client using given details
func NewClient(baseURL, token string) Client {
httpClient := resty.New()
httpClient.SetAuthScheme("Bearer")
httpClient.SetAuthToken(token)
httpClient.OnAfterResponse(func(c *resty.Client, r *resty.Response) error {
if r.StatusCode() > 302 {
return fmt.Errorf("error when making HTTP request: %s", r.Status())
@ -154,11 +139,5 @@ func NewAuthenticatedClient(baseURL string, credentials CredentialsDto) (Client,
baseURL: baseURL,
}
token, err := client.Authenticate(credentials)
if err != nil {
return nil, err
}
client.token = token
return client, nil
return client
}

@ -2,54 +2,66 @@ version: '3'
services:
nats:
image: nats:2.1.6-alpine3.11
logging:
driver: none
image: nats:2.1.9-alpine3.12
torproxy:
image: dperson/torproxy:latest
logging:
driver: none
elasticsearch:
image: elasticsearch:7.5.1
logging:
driver: none
image: elasticsearch:7.10.1
environment:
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms2g -Xmx2g
volumes:
- esdata:/usr/share/elasticsearch/data
kibana:
image: kibana:7.5.1
logging:
driver: none
image: kibana:7.10.1
depends_on:
- elasticsearch
ports:
- 15004:5601
crawler:
image: creekorful/tdsh-crawler:latest
command: --log-level debug --nats-uri nats --tor-uri torproxy:9050
command: >
--log-level debug
--nats-uri nats
--tor-uri torproxy:9050
restart: always
depends_on:
- nats
- torproxy
scheduler:
image: creekorful/tdsh-scheduler:latest
command: --log-level debug --nats-uri nats --api-uri http://api:8080 --api-login scheduler:ZjDXeaLGj4EEUGu6
command: >
--log-level debug
--nats-uri nats
--api-uri http://api:8080
--api-token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InNjaGVkdWxlciIsInJpZ2h0cyI6eyJHRVQiOlsiL3YxL3Jlc291cmNlcyJdfX0.dBR6KLQp2h2srY-By3zikEznhQplLCtDrvOkcXP6USY
--forbidden-extensions png
--forbidden-extensions gif
--forbidden-extensions jpg
--forbidden-extensions jpeg
--forbidden-extensions bmp
restart: always
depends_on:
- nats
- api
extractor:
image: creekorful/tdsh-extractor:latest
command: --log-level debug --nats-uri nats --api-uri http://api:8080 --api-login extractor:hWx2KsrhWVQb5vxg
command: >
--log-level debug
--nats-uri nats
--api-uri http://api:8080
--api-token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImV4dHJhY3RvciIsInJpZ2h0cyI6eyJQT1NUIjpbIi92MS9yZXNvdXJjZXMiXX19.mytGd_9zyK8y_T3fsWAmH8FnaBNr6qWefwCPDOx4in0
restart: always
depends_on:
- nats
- api
api:
image: creekorful/tdsh-api:latest
command: --log-level debug --nats-uri nats --elasticsearch-uri http://elasticsearch:9200 --signing-key K==M5RsU_DQa4_XSbkX?L27s^xWmde25 --users extractor:hWx2KsrhWVQb5vxg --users scheduler:ZjDXeaLGj4EEUGu6 --users demo:demo
command: >
--log-level debug
--nats-uri nats
--elasticsearch-uri http://elasticsearch:9200
--signing-key K==M5RsU_DQa4_XSbkX?L27s^xWmde25
restart: always
depends_on:
- elasticsearch

@ -3,6 +3,7 @@ module github.com/creekorful/trandoshan
go 1.14
require (
github.com/PuerkitoBio/goquery v1.6.0
github.com/PuerkitoBio/purell v1.1.1
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible

@ -1,9 +1,13 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/PuerkitoBio/goquery v1.6.0 h1:j7taAbelrdcsOlGeMenZxc2AWXD5fieT1/znArdnx94=
github.com/PuerkitoBio/goquery v1.6.0/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/aws/aws-sdk-go v1.34.13/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
@ -120,6 +124,7 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=

@ -1,10 +1,10 @@
package api
import (
"github.com/creekorful/trandoshan/internal/api/auth"
"github.com/creekorful/trandoshan/internal/logging"
"github.com/creekorful/trandoshan/internal/util"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
)
@ -47,30 +47,34 @@ func execute(c *cli.Context) error {
logging.ConfigureLogger(c)
e := echo.New()
e.HTTPErrorHandler = func(err error, c echo.Context) {
log.Err(err).Msg("error while processing API call")
e.DefaultHTTPErrorHandler(err, c)
}
e.HideBanner = true
log.Info().Str("ver", c.App.Version).Msg("Starting tdsh-api")
log.Debug().Str("uri", c.String("elasticsearch-uri")).Msg("Using Elasticsearch server")
log.Debug().Str("uri", c.String("nats-uri")).Msg("Using NATS server")
log.Info().Str("ver", c.App.Version).
Str("elasticsearch-uri", c.String("elasticsearch-uri")).
Str("nats-uri", c.String("nats-uri")).
Msg("Starting tdsh-api")
signingKey := []byte(c.String("signing-key"))
// Create the service
svc, err := newService(c, signingKey)
svc, err := newService(c)
if err != nil {
log.Err(err).Msg("Unable to start API")
return err
}
// Setup middlewares
jwtMiddleware := middleware.JWT(signingKey)
authMiddleware := auth.NewMiddleware(signingKey)
e.Use(authMiddleware.Middleware())
// Add endpoints
e.GET("/v1/resources", searchResourcesEndpoint(svc), jwtMiddleware)
e.POST("/v1/resources", addResourceEndpoint(svc), jwtMiddleware)
e.POST("/v1/urls", scheduleURLEndpoint(svc), jwtMiddleware)
e.POST("/v1/sessions", authenticateEndpoint(svc))
e.GET("/v1/resources", searchResourcesEndpoint(svc))
e.POST("/v1/resources", addResourceEndpoint(svc))
e.POST("/v1/urls", scheduleURLEndpoint(svc))
log.Info().Msg("Successfully initialized tdsh-api. Waiting for requests")

@ -0,0 +1,112 @@
package auth
import (
"fmt"
"github.com/dgrijalva/jwt-go"
"github.com/labstack/echo/v4"
"net/http"
"strings"
)
// ErrInvalidOrMissingAuth is returned if the authorization header is absent or invalid
var ErrInvalidOrMissingAuth = &echo.HTTPError{
Code: http.StatusUnauthorized,
Message: "Invalid or missing `Authorization` header",
}
// ErrAccessUnauthorized is returned if the token doesn't grant access to the current resource
var ErrAccessUnauthorized = &echo.HTTPError{
Code: http.StatusUnauthorized,
Message: "Access to the resource is not authorized",
}
// Token is the authentication token used by processes when dialing with the API
type Token struct {
// Username used for logging purposes
Username string `json:"username"`
// Rights that the token provides
// Format is: METHOD - list of paths
Rights map[string][]string `json:"rights"`
}
// Middleware is the authentication middleware
type Middleware struct {
signingKey []byte
}
// NewMiddleware create a new Middleware instance with given secret token signing key
func NewMiddleware(signingKey []byte) *Middleware {
return &Middleware{signingKey: signingKey}
}
// Middleware return an echo compatible middleware func to use
func (m *Middleware) Middleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Extract authorization header
tokenStr := c.Request().Header.Get(echo.HeaderAuthorization)
if tokenStr == "" {
return ErrInvalidOrMissingAuth
}
tokenStr = strings.TrimPrefix(tokenStr, "Bearer ")
// Decode the JWT token
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
// Validate expected alg
if v, ok := t.Method.(*jwt.SigningMethodHMAC); !ok || v.Name != "HS256" {
return nil, fmt.Errorf("unexpected signing method: %s", t.Header["alg"])
}
// Return signing secret
return m.signingKey, nil
})
if err != nil {
return ErrInvalidOrMissingAuth
}
// From here we have a valid JWT token, extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return fmt.Errorf("error while parsing token claims")
}
rights := map[string][]string{}
for method, paths := range claims["rights"].(map[string]interface{}) {
for _, path := range paths.([]interface{}) {
rights[method] = append(rights[method], path.(string))
}
}
t := Token{
Username: claims["username"].(string),
Rights: rights,
}
// Validate rights
paths, contains := t.Rights[c.Request().Method]
if !contains {
return ErrAccessUnauthorized
}
authorized := false
for _, path := range paths {
if path == c.Request().URL.Path {
authorized = true
break
}
}
if !authorized {
return ErrAccessUnauthorized
}
// Set user context
c.Set("username", t.Username)
// Everything's fine, call next handler ;D
return next(c)
}
}
}

@ -0,0 +1,88 @@
package auth
import (
"fmt"
"github.com/labstack/echo/v4"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
)
func TestMiddleware_NoTokenShouldReturnUnauthorized(t *testing.T) {
e := echo.New()
m := (&Middleware{signingKey: []byte("test")}).Middleware()(okHandler())
// no token shouldn't be able to access
req := httptest.NewRequest(http.MethodGet, "/users", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := m(c); err != ErrInvalidOrMissingAuth {
t.Errorf("ErrInvalidOrMissingAuth was expected")
}
}
func TestMiddleware_InvalidTokenShouldReturnUnauthorized(t *testing.T) {
e := echo.New()
m := (&Middleware{signingKey: []byte("test")}).Middleware()
req := httptest.NewRequest(http.MethodGet, "/users", nil)
req.Header.Add(echo.HeaderAuthorization, "zarBR")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := m(okHandler())(c); err != ErrInvalidOrMissingAuth {
t.Errorf("ErrInvalidOrMissingAuth was expected")
}
}
func TestMiddleware_BadRightsShouldReturnUnauthorized(t *testing.T) {
e := echo.New()
m := (&Middleware{signingKey: []byte("test")}).Middleware()
req := httptest.NewRequest(http.MethodPost, "/users", nil)
req.Header.Add(echo.HeaderAuthorization, "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkpvaG4gRG9lIiwicmlnaHRzIjp7IkdFVCI6WyIvdXNlcnMiXSwiUE9TVCI6WyIvc2VhcmNoIl19fQ.fRx0Q66ZgnY_rKCf-9Vaz6gzGKH_tKSgkVHhoQMtKfM")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := m(okHandler())(c); err != ErrAccessUnauthorized {
t.Errorf("ErrAccessUnauthorized was expected")
}
}
func TestMiddleware(t *testing.T) {
e := echo.New()
m := (&Middleware{signingKey: []byte("test")}).Middleware()
req := httptest.NewRequest(http.MethodGet, "/users?id=10", nil)
req.Header.Add(echo.HeaderAuthorization, "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkpvaG4gRG9lIiwicmlnaHRzIjp7IkdFVCI6WyIvdXNlcnMiXSwiUE9TVCI6WyIvc2VhcmNoIl19fQ.fRx0Q66ZgnY_rKCf-9Vaz6gzGKH_tKSgkVHhoQMtKfM")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
_ = m(okHandler())(c)
if rec.Code != http.StatusOK {
t.Fail()
}
b, err := ioutil.ReadAll(rec.Body)
if err != nil {
t.Fail()
}
if string(b) != "Hello, John Doe" {
t.Fail()
}
if token := c.Get("username").(string); token != "John Doe" {
t.Fail()
}
}
func okHandler() echo.HandlerFunc {
return func(c echo.Context) error {
if username := c.Get("username").(string); username != "" {
return c.String(http.StatusOK, fmt.Sprintf("Hello, %s", username))
}
return c.NoContent(http.StatusNoContent)
}
}

@ -14,10 +14,12 @@ var resourcesIndex = "resources"
// ResourceIdx represent a resource as stored in elasticsearch
type ResourceIdx struct {
URL string `json:"url"`
Body string `json:"body"`
Title string `json:"title"`
Time time.Time `json:"time"`
URL string `json:"url"`
Body string `json:"body"`
Time time.Time `json:"time"`
Title string `json:"title"`
Meta map[string]string `json:"meta"`
Description string `json:"description"`
}
// ResSearchParams is the search params used
@ -29,6 +31,7 @@ type ResSearchParams struct {
WithBody bool
PageSize int
PageNumber int
// TODO allow searching by meta
}
// Database is the interface used to abstract communication
@ -170,8 +173,6 @@ func setupElasticSearch(ctx context.Context, es *elastic.Client) error {
if _, err := es.CreateIndex(resourcesIndex).Do(ctx); err != nil {
return err
}
} else {
log.Debug().Msg("index exist")
}
return nil

@ -56,23 +56,6 @@ func scheduleURLEndpoint(s service) echo.HandlerFunc {
}
}
func authenticateEndpoint(s service) echo.HandlerFunc {
return func(c echo.Context) error {
// Validate provided credentials
var credentials api.CredentialsDto
if err := c.Bind(&credentials); err != nil {
return err
}
token, err := s.authenticate(credentials)
if err != nil {
return err
}
return c.JSON(http.StatusOK, token)
}
}
func readPagination(c echo.Context) (int, int) {
paginationPage, err := strconv.Atoi(c.QueryParam(api.PaginationPageQueryParam))
if err != nil {

@ -4,47 +4,23 @@ import (
"github.com/creekorful/trandoshan/api"
"github.com/creekorful/trandoshan/internal/api/database"
"github.com/creekorful/trandoshan/internal/messaging"
"github.com/dgrijalva/jwt-go"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
"golang.org/x/crypto/bcrypt"
"net/http"
"strings"
)
type service interface {
searchResources(params *database.ResSearchParams) ([]api.ResourceDto, int64, error)
addResource(res api.ResourceDto) (api.ResourceDto, error)
scheduleURL(url string) error
authenticate(credentials api.CredentialsDto) (string, error)
close()
}
type svc struct {
users map[string][]byte
signingKey []byte
db database.Database
pub messaging.Publisher
db database.Database
pub messaging.Publisher
}
func newService(c *cli.Context, signingKey []byte) (service, error) {
users := map[string][]byte{}
for _, userEntry := range c.StringSlice("users") {
parts := strings.Split(userEntry, ":")
user := parts[0]
pass := parts[1]
passBytes, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil {
log.Err(err).Msg("Unable to generate user password")
return nil, err
}
log.Debug().Str("username", user).Msg("Register new user")
users[user] = passBytes
}
func newService(c *cli.Context) (service, error) {
// Connect to the NATS server
pub, err := messaging.NewPublisher(c.String("nats-uri"))
if err != nil {
@ -60,10 +36,8 @@ func newService(c *cli.Context, signingKey []byte) (service, error) {
}
return &svc{
db: db,
users: users,
signingKey: signingKey,
pub: pub,
db: db,
pub: pub,
}, nil
}
@ -98,10 +72,12 @@ func (s *svc) addResource(res api.ResourceDto) (api.ResourceDto, error) {
// Create Elasticsearch document
doc := database.ResourceIdx{
URL: res.URL,
Body: res.Body,
Title: res.Title,
Time: res.Time,
URL: res.URL,
Body: res.Body,
Time: res.Time,
Title: res.Title,
Meta: res.Meta,
Description: res.Description,
}
if err := s.db.AddResource(doc); err != nil {
@ -124,37 +100,6 @@ func (s *svc) scheduleURL(url string) error {
return nil
}
func (s *svc) authenticate(credentials api.CredentialsDto) (string, error) {
if credentials.Username == "" || credentials.Password == "" {
log.Warn().Msg("Invalid credentials supplied")
return "", echo.NewHTTPError(http.StatusUnprocessableEntity)
}
// Try to find the user
pass, exists := s.users[credentials.Username]
if !exists {
log.Warn().Str("username", credentials.Username).Msg("No user found")
return "", echo.NewHTTPError(http.StatusUnprocessableEntity)
}
// Validate provided password
if err := bcrypt.CompareHashAndPassword(pass, []byte(credentials.Password)); err != nil {
log.Warn().Str("username", credentials.Username).Msg("Invalid password")
return "", echo.NewHTTPError(http.StatusUnauthorized)
}
log.Debug().Str("username", credentials.Username).Msg("Successfully logged-in")
// Build JWT token
claims := jwt.MapClaims{
"username": credentials.Username,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign JWT token
return token.SignedString(s.signingKey)
}
func (s *svc) close() {
s.pub.Close()
}

@ -6,7 +6,6 @@ import (
"github.com/creekorful/trandoshan/internal/api/database_mock"
"github.com/creekorful/trandoshan/internal/messaging"
"github.com/creekorful/trandoshan/internal/messaging_mock"
"github.com/dgrijalva/jwt-go"
"github.com/golang/mock/gomock"
"testing"
"time"
@ -57,19 +56,23 @@ func TestAddResource(t *testing.T) {
dbMock := database_mock.NewMockDatabase(mockCtrl)
dbMock.EXPECT().AddResource(database.ResourceIdx{
URL: "https://example.onion",
Body: "TheBody",
Title: "Example",
Time: time.Time{},
URL: "https://example.onion",
Body: "TheBody",
Title: "Example",
Time: time.Time{},
Meta: map[string]string{"content": "content-meta"},
Description: "the description",
})
s := svc{db: dbMock}
res, err := s.addResource(api.ResourceDto{
URL: "https://example.onion",
Body: "TheBody",
Title: "Example",
Time: time.Time{},
URL: "https://example.onion",
Body: "TheBody",
Title: "Example",
Time: time.Time{},
Meta: map[string]string{"content": "content-meta"},
Description: "the description",
})
if err != nil {
t.FailNow()
@ -87,6 +90,12 @@ func TestAddResource(t *testing.T) {
if !res.Time.IsZero() {
t.FailNow()
}
if res.Meta["content"] != "content-meta" {
t.FailNow()
}
if res.Description != "the description" {
t.FailNow()
}
}
func TestScheduleURL(t *testing.T) {
@ -103,52 +112,3 @@ func TestScheduleURL(t *testing.T) {
t.FailNow()
}
}
func TestAuthenticateInvalidCredentials(t *testing.T) {
s := svc{}
if _, err := s.authenticate(api.CredentialsDto{}); err == nil {
t.FailNow()
}
}
func TestAuthenticateWrongCredentials(t *testing.T) {
s := svc{users: map[string][]byte{"creekorful": []byte("")}}
if _, err := s.authenticate(api.CredentialsDto{Username: "johndoe", Password: "test"}); err == nil {
t.FailNow()
}
if _, err := s.authenticate(api.CredentialsDto{Username: "creekorful", Password: "tes"}); err == nil {
t.FailNow()
}
}
func TestAuthenticate(t *testing.T) {
s := svc{
users: map[string][]byte{
"creekorful": []byte("$2a$10$aLX2t8JsTOoy9iRLBNm.RuPMmcA8MCXijuzhLvUwUbSlh.C/D2eLm")},
signingKey: []byte("secret"),
}
tokenStr, err := s.authenticate(api.CredentialsDto{Username: "creekorful", Password: "test"})
if err != nil {
t.FailNow()
}
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
return []byte("secret"), nil
})
if err != nil {
t.Error(err)
t.FailNow()
}
if token.Header["alg"] != jwt.SigningMethodHS256.Alg() {
t.Errorf("Invalid alg: %s", token.Header["alg"])
}
if claims["username"] != "creekorful" {
t.Errorf("Invalid username: %s", claims["username"])
}
}

@ -49,11 +49,12 @@ func GetApp() *cli.App {
func execute(ctx *cli.Context) error {
logging.ConfigureLogger(ctx)
log.Info().Str("ver", ctx.App.Version).Msg("Starting tdsh-crawler")
log.Debug().Str("uri", ctx.String("nats-uri")).Msg("Using NATS server")
log.Debug().Str("uri", ctx.String("tor-uri")).Msg("Using TOR proxy")
log.Debug().Strs("content-types", ctx.StringSlice("allowed-ct")).Msg("Allowed content types")
log.Info().
Str("ver", ctx.App.Version).
Str("nats-uri", ctx.String("nats-uri")).
Str("tor-uri", ctx.String("tor-uri")).
Strs("allowed-content-types", ctx.StringSlice("allowed-ct")).
Msg("Starting tdsh-crawler")
// Create the HTTP client
httpClient := &fasthttp.Client{
@ -75,8 +76,8 @@ func execute(ctx *cli.Context) error {
log.Info().Msg("Successfully initialized tdsh-crawler. Waiting for URLs")
if err := sub.QueueSubscribe(messaging.URLTodoSubject, "crawlers",
handleMessage(httpClient, ctx.StringSlice("allowed-ct"))); err != nil {
handler := handleMessage(httpClient, ctx.StringSlice("allowed-ct"))
if err := sub.QueueSubscribe(messaging.URLTodoSubject, "crawlers", handler); err != nil {
return err
}
@ -92,8 +93,7 @@ func handleMessage(httpClient *fasthttp.Client, allowedContentTypes []string) me
body, err := crawURL(httpClient, urlMsg.URL, allowedContentTypes)
if err != nil {
log.Err(err).Str("url", urlMsg.URL).Msg("Error while crawling url")
return err
return fmt.Errorf("error while crawling URL: %s", err)
}
// Publish resource body
@ -102,7 +102,7 @@ func handleMessage(httpClient *fasthttp.Client, allowedContentTypes []string) me
Body: body,
}
if err := sub.PublishMsg(&res); err != nil {
log.Err(err).Msg("Error while publishing resource body")
return fmt.Errorf("error while publishing resource: %s", err)
}
return nil

@ -2,6 +2,7 @@ package extractor
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/PuerkitoBio/purell"
"github.com/creekorful/trandoshan/api"
"github.com/creekorful/trandoshan/internal/logging"
@ -30,7 +31,7 @@ func GetApp() *cli.App {
logging.GetLogFlag(),
util.GetNATSURIFlag(),
util.GetAPIURIFlag(),
util.GetAPILoginFlag(),
util.GetAPITokenFlag(),
},
Action: execute,
}
@ -39,15 +40,13 @@ func GetApp() *cli.App {
func execute(ctx *cli.Context) error {
logging.ConfigureLogger(ctx)
log.Info().Str("ver", ctx.App.Version).Msg("Starting tdsh-extractor")
log.Info().
Str("ver", ctx.App.Version).
Str("nats-uri", ctx.String("nats-uri")).
Str("api-uri", ctx.String("api-uri")).
Msg("Starting tdsh-extractor")
log.Debug().Str("uri", ctx.String("nats-uri")).Msg("Using NATS server")
log.Debug().Str("uri", ctx.String("api-uri")).Msg("Using API server")
apiClient, err := util.GetAPIAuthenticatedClient(ctx)
if err != nil {
return err
}
apiClient := util.GetAPIClient(ctx)
// Create the NATS subscriber
sub, err := messaging.NewSubscriber(ctx.String("nats-uri"))
@ -58,8 +57,8 @@ func execute(ctx *cli.Context) error {
log.Info().Msg("Successfully initialized tdsh-extractor. Waiting for resources")
if err := sub.QueueSubscribe(messaging.NewResourceSubject, "extractors",
handleMessage(apiClient)); err != nil {
handler := handleMessage(apiClient)
if err := sub.QueueSubscribe(messaging.NewResourceSubject, "extractors", handler); err != nil {
return err
}
@ -70,7 +69,6 @@ func handleMessage(apiClient api.Client) messaging.MsgHandler {
return func(sub messaging.Subscriber, msg *nats.Msg) error {
var resMsg messaging.NewResourceMsg
if err := sub.ReadMsg(msg, &resMsg); err != nil {
log.Err(err).Msg("Error while reading message")
return err
}
@ -79,15 +77,13 @@ func handleMessage(apiClient api.Client) messaging.MsgHandler {
// Extract & process resource
resDto, urls, err := extractResource(resMsg)
if err != nil {
log.Err(err).Msg("Error while extracting resource")
return err
return fmt.Errorf("error while extracting resource: %s", err)
}
// Submit to the API
_, err = apiClient.AddResource(resDto)
if err != nil {
log.Err(err).Msg("Error while adding resource")
return err
return fmt.Errorf("error while adding resource (%s): %s", resDto.URL, err)
}
// Finally push found URLs
@ -109,18 +105,35 @@ func handleMessage(apiClient api.Client) messaging.MsgHandler {
}
func extractResource(msg messaging.NewResourceMsg) (api.ResourceDto, []string, error) {
resDto := api.ResourceDto{
URL: msg.URL,
Title: extractTitle(msg.Body),
Body: msg.Body,
Time: time.Now(),
doc, err := goquery.NewDocumentFromReader(strings.NewReader(msg.Body))
if err != nil {
return api.ResourceDto{}, nil, err
}
// Extract URLs
xu := xurls.Strict()
// Get resource title
title := doc.Find("title").First().Text()
// Get meta values
meta := map[string]string{}
doc.Find("meta").Each(func(i int, s *goquery.Selection) {
name, _ := s.Attr("name")
value, _ := s.Attr("content")
// Sanitize URLs
// if name is empty then try to lookup using property
if name == "" {
name, _ = s.Attr("property")
if name == "" {
return
}
}
meta[name] = value
})
// Extract & normalize URLs
xu := xurls.Strict()
urls := xu.FindAllString(msg.Body, -1)
var normalizedURLS []string
for _, url := range urls {
@ -132,21 +145,14 @@ func extractResource(msg messaging.NewResourceMsg) (api.ResourceDto, []string, e
normalizedURLS = append(normalizedURLS, normalizedURL)
}
return resDto, normalizedURLS, nil
}
// extract title from html body
func extractTitle(body string) string {
cleanBody := strings.ToLower(body)
if strings.Index(cleanBody, "<title>") == -1 || strings.Index(cleanBody, "</title>") == -1 {
return ""
}
startPos := strings.Index(cleanBody, "<title>") + len("<title>")
endPos := strings.Index(cleanBody, "</title>")
return body[startPos:endPos]
return api.ResourceDto{
URL: msg.URL,
Body: msg.Body,
Time: time.Now(),
Title: title,
Meta: meta,
Description: meta["description"],
}, normalizedURLS, nil
}
func normalizeURL(u string) (string, error) {

@ -11,9 +11,20 @@ import (
)
func TestExtractResource(t *testing.T) {
body := `
<title>Creekorful Inc</title>
This is sparta
<a href="https://google.com/test?test=test#12">
<meta name="description" content="Zhello world">
<meta property="og:url" content="https://example.org">
`
msg := messaging.NewResourceMsg{
URL: "https://example.org/300",
Body: "<title>Creekorful Inc</title>This is sparta<a href\"https://google.com/test?test=test#12\"",
Body: body,
}
resDto, urls, err := extractResource(msg)
@ -37,17 +48,17 @@ func TestExtractResource(t *testing.T) {
if urls[0] != "https://google.com/test?test=test" {
t.Fail()
}
}
func TestExtractTitle(t *testing.T) {
c := "hello this <title>is A</title>TEST"
if val := extractTitle(c); val != "is A" {
t.Errorf("Wanted: %s Got: %s", "is A", val)
if resDto.Description != "Zhello world" {
t.Fail()
}
c = "hello this is another test"
if val := extractTitle(c); val != "" {
t.Errorf("No matches should have been returned")
if resDto.Meta["description"] != "Zhello world" {
t.Fail()
}
if resDto.Meta["og:url"] != "https://example.org" {
t.Fail()
}
}
@ -63,6 +74,16 @@ func TestNormalizeURL(t *testing.T) {
}
func TestHandleMessage(t *testing.T) {
body := `
<title>Creekorful Inc</title>
This is sparta
<a href="https://google.com/test?test=test#12">
<meta name="description" content="Zhello world">
<meta property="og:url" content="https://example.org">`
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
@ -72,19 +93,24 @@ func TestHandleMessage(t *testing.T) {
msg := nats.Msg{}
subscriberMock.EXPECT().
ReadMsg(&msg, &messaging.NewResourceMsg{}).
SetArg(1, messaging.NewResourceMsg{URL: "https://example.onion", Body: "Hello, world<title>Title</title><a href=\"https://google.com\"></a>"}).
SetArg(1, messaging.NewResourceMsg{URL: "https://example.onion", Body: body}).
Return(nil)
// make sure we are creating the resource
apiClientMock.EXPECT().AddResource(&resMatcher{target: api.ResourceDto{
URL: "https://example.onion",
Body: "Hello, world<title>Title</title><a href=\"https://google.com\"></a>",
Title: "Title",
URL: "https://example.onion",
Body: body,
Title: "Creekorful Inc",
Meta: map[string]string{"description": "Zhello world", "og:url": "https://example.org"},
Description: "Zhello world",
}}).Return(api.ResourceDto{}, nil)
// make sure we are pushing found URLs
subscriberMock.EXPECT().
PublishMsg(&messaging.URLFoundMsg{URL: "https://google.com"}).
PublishMsg(&messaging.URLFoundMsg{URL: "https://example.org"}).
Return(nil)
subscriberMock.EXPECT().
PublishMsg(&messaging.URLFoundMsg{URL: "https://google.com/test?test=test"}).
Return(nil)
if err := handleMessage(apiClientMock)(subscriberMock, &msg); err != nil {
@ -100,9 +126,24 @@ type resMatcher struct {
func (rm *resMatcher) Matches(x interface{}) bool {
arg := x.(api.ResourceDto)
return arg.Title == rm.target.Title && arg.URL == rm.target.URL && arg.Body == rm.target.Body
return arg.Title ==
rm.target.Title &&
arg.URL == rm.target.URL &&
arg.Body == rm.target.Body &&
arg.Description == rm.target.Description &&
exactMatch(arg.Meta, rm.target.Meta)
}
func (rm *resMatcher) String() string {
return "is valid resource"
}
func exactMatch(left, right map[string]string) bool {
for key, want := range left {
if got, exist := right[key]; !exist || got != want {
return false
}
}
return true
}

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"github.com/nats-io/nats.go"
"github.com/rs/zerolog/log"
)
// MsgHandler represent an handler for a NATS subscriber
@ -51,11 +52,13 @@ func (s *subscriber) QueueSubscribe(subject, queue string, handler MsgHandler) e
// Read incoming message
msg, err := sub.NextMsgWithContext(context.Background())
if err != nil {
log.Warn().Str("err", err.Error()).Msg("error while reading incoming message, skipping it")
continue
}
// ... And process it
if err := handler(s, msg); err != nil {
log.Err(err).Msg("error while processing message")
continue
}
}

@ -25,11 +25,15 @@ func GetApp() *cli.App {
logging.GetLogFlag(),
util.GetNATSURIFlag(),
util.GetAPIURIFlag(),
util.GetAPILoginFlag(),
util.GetAPITokenFlag(),
&cli.StringFlag{
Name: "refresh-delay",
Usage: "Duration before allowing crawl of existing resource (none = never)",
},
&cli.StringSliceFlag{
Name: "forbidden-extensions",
Usage: "Extensions to disable scheduling for (i.e png, exe, css, ...) (the dot will be added automatically)",
},
},
Action: execute,
}
@ -38,23 +42,18 @@ func GetApp() *cli.App {
func execute(ctx *cli.Context) error {
logging.ConfigureLogger(ctx)
log.Info().Str("ver", ctx.App.Version).Msg("Starting tdsh-scheduler")
log.Debug().Str("uri", ctx.String("nats-uri")).Msg("Using NATS server")
log.Debug().Str("uri", ctx.String("api-uri")).Msg("Using API server")
refreshDelay := parseRefreshDelay(ctx.String("refresh-delay"))
if refreshDelay != -1 {
log.Debug().Stringer("delay", refreshDelay).Msg("Existing resources will be crawled again")
} else {
log.Debug().Msg("Existing resources will NOT be crawled again")
}
log.Info().
Str("ver", ctx.App.Version).
Str("nats-uri", ctx.String("nats-uri")).
Str("api-uri", ctx.String("api-uri")).
Strs("forbidden-exts", ctx.StringSlice("forbidden-extensions")).
Dur("refresh-delay", refreshDelay).
Msg("Starting tdsh-scheduler")
// Create the API client
apiClient, err := util.GetAPIAuthenticatedClient(ctx)
if err != nil {
return err
}
apiClient := util.GetAPIClient(ctx)
// Create the NATS subscriber
sub, err := messaging.NewSubscriber(ctx.String("nats-uri"))
@ -65,32 +64,43 @@ func execute(ctx *cli.Context) error {
log.Info().Msg("Successfully initialized tdsh-scheduler. Waiting for URLs")
if err := sub.QueueSubscribe(messaging.URLFoundSubject, "schedulers", handleMessage(apiClient, refreshDelay)); err != nil {
handler := handleMessage(apiClient, refreshDelay, ctx.StringSlice("forbidden-extensions"))
if err := sub.QueueSubscribe(messaging.URLFoundSubject, "schedulers", handler); err != nil {
return err
}
return nil
}
func handleMessage(apiClient api.Client, refreshDelay time.Duration) messaging.MsgHandler {
func handleMessage(apiClient api.Client, refreshDelay time.Duration, forbiddenExtensions []string) messaging.MsgHandler {
return func(sub messaging.Subscriber, msg *nats.Msg) error {
var urlMsg messaging.URLFoundMsg
if err := sub.ReadMsg(msg, &urlMsg); err != nil {
return err
}
log.Debug().Str("url", urlMsg.URL).Msg("Processing URL")
log.Trace().Str("url", urlMsg.URL).Msg("Processing URL")
u, err := url.Parse(urlMsg.URL)
if err != nil {
log.Err(err).Msg("Error while parsing URL")
return err
return fmt.Errorf("error while parsing URL: %s", err)
}
// Make sure URL is valid .onion
if !strings.Contains(u.Host, ".onion") {
log.Debug().Stringer("url", u).Msg("URL is not a valid hidden service")
return fmt.Errorf("%s is not a valid .onion", u.Host)
log.Trace().Stringer("url", u).Msg("URL is not a valid hidden service")
return nil // Technically not an error
}
// Make sure extension is not forbidden
for _, ext := range forbiddenExtensions {
if strings.HasSuffix(u.Path, "."+ext) {
log.Trace().
Stringer("url", u).
Str("ext", ext).
Msg("Skipping URL with forbidden extension")
return nil // Technically not an error
}
}
// If we want to allow re-schedule of existing crawled resources we need to retrieve only resources
@ -102,8 +112,7 @@ func handleMessage(apiClient api.Client, refreshDelay time.Duration) messaging.M
_, count, err := apiClient.SearchResources(u.String(), "", time.Time{}, endDate, 1, 1)
if err != nil {
log.Err(err).Msg("Error while searching URL")
return err
return fmt.Errorf("error while searching resource (%s): %s", u, err)
}
// No matches: schedule!

@ -42,12 +42,12 @@ func TestHandleMessageNotOnion(t *testing.T) {
SetArg(1, messaging.URLFoundMsg{URL: "https://example.org"}).
Return(nil)
if err := handleMessage(apiClientMock, -1)(subscriberMock, &msg); err == nil {
if err := handleMessage(apiClientMock, -1, []string{})(subscriberMock, &msg); err != nil {
t.FailNow()
}
}
func TestHandleMessageNoSchedule(t *testing.T) {
func TestHandleMessageAlreadyCrawled(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
@ -64,7 +64,25 @@ func TestHandleMessageNoSchedule(t *testing.T) {
SearchResources("https://example.onion", "", time.Time{}, time.Time{}, 1, 1).
Return([]api.ResourceDto{}, int64(1), nil)
if err := handleMessage(apiClientMock, -1)(subscriberMock, &msg); err != nil {
if err := handleMessage(apiClientMock, -1, []string{"png"})(subscriberMock, &msg); err != nil {
t.FailNow()
}
}
func TestHandleMessageForbiddenExtensions(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
apiClientMock := api_mock.NewMockClient(mockCtrl)
subscriberMock := messaging_mock.NewMockSubscriber(mockCtrl)
msg := nats.Msg{}
subscriberMock.EXPECT().
ReadMsg(&msg, &messaging.URLFoundMsg{}).
SetArg(1, messaging.URLFoundMsg{URL: "https://example.onion/image.png?id=12&test=2"}).
Return(nil)
if err := handleMessage(apiClientMock, -1, []string{"png"})(subscriberMock, &msg); err != nil {
t.FailNow()
}
}
@ -90,7 +108,7 @@ func TestHandleMessage(t *testing.T) {
PublishMsg(&messaging.URLTodoMsg{URL: "https://example.onion"}).
Return(nil)
if err := handleMessage(apiClientMock, -1)(subscriberMock, &msg); err != nil {
if err := handleMessage(apiClientMock, -1, []string{})(subscriberMock, &msg); err != nil {
t.FailNow()
}
}

@ -17,10 +17,6 @@ func GetApp() *cli.App {
apiFlag.Value = "http://localhost:15005"
apiFlag.Required = false
apiLoginFlag := util.GetAPILoginFlag()
apiLoginFlag.Value = "demo:demo"
apiLoginFlag.Required = false
return &cli.App{
Name: "trandoshanctl",
Version: "0.5.1",
@ -28,7 +24,7 @@ func GetApp() *cli.App {
Flags: []cli.Flag{
logging.GetLogFlag(),
apiFlag,
apiLoginFlag,
util.GetAPITokenFlag(),
},
Commands: []*cli.Command{
{
@ -61,11 +57,7 @@ func schedule(c *cli.Context) error {
url := c.Args().First()
// Create the API client
apiClient, err := util.GetAPIAuthenticatedClient(c)
if err != nil {
log.Err(err).Msg("Error while creating API client")
return err
}
apiClient := util.GetAPIClient(c)
if err := apiClient.ScheduleURL(url); err != nil {
log.Err(err).Str("url", url).Msg("Unable to schedule crawling for URL")
@ -81,11 +73,7 @@ func search(c *cli.Context) error {
keyword := c.Args().First()
// Create the API client
apiClient, err := util.GetAPIAuthenticatedClient(c)
if err != nil {
log.Err(err).Msg("Error while creating API client")
return err
}
apiClient := util.GetAPIClient(c)
res, count, err := apiClient.SearchResources("", keyword, time.Time{}, time.Time{}, 1, 10)
if err != nil {

@ -1,17 +1,15 @@
package util
import (
"fmt"
"github.com/creekorful/trandoshan/api"
"github.com/urfave/cli/v2"
"strings"
)
// GetAPILoginFlag return the cli flag to set api credentials
func GetAPILoginFlag() *cli.StringFlag {
// GetAPITokenFlag return the cli flag to provide API token
func GetAPITokenFlag() *cli.StringFlag {
return &cli.StringFlag{
Name: "api-login",
Usage: "Login to use when dialing with the API",
Name: "api-token",
Usage: "Token to use to authenticate against the API",
Required: true,
}
}
@ -25,31 +23,7 @@ func GetAPIURIFlag() *cli.StringFlag {
}
}
// GetAPILogin return the credentials from cli flag
func GetAPILogin(c *cli.Context) (api.CredentialsDto, error) {
if c.String("api-login") == "" {
return api.CredentialsDto{}, fmt.Errorf("missing credentials")
}
credentials := strings.Split(c.String("api-login"), ":")
if len(credentials) != 2 {
return api.CredentialsDto{}, fmt.Errorf("wrong credentials format")
}
return api.CredentialsDto{Username: credentials[0], Password: credentials[1]}, nil
}
// GetAPIAuthenticatedClient return the authenticated api client
func GetAPIAuthenticatedClient(c *cli.Context) (api.Client, error) {
// Create the API client
credentials, err := GetAPILogin(c)
if err != nil {
return nil, err
}
apiClient, err := api.NewAuthenticatedClient(c.String("api-uri"), credentials)
if err != nil {
return nil, err
}
return apiClient, nil
// GetAPIClient return a new configured API client
func GetAPIClient(c *cli.Context) api.Client {
return api.NewClient(c.String("api-uri"), c.String("api-token"))
}

@ -0,0 +1,37 @@
#!/bin/bash
# make sure we have passed a tag as version
if [ "$1" ]; then
tag="$1"
else
echo "correct usage ./release.sh <tag>"
exit 1
fi
# create release commit
git add .
git commit -m "Release $tag"
# create signed tag
git tag -s "v$tag" -m "Release $tag"
# build the docker images
./scripts/build.sh "$tag" # create version tag
./scripts/build.sh # create latest tag
echo ""
echo ""
echo "Release $tag is ready!"
echo "Please validate the changes, and once everything is confirmed, run the following:"
echo ""
echo "Update the git repository:"
echo ""
echo "$ git push && git push --tags"
echo ""
echo "Update the docker images:"
echo ""
echo "$ ./scripts/push.sh $tag"
echo "$ ./scripts/push.sh"
echo ""
echo ""
echo "Happy hacking ;D"
Loading…
Cancel
Save