├── static ├── ads.txt ├── security.txt ├── robots.txt └── views │ ├── google-analytics-template.html │ ├── header-template.html │ ├── newsletter-banner-template.html │ ├── apply-box-developer-template.html │ ├── messages-chart-template.html │ ├── clickout-chart-template.html │ ├── pageviews-chart-template.html │ ├── view-blogpost.html │ ├── post-a-job-error.html │ ├── apply-message.html │ ├── post-a-job-success.html │ ├── support.html │ ├── privacy-policy.html │ ├── recruiter-job-posts.html │ ├── sent-messages.html │ ├── about.html │ └── edit-recruiter-profile.html ├── .gitignore ├── run-local-webserver.sh ├── SECURITY.md ├── internal ├── recruiter │ ├── model.go │ └── repository.go ├── user │ ├── model.go │ └── repository.go ├── bookmark │ ├── model.go │ └── repository.go ├── blog │ ├── model.go │ └── repository.go ├── company │ ├── model.go │ └── repository.go ├── handler │ ├── recruiter.go │ ├── developer.go │ └── bookmark.go ├── developer │ ├── filters.go │ └── model.go ├── imagemeta │ └── imagemeta.go ├── email │ └── email.go ├── template │ └── template.go ├── payment │ └── payment.go ├── middleware │ └── middleware.go ├── job │ └── model.go └── seo │ └── seo.go ├── .github └── workflows │ ├── golangci-lint.yml │ └── codeql-analysis.yml ├── setup-database.sh ├── LICENSE.txt ├── go.mod ├── README.md ├── .golangci.yaml └── go.sum /static/ads.txt: -------------------------------------------------------------------------------- 1 | google.com, pub-2492493440371342, DIRECT, f08c47fec0942fa0 -------------------------------------------------------------------------------- /static/security.txt: -------------------------------------------------------------------------------- 1 | Contact: mailto:__support_email_placeholder__ 2 | Preferred-Languages: en 3 | Canonical: https://__host_placeholder__/.well-known/security.txt 4 | -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: /x/r 3 | Allow: /x/s/m/* 4 | Disallow: /x/* 5 | Disallow: /cdn-cgi/l/email-protection 6 | 7 | Sitemap: https://__host_placeholder__/sitemap.xml 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode/ 3 | *.sh 4 | *.swp 5 | *.env 6 | !setup-database.sh 7 | !run-local-webserver.sh 8 | !local.env 9 | bin 10 | .idea 11 | cmd/server/tmp 12 | tmp 13 | fly.toml 14 | -------------------------------------------------------------------------------- /run-local-webserver.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker run -it --rm \ 4 | -w /app \ 5 | -v $(pwd):/app \ 6 | -p 9876:9876 \ 7 | --env-file local.env \ 8 | cosmtrek/air \ 9 | --build.cmd "go build -o bin/server cmd/server/main.go" --build.bin "./bin/server" 10 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | We do offer discretinary bounties for [security vulnerability](https://www.first.org/cvss/examples) that have complete (C) or partial (P) impact on Availability (A), Integrity (I) or Confidentiality (C). Please reach out at security@golang.cafe to report any possible vulnerability. 6 | -------------------------------------------------------------------------------- /internal/recruiter/model.go: -------------------------------------------------------------------------------- 1 | package recruiter 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Recruiter struct { 8 | ID string 9 | Name string 10 | Email string 11 | Title string 12 | Company string 13 | CompanyURL string 14 | Slug string 15 | CreatedAt time.Time 16 | UpdatedAt time.Time 17 | PlanExpiredAt time.Time 18 | } 19 | -------------------------------------------------------------------------------- /static/views/google-analytics-template.html: -------------------------------------------------------------------------------- 1 | {{ define "google-analytics" }} 2 | 3 | 10 | {{ end }} -------------------------------------------------------------------------------- /internal/user/model.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "time" 4 | 5 | const ( 6 | UserTypeDeveloper = "developer" 7 | UserTypeAdmin = "admin" 8 | UserTypeRecruiter = "recruiter" 9 | ) 10 | 11 | type User struct { 12 | ID string 13 | Email string 14 | CreatedAtHumanised string 15 | CreatedAt time.Time 16 | IsAdmin bool 17 | Type string 18 | } 19 | -------------------------------------------------------------------------------- /internal/bookmark/model.go: -------------------------------------------------------------------------------- 1 | package bookmark 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/lib/pq" 7 | ) 8 | 9 | type Bookmark struct { 10 | UserID string 11 | JobPostID int 12 | CreatedAt time.Time 13 | AppliedAt pq.NullTime 14 | 15 | JobSlug string 16 | JobTitle string 17 | CompanyName string 18 | JobExternalID string 19 | CompanyURLEnc string 20 | JobLocation string 21 | JobSalaryRange string 22 | JobSalaryPeriod string 23 | JobCreatedAt time.Time 24 | JobTimeAgo string 25 | 26 | HasApplyRecord bool 27 | } 28 | -------------------------------------------------------------------------------- /internal/blog/model.go: -------------------------------------------------------------------------------- 1 | package blog 2 | 3 | import "time" 4 | 5 | type BlogPost struct { 6 | ID string 7 | Title string 8 | Description string 9 | Tags string 10 | Slug string 11 | Text string 12 | CreatedAt time.Time 13 | UpdatedAt time.Time 14 | PublishedAt *time.Time 15 | CreatedBy string 16 | } 17 | 18 | type CreateRq struct { 19 | Title string 20 | Description string 21 | Tags string 22 | Text string 23 | } 24 | 25 | type UpdateRq struct { 26 | ID string 27 | Title string 28 | Description string 29 | Tags string 30 | Text string 31 | } 32 | -------------------------------------------------------------------------------- /internal/company/model.go: -------------------------------------------------------------------------------- 1 | package company 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | const ( 8 | SearchTypeCompany = "company" 9 | ) 10 | 11 | type Company struct { 12 | ID string 13 | Name string 14 | URL string 15 | Locations string 16 | IconImageID string 17 | Description *string 18 | LastJobCreatedAt time.Time 19 | TotalJobCount int 20 | ActiveJobCount int 21 | Featured bool 22 | Slug string 23 | Twitter *string 24 | Github *string 25 | Linkedin *string 26 | CompanyPageEligibilityExpiredAt time.Time 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | paths: 11 | - '**/*.go' 12 | permissions: 13 | contents: read 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v2 21 | - name: Run golangci-lint 22 | uses: golangci/golangci-lint-action@v2 23 | with: 24 | version: v1.29 25 | args: --issues-exit-code=0 # Optional: golangci-lint command line arguments. 26 | only-new-issues: true # Optional: show only new issues if it's a pull request. 27 | skip-go-installation: true # Optional: use pre-installed Go. 28 | skip-pkg-cache: true # Optional: don't cache or restore ~/go/pkg. 29 | skip-build-cache: true # Optional: don't cache or restore ~/.cache/go-build. 30 | 31 | -------------------------------------------------------------------------------- /setup-database.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! command -v docker &> /dev/null 4 | then 5 | echo 'Docker is required for this script. Please install it at https://docs.docker.com/get-docker/'; 6 | exit 1 7 | fi; 8 | 9 | DB_USER=postgres 10 | DB_PASS=passw0rd! 11 | DB_NAME=postgres 12 | 13 | echo 'Making sure database name is available...'; 14 | 15 | docker rm -f golangcafepsql 16 | 17 | echo 'Starting database...'; 18 | 19 | docker run --name golangcafepsql --rm -e POSTGRES_USER="${DB_USER}" -e POSTGRES_DB="${DB_NAME}" -e POSTGRES_PASSWORD="${DB_PASS}" -p 0.0.0.0:9999:5432 -d postgres 20 | 21 | echo 'Waiting for database to be ready...'; 22 | 23 | sleep 10 24 | 25 | echo 'Setting up database schema...'; 26 | docker cp ./latest-schema.sql golangcafepsql:/tmp/latest-schema.sql 27 | docker exec -u "${DB_USER}" golangcafepsql psql "${DB_NAME}" "${DB_USER}" -f /tmp/latest-schema.sql 28 | 29 | echo 'Setting up database fixtures...'; 30 | docker cp ./latest-fixtures.sql golangcafepsql:/tmp/latest-fixtures.sql 31 | docker exec -u "${DB_USER}" golangcafepsql psql "${DB_NAME}" "${DB_USER}" -f /tmp/latest-fixtures.sql 32 | -------------------------------------------------------------------------------- /internal/handler/recruiter.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/golang-cafe/job-board/internal/developer" 7 | "github.com/golang-cafe/job-board/internal/middleware" 8 | "github.com/golang-cafe/job-board/internal/server" 9 | ) 10 | 11 | func SentMessages(svr server.Server, devRepo *developer.Repository) http.HandlerFunc { 12 | return middleware.UserAuthenticatedMiddleware( 13 | svr.SessionStore, 14 | svr.GetJWTSigningKey(), 15 | func(w http.ResponseWriter, r *http.Request) { 16 | profile, _ := middleware.GetUserFromJWT(r, svr.SessionStore, svr.GetJWTSigningKey()) 17 | if !profile.IsAdmin && !profile.IsRecruiter { 18 | svr.JSON(w, http.StatusForbidden, nil) 19 | return 20 | } 21 | 22 | messages, err := devRepo.GetDeveloperMessagesSentFrom(profile.UserID) 23 | if err != nil { 24 | svr.Log(err, "GetDeveloperMessagesSentFrom") 25 | } 26 | 27 | err = svr.Render(r, w, http.StatusOK, "sent-messages.html", map[string]interface{}{ 28 | "Messages": messages, 29 | }) 30 | if err != nil { 31 | svr.Log(err, "unable to render sent messages page") 32 | } 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /static/views/header-template.html: -------------------------------------------------------------------------------- 1 | {{ define "header-html" }} 2 |
3 | 21 |
22 | {{ end }} -------------------------------------------------------------------------------- /internal/developer/filters.go: -------------------------------------------------------------------------------- 1 | package developer 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type RecruiterFilters = struct { 10 | HourlyMin int 11 | HourlyMax int 12 | RoleLevels map[string]interface{} 13 | RoleTypes map[string]interface{} 14 | } 15 | 16 | func ParseRecruiterFiltersFromQuery(query url.Values) RecruiterFilters { 17 | hourlyMinStr := query.Get("hourlyMin") 18 | hourlyMaxStr := query.Get("hourlyMax") 19 | rawRoleLevelsStr := query.Get("roleLevel") 20 | rawRoleTypesStr := query.Get("roleType") 21 | 22 | // If we can't convert the string to an int we're happy leaving the zero values 23 | hourlyMin, _ := strconv.Atoi(hourlyMinStr) 24 | hourlyMax, _ := strconv.Atoi(hourlyMaxStr) 25 | 26 | // We can take a CSV of role levels. 27 | roleLevels := make(map[string]interface{}) 28 | for _, rawRoleLevel := range strings.Split(rawRoleLevelsStr, ",") { 29 | if _, ok := ValidRoleLevels[rawRoleLevel]; ok { 30 | roleLevels[rawRoleLevel] = true 31 | } 32 | } 33 | 34 | // and role types 35 | roleTypes := make(map[string]interface{}) 36 | for _, rawRoleType := range strings.Split(rawRoleTypesStr, ",") { 37 | if _, ok := ValidRoleTypes[rawRoleType]; ok { 38 | roleTypes[rawRoleType] = true 39 | } 40 | } 41 | 42 | return RecruiterFilters{ 43 | hourlyMin, 44 | hourlyMax, 45 | roleLevels, 46 | roleTypes, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, the respective contributors, as shown by the AUTHORS file. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /internal/imagemeta/imagemeta.go: -------------------------------------------------------------------------------- 1 | package imagemeta 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image/color" 7 | "image/png" 8 | "io" 9 | "path/filepath" 10 | 11 | "github.com/golang-cafe/job-board/internal/job" 12 | "github.com/fogleman/gg" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | const ( 17 | backgroundImageFilename = "static/assets/img/meta-bg.jpg" 18 | outputFilename = "output.jpg" 19 | ) 20 | 21 | func GenerateImageForJob(jobPost job.JobPost) (io.ReadWriter, error) { 22 | dc := gg.NewContext(1200, 628) 23 | backgroundImage, err := gg.LoadImage(backgroundImageFilename) 24 | w := bytes.NewBuffer([]byte{}) 25 | if err != nil { 26 | return w, errors.Wrap(err, "load background image") 27 | } 28 | // draw background image 29 | dc.DrawImage(backgroundImage, 0, 0) 30 | 31 | // draw job post title and description 32 | title := fmt.Sprintf("%s with %s\n\n %s\n\n %s", jobPost.JobTitle, jobPost.Company, jobPost.Location, jobPost.SalaryRange) 33 | mainTextColor := color.RGBA{ 34 | R: uint8(0), 35 | G: uint8(0), 36 | B: uint8(144), 37 | A: uint8(255), 38 | } 39 | fontPath := filepath.Join("static", "assets", "fonts", "verdana", "verdana.ttf") 40 | if err := dc.LoadFontFace(fontPath, 60); err != nil { 41 | return w, errors.Wrap(err, "load Courier_Prime for job link") 42 | } 43 | textRightMargin := 80.0 44 | textTopMargin := 90.0 45 | x := textRightMargin 46 | y := textTopMargin 47 | maxWidth := float64(dc.Width()) - textRightMargin - textRightMargin 48 | dc.SetColor(mainTextColor) 49 | dc.DrawStringWrapped(title, x, y, 0, 0, maxWidth, 1.5, gg.AlignLeft) 50 | 51 | if err := png.Encode(w, dc.Image()); err != nil { 52 | return w, err 53 | } 54 | 55 | return w, nil 56 | } 57 | -------------------------------------------------------------------------------- /static/views/newsletter-banner-template.html: -------------------------------------------------------------------------------- 1 | {{ define "newsletter-banner-html" }} 2 | {{ if not .LoggedUser }} 3 |
4 | Get a weekly email with all new {{ .SiteJobCategory }} jobs 5 | 6 | 7 |
x
8 |
9 | {{ end }} 10 | {{ end }} 11 | {{ define "newsletter-banner-css" }} 12 | {{ if not .LoggedUser }} 13 | 18 | {{ end }} 19 | {{ end }} 20 | {{ define "newsletter-banner-js" }} 21 | {{ if not .LoggedUser }} 22 | 43 | {{ end }} 44 | {{ end }} 45 | -------------------------------------------------------------------------------- /static/views/apply-box-developer-template.html: -------------------------------------------------------------------------------- 1 | {{ define "apply-box-developer"}} 2 |
3 |

Create your profile to continue

4 |
5 |

6 |
7 | {{ range $i, $j := .TopDevelopers }} 8 | {{ .Name }} 16 | {{ end }} 17 |
18 | 41 |
42 | 49 |
50 |
51 | {{ end }} 52 | -------------------------------------------------------------------------------- /static/views/messages-chart-template.html: -------------------------------------------------------------------------------- 1 | {{ define "messages-chart" }} 2 |

Received Messages

3 |
4 | {{ end }} 5 | 6 | {{ define "messages-chart-js" }} 7 | 58 | {{ end }} 59 | -------------------------------------------------------------------------------- /static/views/clickout-chart-template.html: -------------------------------------------------------------------------------- 1 | {{ define "clickout-chart" }} 2 |

Clickouts

3 |
4 | {{ end }} 5 | 6 | {{ define "clickout-chart-js" }} 7 | 59 | {{ end }} 60 | -------------------------------------------------------------------------------- /static/views/pageviews-chart-template.html: -------------------------------------------------------------------------------- 1 | {{ define "pageviews-chart" }} 2 |

Pageviews

3 |
4 | {{ end }} 5 | 6 | {{ define "pageviews-chart-js" }} 7 | 58 | {{ end }} 59 | -------------------------------------------------------------------------------- /internal/handler/developer.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/golang-cafe/job-board/internal/developer" 9 | "github.com/golang-cafe/job-board/internal/middleware" 10 | "github.com/golang-cafe/job-board/internal/server" 11 | ) 12 | 13 | func ReceivedMessages(svr server.Server, devRepo *developer.Repository) http.HandlerFunc { 14 | return middleware.UserAuthenticatedMiddleware( 15 | svr.SessionStore, 16 | svr.GetJWTSigningKey(), 17 | func(w http.ResponseWriter, r *http.Request) { 18 | profile, _ := middleware.GetUserFromJWT(r, svr.SessionStore, svr.GetJWTSigningKey()) 19 | if !profile.IsAdmin && !profile.IsDeveloper { 20 | svr.JSON(w, http.StatusForbidden, nil) 21 | return 22 | } 23 | 24 | dev, err := devRepo.DeveloperProfileByEmail(profile.Email) 25 | if err != nil { 26 | svr.Log(err, "DeveloperProfileByEmail") 27 | svr.JSON(w, http.StatusInternalServerError, nil) 28 | return 29 | } 30 | 31 | messages, err := devRepo.GetDeveloperMessagesSentTo(dev.ID) 32 | if err != nil { 33 | svr.Log(err, "GetDeveloperMessagesSentTo") 34 | } 35 | 36 | viewCount, err := devRepo.GetViewCountForProfile(dev.ID) 37 | if err != nil { 38 | svr.Log(err, fmt.Sprintf("unable to retrieve job view count for dev id %s", dev.ID)) 39 | } 40 | messagesCount, err := devRepo.GetMessagesCountForJob(dev.ID) 41 | if err != nil { 42 | svr.Log(err, fmt.Sprintf("unable to retrieve job messages count for dev id %s", dev.ID)) 43 | } 44 | stats, err := devRepo.GetStatsForProfile(dev.ID) 45 | if err != nil { 46 | svr.Log(err, fmt.Sprintf("unable to retrieve stats for dev id %s", dev.ID)) 47 | } 48 | statsSet, err := json.Marshal(stats) 49 | if err != nil { 50 | svr.Log(err, fmt.Sprintf("unable to marshal stats for dev id %s", dev.ID)) 51 | } 52 | 53 | err = svr.Render(r, w, http.StatusOK, "messages.html", map[string]interface{}{ 54 | "Messages": messages, 55 | "ViewCount": viewCount, 56 | "MessagesCount": messagesCount, 57 | "Stats": string(statsSet), 58 | }) 59 | if err != nil { 60 | svr.Log(err, "unable to render sent messages page") 61 | } 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/golang-cafe/job-board 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/ChimeraCoder/anaconda v2.0.0+incompatible 7 | github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7 // indirect 8 | github.com/PuerkitoBio/goquery v1.6.0 9 | github.com/aclements/go-moremath v0.0.0-20161014184102-0ff62e0875ff 10 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc 11 | github.com/allegro/bigcache/v2 v2.2.5 // indirect 12 | github.com/allegro/bigcache/v3 v3.0.0 13 | github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 // indirect 14 | github.com/bot-api/telegram v0.0.0-20170115211335-b7abf87c449e 15 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 16 | github.com/dustin/go-humanize v1.0.0 17 | github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc // indirect 18 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect 19 | github.com/fogleman/gg v1.3.0 20 | github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 // indirect 21 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 22 | github.com/gorilla/feeds v1.1.1 23 | github.com/gorilla/mux v1.7.3 24 | github.com/gorilla/sessions v1.2.0 25 | github.com/gosimple/slug v1.3.0 26 | github.com/kr/pretty v0.2.1 // indirect 27 | github.com/lib/pq v1.10.4 28 | github.com/m0sth8/httpmock v0.0.0-20160716183344-e00e64b1d782 // indirect 29 | github.com/machinebox/graphql v0.2.2 30 | github.com/matryer/is v1.4.0 // indirect 31 | github.com/microcosm-cc/bluemonday v1.0.16 32 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 33 | github.com/pkg/errors v0.8.1 34 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect 35 | github.com/rs/zerolog v1.20.0 36 | github.com/segmentio/ksuid v1.0.2 37 | github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect 38 | github.com/snabb/diagio v0.0.0-20170305182244-0ef68e3dbf01 // indirect 39 | github.com/snabb/sitemap v0.0.0-20171225173334-36baa8b39ef4 40 | github.com/stretchr/testify v1.8.0 // indirect 41 | github.com/stripe/stripe-go v62.10.0+incompatible 42 | golang.org/x/image v0.5.0 // indirect 43 | gopkg.in/russross/blackfriday.v2 v2.0.0 44 | gopkg.in/stretchr/testify.v1 v1.2.2 // indirect 45 | ) 46 | -------------------------------------------------------------------------------- /internal/handler/bookmark.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/golang-cafe/job-board/internal/bookmark" 7 | "github.com/golang-cafe/job-board/internal/job" 8 | "github.com/golang-cafe/job-board/internal/middleware" 9 | "github.com/golang-cafe/job-board/internal/server" 10 | ) 11 | 12 | func BookmarkListHandler(svr server.Server, bookmarkRepo *bookmark.Repository) http.HandlerFunc { 13 | return func(w http.ResponseWriter, r *http.Request) { 14 | profile, err := middleware.GetUserFromJWT(r, svr.SessionStore, svr.GetJWTSigningKey()) 15 | if err != nil { 16 | svr.Log(err, "unable to retrieve user from JWT") 17 | svr.JSON(w, http.StatusForbidden, nil) 18 | return 19 | } 20 | 21 | bookmarks, err := bookmarkRepo.GetBookmarksForUser(profile.UserID) 22 | if err != nil { 23 | svr.Log(err, "GetBookmarksForUser") 24 | } 25 | 26 | err = svr.Render(r, w, http.StatusOK, "bookmarks.html", map[string]interface{}{ 27 | "Bookmarks": bookmarks, 28 | }) 29 | if err != nil { 30 | svr.Log(err, "unable to render bookmarks page") 31 | } 32 | } 33 | } 34 | 35 | func BookmarkJobHandler(svr server.Server, bookmarkRepo *bookmark.Repository, jobRepo *job.Repository) http.HandlerFunc { 36 | return func(w http.ResponseWriter, r *http.Request) { 37 | profile, err := middleware.GetUserFromJWT(r, svr.SessionStore, svr.GetJWTSigningKey()) 38 | if err != nil { 39 | svr.Log(err, "unable to retrieve user from JWT") 40 | svr.JSON(w, http.StatusForbidden, nil) 41 | return 42 | } 43 | 44 | externalID := r.FormValue("job-id") 45 | job, err := jobRepo.GetJobByExternalID(externalID) 46 | if err != nil { 47 | svr.Log(err, "BookmarkJob") 48 | svr.JSON(w, http.StatusBadRequest, nil) 49 | return 50 | } 51 | 52 | switch r.Method { 53 | case http.MethodPost: 54 | err = bookmarkRepo.BookmarkJob(profile.UserID, job.ID, false) 55 | if err != nil { 56 | svr.Log(err, "BookmarkJob") 57 | svr.JSON(w, http.StatusInternalServerError, nil) 58 | return 59 | } 60 | svr.JSON(w, http.StatusCreated, nil) 61 | 62 | case http.MethodDelete: 63 | err = bookmarkRepo.RemoveBookmark(profile.UserID, job.ID) 64 | if err != nil { 65 | svr.Log(err, "RemoveBookmark") 66 | svr.JSON(w, http.StatusInternalServerError, nil) 67 | return 68 | } 69 | svr.JSON(w, http.StatusNoContent, nil) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '36 11 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go', 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /internal/email/email.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "encoding/base64" 5 | "net/smtp" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | type Client struct { 12 | senderAddress string 13 | noReplyAddress string 14 | siteName string 15 | client http.Client 16 | smtpUser string 17 | smtpPassword string 18 | smtpHost string 19 | baseURL string 20 | isLocal bool 21 | } 22 | 23 | type Attachment struct { 24 | Name string `json:"name"` 25 | B64Data string `json:"content"` 26 | } 27 | 28 | type Address struct { 29 | Name string `json:"name,omitempty"` 30 | Email string `json:"email,omitempty"` 31 | } 32 | 33 | type EmailMessage struct { 34 | Sender Address `json:"sender"` 35 | To []Address `json:"to"` 36 | Subject string `json:"subject"` 37 | ReplyTo Address `json:"replyTo,omitempty"` 38 | TextContent string `json:"textContent,omitempty"` 39 | HtmlContent string `json:"htmlContent,omitempty"` 40 | } 41 | 42 | type EmailMessageWithAttachment struct { 43 | EmailMessage 44 | Attachment []Attachment `json:"attachment,omitempty"` 45 | } 46 | 47 | func NewClient(smtpUser, smtpPassword, smtpHost, senderAddress, noReplyAddress, siteName string, isLocal bool) (Client, error) { 48 | return Client{ 49 | client: *http.DefaultClient, 50 | smtpUser: smtpUser, 51 | smtpPassword: smtpPassword, 52 | smtpHost: smtpHost, 53 | senderAddress: senderAddress, 54 | siteName: siteName, 55 | noReplyAddress: noReplyAddress, 56 | isLocal: isLocal, 57 | baseURL: "https://api.sendinblue.com"}, nil 58 | } 59 | 60 | func (e Client) DefaultReplyTo() string { 61 | return e.senderAddress 62 | } 63 | 64 | func (e Client) DefaultSenderName() string { 65 | return e.siteName 66 | } 67 | 68 | func (e Client) SupportSenderAddress() string { 69 | return e.senderAddress 70 | } 71 | 72 | func (e Client) NoReplySenderAddress() string { 73 | return e.noReplyAddress 74 | } 75 | 76 | func (e Client) DefaultAdminAddress() string { 77 | return e.senderAddress 78 | } 79 | 80 | func (e Client) SendHTMLEmail(from, to, replyTo Address, subject, text string) error { 81 | if e.isLocal { 82 | log.Printf( 83 | "SendHTMLEmail: from: %v, to: %s, replyTo: %v, subject: %s, text: %s", 84 | from, 85 | to, 86 | replyTo, 87 | subject, 88 | text, 89 | ) 90 | return nil 91 | } 92 | auth := smtp.PlainAuth("", e.smtpUser, e.smtpPassword, e.smtpHost) 93 | header := make(map[string]string) 94 | header["From"] = e.smtpUser 95 | header["To"] = to.Email 96 | header["Subject"] = subject 97 | header["MIME-Version"] = "1.0" 98 | header["Content-Type"] = "text/plain; charset=\"utf-8\"" 99 | header["Content-Transfer-Encoding"] = "base64" 100 | message := "" 101 | for k, v := range header { 102 | message += fmt.Sprintf("%s: %s\r\n", k, v) 103 | } 104 | message += "\r\n" + base64.StdEncoding.EncodeToString([]byte(text)) 105 | 106 | err := smtp.SendMail(e.smtpHost+":25", auth, e.smtpUser, []string{to.Email}, []byte(message)) 107 | if err != nil { 108 | log.Println("error send mail", err.Error()) 109 | return err 110 | } 111 | 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /internal/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "embed" 7 | "time" 8 | 9 | stdtemplate "html/template" 10 | humanize "github.com/dustin/go-humanize" 11 | blackfriday "gopkg.in/russross/blackfriday.v2" 12 | ) 13 | 14 | type Template struct { 15 | templates *stdtemplate.Template 16 | funcMap stdtemplate.FuncMap 17 | } 18 | 19 | func NewTemplate(fs embed.FS) *Template { 20 | funcMap := stdtemplate.FuncMap{ 21 | "add": func(a, b int) int { 22 | return a + b 23 | }, 24 | "sub": func(a, b int) int { 25 | return a - b 26 | }, 27 | "last": func(a []int) int { 28 | if len(a) == 0 { 29 | return -1 30 | } 31 | return a[len(a)-1] 32 | }, 33 | "jsescape": stdtemplate.JSEscapeString, 34 | "humantime": humanize.Time, 35 | "humannumber": func(n int) string { 36 | return humanize.Comma(int64(n)) 37 | }, 38 | "isTimeBeforeNow": func(t time.Time) bool { 39 | return t.Before(time.Now()) 40 | }, 41 | "isTimeAfterNow": func(t time.Time) bool { 42 | return t.After(time.Now()) 43 | }, 44 | "truncateName": func(s string) string { 45 | parts := strings.Split(s, " ") 46 | return parts[0] 47 | }, 48 | "stringTitle": func(s string) string { 49 | return strings.Title(s) 50 | }, 51 | "replaceDash": func(s string) string { 52 | return strings.ReplaceAll(s, "-", " ") 53 | }, 54 | "mul": func(a int, b int) int { 55 | return a*b 56 | }, 57 | "jobOlderThanMonths": func(monthYearCreated string, monthsAgo int) bool { 58 | t, err := time.Parse("January 2006", monthYearCreated) 59 | if err != nil { 60 | return false 61 | } 62 | return t.Before(time.Now().AddDate(0, -monthsAgo, 0)) 63 | }, 64 | "currencysymbol": func(currency string) string { 65 | symbols := map[string]string{ 66 | "USD": "$", 67 | "EUR": "€", 68 | "JPY": "¥", 69 | "GBP": "£", 70 | "AUD": "A$", 71 | "CAD": "C$", 72 | "CHF": "Fr", 73 | "CNY": "元", 74 | "HKD": "HK$", 75 | "NZD": "NZ$", 76 | "SEK": "kr", 77 | "KRW": "₩", 78 | "SGD": "S$", 79 | "NOK": "kr", 80 | "MXN": "MX$", 81 | "INR": "₹", 82 | "RUB": "₽", 83 | "ZAR": "R", 84 | "TRY": "₺", 85 | "BRL": "R$", 86 | } 87 | symbol, ok := symbols[currency] 88 | if !ok { 89 | return "$" 90 | } 91 | return symbol 92 | }, 93 | } 94 | return &Template{ 95 | templates: stdtemplate.Must(stdtemplate.New("stdtmpl").Funcs(funcMap).ParseFS(fs, "static/views/*.html")), 96 | } 97 | } 98 | 99 | func (t *Template) JSEscapeString(s string) string { 100 | return stdtemplate.JSEscapeString(s) 101 | } 102 | 103 | func (t *Template) Render(w http.ResponseWriter, status int, name string, data interface{}) error { 104 | w.WriteHeader(status) 105 | return t.templates.ExecuteTemplate(w, name, data) 106 | } 107 | 108 | func (t *Template) StringToHTML(s string) stdtemplate.HTML { 109 | return stdtemplate.HTML(s) 110 | } 111 | 112 | func (t *Template) MarkdownToHTML(s string) stdtemplate.HTML { 113 | renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{ 114 | Flags: blackfriday.Safelink | 115 | blackfriday.NofollowLinks | 116 | blackfriday.NoreferrerLinks | 117 | blackfriday.HrefTargetBlank, 118 | }) 119 | return stdtemplate.HTML(blackfriday.Run([]byte(s), blackfriday.WithRenderer(renderer))) 120 | } 121 | -------------------------------------------------------------------------------- /internal/developer/model.go: -------------------------------------------------------------------------------- 1 | package developer 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | const ( 10 | SearchStatusNotAvailable = "not-available" 11 | SearchStatusCasuallyLooking = "casually-looking" 12 | SearchStatusActivelyApplying = "actively-applying" 13 | ) 14 | 15 | var ValidSearchStatus = map[string]struct{}{ 16 | SearchStatusActivelyApplying: {}, 17 | SearchStatusCasuallyLooking: {}, 18 | SearchStatusNotAvailable: {}, 19 | } 20 | 21 | type RoleLevel struct { 22 | Id string 23 | Label string 24 | DisplayOrder int 25 | } 26 | 27 | var ValidRoleLevels = map[string]RoleLevel{ 28 | "junior": {"junior", "Junior", 0}, 29 | "mid-level": {"mid-level", "Mid-Level", 1}, 30 | "senior": {"senior", "Senior", 2}, 31 | "lead": {"lead", "Lead/Staff Engineer", 3}, 32 | "c-level": {"c-level", "C-level", 4}, 33 | } 34 | 35 | func SortedRoleLevels() (sortedRoleLevels []RoleLevel) { 36 | for _, role := range ValidRoleLevels { 37 | sortedRoleLevels = append(sortedRoleLevels, role) 38 | } 39 | sort.Slice(sortedRoleLevels, func(i, j int) bool { 40 | return sortedRoleLevels[i].DisplayOrder < sortedRoleLevels[j].DisplayOrder 41 | }) 42 | return 43 | } 44 | 45 | type RoleType struct { 46 | Id string 47 | Label string 48 | DisplayOrder int 49 | } 50 | 51 | var ValidRoleTypes = map[string]RoleType{ 52 | "full-time": {"full-time", "Full-Time", 0}, 53 | "part-time": {"part-time", "Part-Time", 1}, 54 | "contract": {"contract", "Contract", 2}, 55 | "internship": {"internship", "Internship", 3}, 56 | } 57 | 58 | func SortedRoleTypes() (sortedRoleTypes []RoleType) { 59 | for _, role := range ValidRoleTypes { 60 | sortedRoleTypes = append(sortedRoleTypes, role) 61 | } 62 | sort.Slice(sortedRoleTypes, func(i, j int) bool { 63 | return sortedRoleTypes[i].DisplayOrder < sortedRoleTypes[j].DisplayOrder 64 | }) 65 | return 66 | } 67 | 68 | type Developer struct { 69 | ID string 70 | Name string 71 | LinkedinURL string 72 | Email string 73 | Location string 74 | HourlyRate int64 75 | Available bool 76 | ImageID string 77 | Slug string 78 | CreatedAt time.Time 79 | UpdatedAt time.Time 80 | Skills string 81 | GithubURL *string 82 | TwitterURL *string 83 | SearchStatus string 84 | RoleLevel string 85 | RoleTypes []string 86 | DetectedLocationID *string 87 | 88 | Bio string 89 | SkillsArray []string 90 | CreatedAtHumanized string 91 | UpdatedAtHumanized string 92 | } 93 | 94 | func (d Developer) RoleTypeAsString() string { 95 | return strings.Join(d.RoleTypes, ", ") 96 | } 97 | 98 | type DeveloperMessage struct { 99 | ID string 100 | Email string 101 | Content string 102 | RecipientName string 103 | ProfileID string 104 | ProfileSlug string 105 | CreatedAt time.Time 106 | SentAt time.Time 107 | SenderID string 108 | } 109 | 110 | type DeveloperMetadata struct { 111 | ID string 112 | DeveloperProfileID string 113 | MetadataType string 114 | Title string 115 | Description string 116 | Link *string 117 | } 118 | 119 | type DevStat struct { 120 | Date string `json:"date"` 121 | PageViews int `json:"pageviews"` 122 | SentMessages int `json:"messages"` 123 | } 124 | -------------------------------------------------------------------------------- /internal/blog/repository.go: -------------------------------------------------------------------------------- 1 | package blog 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | type Repository struct { 8 | db *sql.DB 9 | } 10 | 11 | func NewRepository(db *sql.DB) *Repository { 12 | return &Repository{db} 13 | } 14 | 15 | func (r Repository) GetByIDAndAuthor(id, authorID string) (BlogPost, error) { 16 | var bp BlogPost 17 | row := r.db.QueryRow(`SELECT id, title, description, tags, slug, text, created_at, updated_at, created_by, published_at FROM blog_post WHERE id = $1 AND created_by = $2`, id, authorID) 18 | if err := row.Scan(&bp.ID, &bp.Title, &bp.Description, &bp.Tags, &bp.Slug, &bp.Text, &bp.CreatedAt, &bp.UpdatedAt, &bp.CreatedBy, &bp.PublishedAt); err != nil { 19 | return bp, err 20 | } 21 | 22 | return bp, nil 23 | } 24 | 25 | func (r Repository) GetBySlug(slug string) (BlogPost, error) { 26 | var bp BlogPost 27 | row := r.db.QueryRow(`SELECT id, title, description, tags, slug, text, created_at, updated_at, created_by FROM blog_post WHERE slug = $1 AND published_at IS NOT NULL`, slug) 28 | if err := row.Scan(&bp.ID, &bp.Title, &bp.Description, &bp.Tags, &bp.Slug, &bp.Text, &bp.CreatedAt, &bp.UpdatedAt, &bp.CreatedBy); err != nil { 29 | return bp, err 30 | } 31 | 32 | return bp, nil 33 | } 34 | 35 | func (r Repository) GetByCreatedBy(userID string) ([]BlogPost, error) { 36 | all := make([]BlogPost, 0) 37 | rows, err := r.db.Query(`SELECT id, title, description, tags, slug, text, updated_at, created_by, published_at FROM blog_post WHERE created_by = $1`, userID) 38 | if err == sql.ErrNoRows { 39 | return all, nil 40 | } 41 | if err != nil { 42 | return all, err 43 | } 44 | for rows.Next() { 45 | var bp BlogPost 46 | err := rows.Scan(&bp.ID, &bp.Title, &bp.Description, &bp.Tags, &bp.Slug, &bp.Text, &bp.UpdatedAt, &bp.CreatedBy, &bp.PublishedAt) 47 | if err != nil { 48 | return all, err 49 | } 50 | all = append(all, bp) 51 | } 52 | 53 | return all, nil 54 | } 55 | 56 | func (r Repository) GetAllPublished() ([]BlogPost, error) { 57 | all := make([]BlogPost, 0) 58 | rows, err := r.db.Query(`SELECT id, title, description, tags, slug, text, updated_at, created_by, published_at FROM blog_post WHERE published_at IS NOT NULL`) 59 | if err == sql.ErrNoRows { 60 | return all, nil 61 | } 62 | if err != nil { 63 | return all, err 64 | } 65 | for rows.Next() { 66 | var bp BlogPost 67 | err := rows.Scan(&bp.ID, &bp.Title, &bp.Description, &bp.Tags, &bp.Slug, &bp.Text, &bp.UpdatedAt, &bp.CreatedBy, &bp.PublishedAt) 68 | if err != nil { 69 | return all, err 70 | } 71 | all = append(all, bp) 72 | } 73 | 74 | return all, nil 75 | } 76 | 77 | func (r Repository) Create(bp BlogPost) error { 78 | _, err := r.db.Exec(`INSERT INTO blog_post (id, title, description, slug, tags, text, created_at, updated_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW(), $7)`, bp.ID, bp.Title, bp.Description, bp.Slug, bp.Tags, bp.Text, bp.CreatedBy) 79 | return err 80 | } 81 | 82 | func (r Repository) Update(bp BlogPost) error { 83 | _, err := r.db.Exec(`UPDATE blog_post SET title = $1, description = $2, tags = $3, text = $4, updated_at = NOW() WHERE id = $5 AND created_by = $6`, bp.Title, bp.Description, bp.Tags, bp.Text, bp.ID, bp.CreatedBy) 84 | return err 85 | } 86 | 87 | func (r Repository) Publish(bp BlogPost) error { 88 | _, err := r.db.Exec(`UPDATE blog_post SET published_at = NOW() WHERE id = $1 AND created_by = $2`, bp.ID, bp.CreatedBy) 89 | return err 90 | } 91 | 92 | func (r Repository) Unpublish(bp BlogPost) error { 93 | _, err := r.db.Exec(`UPDATE blog_post SET published_at = NULL WHERE id = $1 AND created_by = $2`, bp.ID, bp.CreatedBy) 94 | return err 95 | } 96 | -------------------------------------------------------------------------------- /internal/recruiter/repository.go: -------------------------------------------------------------------------------- 1 | package recruiter 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/golang-cafe/job-board/internal/job" 10 | "github.com/gosimple/slug" 11 | "github.com/segmentio/ksuid" 12 | ) 13 | 14 | type Repository struct { 15 | db *sql.DB 16 | } 17 | 18 | func NewRepository(db *sql.DB) *Repository { 19 | return &Repository{db} 20 | } 21 | 22 | func (r *Repository) RecruiterProfileByID(id string) (Recruiter, error) { 23 | row := r.db.QueryRow(`SELECT id, email, name, company_url, slug, created_at, updated_at FROM recruiter_profile WHERE id = $1`, id) 24 | obj := Recruiter{} 25 | var nullTime sql.NullTime 26 | err := row.Scan( 27 | &obj.ID, 28 | &obj.Email, 29 | &obj.Name, 30 | &obj.CompanyURL, 31 | &obj.Slug, 32 | &obj.CreatedAt, 33 | &nullTime, 34 | ) 35 | if nullTime.Valid { 36 | obj.UpdatedAt = nullTime.Time 37 | } 38 | if err != nil { 39 | return obj, err 40 | } 41 | 42 | return obj, nil 43 | } 44 | 45 | func (r *Repository) RecruiterProfilePlanExpiration(email string) (time.Time, error) { 46 | var expTime time.Time 47 | row := r.db.QueryRow(`SELECT plan_expired_at FROM recruiter_profile WHERE email = $1`, email) 48 | if err := row.Scan(&expTime); err != nil { 49 | return expTime, err 50 | } 51 | return expTime, nil 52 | } 53 | 54 | func (r *Repository) UpdateRecruiterPlanExpiration(email string, expiredAt time.Time) error { 55 | _, err := r.db.Exec(`UPDATE recruiter_profile SET plan_expired_at = $1 WHERE email = $2`, expiredAt, email) 56 | return err 57 | } 58 | 59 | func (r *Repository) ActivateRecruiterProfile(email string) error { 60 | _, err := r.db.Exec(`UPDATE recruiter_profile SET updated_at = NOW() WHERE email = $1`, email) 61 | return err 62 | } 63 | 64 | func (r *Repository) RecruiterProfileByEmail(email string) (Recruiter, error) { 65 | row := r.db.QueryRow(`SELECT id, email, name, company_url, slug, created_at, updated_at, plan_expired_at FROM recruiter_profile WHERE email = $1`, email) 66 | obj := Recruiter{} 67 | var nullTime sql.NullTime 68 | err := row.Scan( 69 | &obj.ID, 70 | &obj.Email, 71 | &obj.Name, 72 | &obj.CompanyURL, 73 | &obj.Slug, 74 | &obj.CreatedAt, 75 | &nullTime, 76 | &obj.PlanExpiredAt, 77 | ) 78 | if nullTime.Valid { 79 | obj.UpdatedAt = nullTime.Time 80 | } else { 81 | obj.UpdatedAt = obj.CreatedAt 82 | } 83 | if err == sql.ErrNoRows { 84 | return obj, nil 85 | } 86 | if err != nil { 87 | return obj, err 88 | } 89 | 90 | return obj, nil 91 | } 92 | 93 | func (r *Repository) SaveRecruiterProfile(dev Recruiter) error { 94 | dev.Slug = slug.Make(fmt.Sprintf("%s %d", dev.Name, time.Now().UTC().Unix())) 95 | _, err := r.db.Exec( 96 | `INSERT INTO recruiter_profile (id, email, name, company_url, slug, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, 97 | dev.ID, 98 | dev.Email, 99 | dev.Name, 100 | dev.CompanyURL, 101 | dev.Slug, 102 | ) 103 | return err 104 | } 105 | 106 | func (r *Repository) CreateRecruiterProfileBasedOnLastJobPosted(email string, jobRepo *job.Repository) error { 107 | k, err := ksuid.NewRandom() 108 | if err != nil { 109 | return err 110 | } 111 | 112 | job, err := jobRepo.LastJobPostedByEmail(email) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | username := strings.Split(email, "@")[0] 118 | rec := Recruiter{ 119 | ID: k.String(), 120 | Email: strings.ToLower(email), 121 | Name: username, 122 | CompanyURL: job.CompanyURL, 123 | } 124 | return r.SaveRecruiterProfile(rec) 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Job Board 2 | 3 | This is the first Go job board with no recruiters & clear salary ranges. 4 | 5 | - Clear salary range in each job description 6 | - No third party recruiters (apply directly to companies) 7 | - All jobs are manually vetted and reviewed 8 | - Browse salary trends by region 9 | - Browse companies hiring Go engineers and using Go in production 10 | - Browse Go developers 11 | - Weekly Job Newsletter Digest 12 | - Open Source 13 | - Filter by minimum Salary 14 | - The site home page weights under 250kb ([188kb uncompressed](https://gtmetrix.com/reports/golang.cafe/FQEvpFuT/)) 15 | 16 | ### Tech Stack 17 | 18 | - [Go](https://golang.org) 19 | - [HTML](https://www.w3.org/html/) 20 | - [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS) 21 | - [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript) 22 | - [PostgreSQL](https://www.postgresql.org) 23 | - [Digital Ocean App Platform](https://www.digitalocean.com/products/app-platform/) 24 | - [Cloudflare](https://cloudflare.com) 25 | 26 | ### Local Development Mode - Setup Guide 27 | 28 | It's possible to setup a blank instance of this job board locally. This is a local development mode and it's different from the way the app runs in production. In this scenario the app runs on a minimal database containing mock data. The app has reduced functionality when ran in local development mode. It's not possible to send emails or connect to third party services, like Twitter, Telegram and FX APIs. It's still possible to run and test the app but with limited functionality. 29 | 30 | **Requirements** 31 | 32 | These are basic requirements with the respective versions have been tested to work locally on MacOS. The same should apply both on Linux and WSL/Windows. 33 | 34 | - Bash 3.2.x or higher 35 | - Docker 20.10.x or higher 36 | - Go 1.15.x or higher 37 | 38 | **Dependencies** 39 | 40 | - **PostgreSQL instance** mocked using local Docker container, with local schema and fixtures 41 | - **Sparkpost Mail** emails are sent using http requests through Sparkpost APIs. This is not enabled in local development mode. 42 | - **Telegram API** telegram updates are sent through the Job Board official Telegram channel. This is not enabled in local development mode. 43 | - **Twitter API** twitter updates are sent through the Job Board official page. This is not enabled in local development mode. 44 | - **FX API** a minimal set of Foreign Currency Exchange data is kept up-to-date to filter out salary ranges. This is not enabled in local development mode. 45 | 46 | **Setup Guide** 47 | 48 | The only thing that needs to be setup in order for the app to run is the PostgreSQL database instance. Please run the following command in order to setup your local database instance. 49 | 50 | ``` 51 | ./setup-database.sh 52 | ``` 53 | 54 | Once this command is successful you can now start the application 55 | 56 | ``` 57 | ./run-local-webserver.sh 58 | ``` 59 | 60 | **Test Cron-Jobs Locally** 61 | 62 | There are a few cron-jobs that are triggered on a schedule, these are scheduled externally via custom http calls to the website. You can see all available cron-jobs by searching for all routes that start with /x/task/. 63 | 64 | In order to test a cron-job you can just make the following http request 65 | 66 | ``` 67 | curl -v -H 'x-machine-token: ' -X POST http://localhost:9876/x/task/ 68 | ``` 69 | 70 | You can find the under your local environment variable configuration, in the `MACHINE_TOKEN` environment variable. 71 | 72 | ### Telegram Group OSS discussions 73 | 74 | https://t.me/+VloraT7W9yA1YTI8 75 | 76 | ### Feedback? 77 | 78 | Feel free to open an issue on GitHub 79 | 80 | ### License 81 | 82 | This source code is licensed under [BSD 3-Clause License](LICENSE.txt) 83 | -------------------------------------------------------------------------------- /internal/bookmark/repository.go: -------------------------------------------------------------------------------- 1 | package bookmark 2 | 3 | import ( 4 | "database/sql" 5 | "net/url" 6 | ) 7 | 8 | type Repository struct { 9 | db *sql.DB 10 | } 11 | 12 | func NewRepository(db *sql.DB) *Repository { 13 | return &Repository{db} 14 | } 15 | 16 | func (r *Repository) GetBookmarksForUser(userID string) ([]*Bookmark, error) { 17 | bookmarks := []*Bookmark{} 18 | var rows *sql.Rows 19 | rows, err := r.db.Query( 20 | `SELECT user_id, 21 | job_id, 22 | MIN(created_at) AS first_created_at, 23 | MIN(applied_at) AS first_applied_at, 24 | MAX(slug) AS slug, 25 | MAX(job_title) AS job_title, 26 | MAX(company) AS company, 27 | MAX(external_id) AS external_id, 28 | MAX(location) as location, 29 | MAX(salary_range) as salary_range, 30 | MAX(salary_period) as salary_period, 31 | MAX(created_at) as job_created_at, 32 | MAX(apply_token_entry) AS apply_token_entry 33 | FROM ( 34 | SELECT b.user_id, b.job_id, b.created_at, b.applied_at, j.slug, j.job_title, j.company, j.external_id, j.location, j.salary_range, j.salary_period, j.created_at as job_created_at, 0 as apply_token_entry 35 | FROM bookmark b 36 | LEFT JOIN job j ON j.id = b.job_id 37 | WHERE b.user_id = $1 38 | UNION 39 | SELECT u.id, a.job_id, a.created_at, a.created_at, j.slug, j.job_title, j.company, j.external_id, j.location, j.salary_range, j.salary_period, j.created_at as job_created_at, 1 as apply_token_entry 40 | FROM apply_token a 41 | LEFT JOIN job j ON j.id = a.job_id 42 | LEFT JOIN users u ON u.email = a.email 43 | WHERE u.id = $1 44 | ORDER BY created_at DESC 45 | ) AS subquery 46 | GROUP BY user_id, job_id 47 | ORDER BY first_created_at DESC;`, 48 | userID) 49 | if err != nil { 50 | return bookmarks, err 51 | } 52 | 53 | defer rows.Close() 54 | for rows.Next() { 55 | bookmark := &Bookmark{} 56 | err := rows.Scan( 57 | &bookmark.UserID, 58 | &bookmark.JobPostID, 59 | &bookmark.CreatedAt, 60 | &bookmark.AppliedAt, 61 | &bookmark.JobSlug, 62 | &bookmark.JobTitle, 63 | &bookmark.CompanyName, 64 | &bookmark.JobExternalID, 65 | &bookmark.JobLocation, 66 | &bookmark.JobSalaryRange, 67 | &bookmark.JobSalaryPeriod, 68 | &bookmark.JobCreatedAt, 69 | &bookmark.HasApplyRecord, 70 | ) 71 | if err != nil { 72 | return bookmarks, err 73 | } 74 | bookmark.JobTimeAgo = bookmark.JobCreatedAt.UTC().Format("January 2006") 75 | bookmark.CompanyURLEnc = url.PathEscape(bookmark.CompanyName) 76 | 77 | bookmarks = append(bookmarks, bookmark) 78 | } 79 | err = rows.Err() 80 | if err != nil { 81 | return bookmarks, err 82 | } 83 | return bookmarks, nil 84 | } 85 | 86 | // GetBookmarksByJobId can be used to quickly & efficiently check whether a job has previously been bookmarked by a user 87 | func (r *Repository) GetBookmarksByJobId(userID string) (map[int]*Bookmark, error) { 88 | bookmarksByJobId := make(map[int]*Bookmark) 89 | bookmarks, err := r.GetBookmarksForUser(userID) 90 | if err != nil { 91 | return bookmarksByJobId, err 92 | } 93 | 94 | for _, b := range bookmarks { 95 | bookmarksByJobId[b.JobPostID] = b 96 | } 97 | 98 | return bookmarksByJobId, nil 99 | } 100 | 101 | func (r *Repository) BookmarkJob(userID string, jobID int, setApplied bool) error { 102 | appliedAtExpr := "NULL" 103 | if setApplied { 104 | appliedAtExpr = "NOW()" 105 | } 106 | 107 | stmt := ` 108 | INSERT INTO bookmark (user_id, job_id, created_at, applied_at) 109 | VALUES ($1, $2, NOW(), ` + appliedAtExpr + `) 110 | ON CONFLICT (user_id, job_id) DO UPDATE 111 | SET applied_at = EXCLUDED.applied_at 112 | WHERE bookmark.applied_at IS NULL` 113 | _, err := r.db.Exec(stmt, userID, jobID) 114 | return err 115 | } 116 | 117 | func (r *Repository) RemoveBookmark(userID string, jobID int) error { 118 | _, err := r.db.Exec( 119 | `DELETE FROM bookmark WHERE user_id = $1 AND job_id = $2`, 120 | userID, 121 | jobID, 122 | ) 123 | return err 124 | } 125 | -------------------------------------------------------------------------------- /internal/user/repository.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "time" 7 | 8 | "github.com/dustin/go-humanize" 9 | "github.com/golang-cafe/job-board/internal/job" 10 | "github.com/golang-cafe/job-board/internal/recruiter" 11 | "github.com/segmentio/ksuid" 12 | ) 13 | 14 | type Repository struct { 15 | db *sql.DB 16 | } 17 | 18 | func NewRepository(db *sql.DB) *Repository { 19 | return &Repository{db} 20 | } 21 | 22 | func (r *Repository) SaveTokenSignOn(email, token, userType string) error { 23 | if _, err := r.db.Exec(`INSERT INTO user_sign_on_token (token, email, user_type, created_at) VALUES ($1, $2, $3, NOW())`, token, email, userType); err != nil { 24 | return err 25 | } 26 | return nil 27 | } 28 | 29 | // GetOrCreateUserFromToken creates or get existing user given a token 30 | // returns the user struct, whether the user existed already and an error 31 | func (r *Repository) GetOrCreateUserFromToken(token string) (User, bool, error) { 32 | u := User{} 33 | row := r.db.QueryRow(`SELECT t.token, t.email, u.id, u.email, u.created_at, t.user_type FROM user_sign_on_token t LEFT JOIN users u ON t.email = u.email WHERE t.token = $1`, token) 34 | var tokenRes, id, email, tokenEmail, userType sql.NullString 35 | var createdAt sql.NullTime 36 | if err := row.Scan(&tokenRes, &tokenEmail, &id, &email, &createdAt, &userType); err != nil { 37 | return u, false, err 38 | } 39 | if !tokenRes.Valid { 40 | return u, false, errors.New("token not found") 41 | } 42 | if !email.Valid { 43 | // user not found create new one 44 | userID, err := ksuid.NewRandom() 45 | if err != nil { 46 | return u, false, err 47 | } 48 | u.ID = userID.String() 49 | u.Email = tokenEmail.String 50 | u.CreatedAt = time.Now() 51 | u.Type = userType.String 52 | u.CreatedAtHumanised = humanize.Time(u.CreatedAt.UTC()) 53 | if _, err := r.db.Exec(`INSERT INTO users (id, email, created_at, user_type) VALUES ($1, $2, $3, $4)`, u.ID, u.Email, u.CreatedAt, u.Type); err != nil { 54 | return User{}, false, err 55 | } 56 | 57 | return u, false, nil 58 | } 59 | u.ID = id.String 60 | u.Email = email.String 61 | u.CreatedAt = createdAt.Time 62 | u.Type = userType.String 63 | u.CreatedAtHumanised = humanize.Time(u.CreatedAt.UTC()) 64 | 65 | return u, true, nil 66 | } 67 | 68 | func (r *Repository) DeleteUserByEmail(email string) error { 69 | _, err := r.db.Exec(`DELETE FROM users WHERE email = $1`, email) 70 | return err 71 | } 72 | 73 | // DeleteExpiredUserSignOnTokens deletes user_sign_on_tokens older than 1 week 74 | func (r *Repository) DeleteExpiredUserSignOnTokens() error { 75 | _, err := r.db.Exec(`DELETE FROM user_sign_on_token WHERE created_at < NOW() - INTERVAL '7 DAYS'`) 76 | return err 77 | } 78 | 79 | func (r *Repository) GetUserTypeByEmail(email string) (string, error) { 80 | var userType string 81 | row := r.db.QueryRow(`SELECT user_type FROM users WHERE email = $1`, email) 82 | err := row.Scan(&userType) 83 | if err == sql.ErrNoRows { 84 | // check if user is unverified recruiter/developer 85 | row = r.db.QueryRow(`SELECT 'recruiter' FROM recruiter_profile WHERE email = $1`, email) 86 | err = row.Scan(&userType) 87 | if err == nil { 88 | return userType, nil 89 | } 90 | row = r.db.QueryRow(`SELECT 'developer' FROM developer_profile WHERE email = $1`, email) 91 | err = row.Scan(&userType) 92 | if err == nil { 93 | return userType, nil 94 | } 95 | return userType, err 96 | } 97 | if err != nil { 98 | return userType, err 99 | } 100 | return userType, nil 101 | } 102 | 103 | // CreateUserWithEmail creates a new user with given email and user_type 104 | // returns the user struct, and an error 105 | func (r *Repository) CreateUserWithEmail(email, userType string) (User, error) { 106 | u := User{} 107 | 108 | userID, err := ksuid.NewRandom() 109 | if err != nil { 110 | return u, err 111 | } 112 | 113 | u.ID = userID.String() 114 | u.Email = email 115 | u.CreatedAt = time.Now() 116 | u.Type = userType 117 | u.CreatedAtHumanised = humanize.Time(u.CreatedAt.UTC()) 118 | if _, err := r.db.Exec(`INSERT INTO users (id, email, created_at, user_type) VALUES ($1, $2, $3, $4)`, u.ID, u.Email, u.CreatedAt, u.Type); err != nil { 119 | return User{}, err 120 | } 121 | 122 | return u, nil 123 | } 124 | 125 | // GetUserTypeByEmailOrCreateUserIfRecruiter seeks a user in the users table or creates one and recruiter profile if the user has a job posting 126 | // returns user struct and an error 127 | func (r *Repository) GetUserTypeByEmailOrCreateUserIfRecruiter(email string, jobRepo *job.Repository, recRepo *recruiter.Repository) (string, error) { 128 | u, err := r.GetUserTypeByEmail(email) 129 | if err == nil { 130 | return u, nil 131 | } 132 | 133 | jobsCountByEmail, err := jobRepo.JobsCountByEmail(email) 134 | if err != nil { 135 | return "", err 136 | } 137 | if jobsCountByEmail > 0 { 138 | user, err := r.CreateUserWithEmail(email, "recruiter") 139 | if err == nil { 140 | err = recRepo.CreateRecruiterProfileBasedOnLastJobPosted(user.Email, jobRepo) 141 | if err != nil { 142 | return "", err 143 | } 144 | return user.Type, nil 145 | } else { 146 | return "", err 147 | } 148 | } 149 | 150 | return "", errors.New("not found") 151 | } 152 | -------------------------------------------------------------------------------- /internal/payment/payment.go: -------------------------------------------------------------------------------- 1 | package payment 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/golang-cafe/job-board/internal/job" 8 | 9 | stripe "github.com/stripe/stripe-go" 10 | session "github.com/stripe/stripe-go/checkout/session" 11 | webhook "github.com/stripe/stripe-go/webhook" 12 | 13 | "strings" 14 | ) 15 | 16 | type Repository struct { 17 | stripeKey string 18 | siteName string 19 | siteHost string 20 | siteProtocol string 21 | } 22 | 23 | func NewRepository(stripeKey, siteName, siteHost, siteProtocol string) *Repository { 24 | return &Repository{ 25 | stripeKey: stripeKey, 26 | siteName: siteName, 27 | siteHost: siteHost, 28 | siteProtocol: siteProtocol, 29 | } 30 | } 31 | 32 | func PlanTypeAndDurationToAmount(planType string, planDuration int64, p1, p2, p3 int64) int64 { 33 | switch planType { 34 | case job.JobPlanTypeBasic: 35 | return p1*planDuration 36 | case job.JobPlanTypePro: 37 | return p2*planDuration 38 | case job.JobPlanTypePlatinum: 39 | return p3*planDuration 40 | } 41 | 42 | return 0 43 | } 44 | 45 | func PlanTypeAndDurationToDescription(planType string, planDuration int64) string { 46 | switch planType { 47 | case job.JobPlanTypeBasic: 48 | return fmt.Sprintf("Basic Plan x %d months", planDuration) 49 | case job.JobPlanTypePro: 50 | return fmt.Sprintf("Pro Plan x %d months", planDuration) 51 | case job.JobPlanTypePlatinum: 52 | return fmt.Sprintf("Platinum Plan x %d months", planDuration) 53 | } 54 | 55 | return "" 56 | } 57 | 58 | func (r Repository) CreateGenericSession(email, currency string, amount int) (*stripe.CheckoutSession, error) { 59 | stripe.Key = r.stripeKey 60 | params := &stripe.CheckoutSessionParams{ 61 | BillingAddressCollection: stripe.String("required"), 62 | PaymentMethodTypes: stripe.StringSlice([]string{ 63 | "card", 64 | }), 65 | LineItems: []*stripe.CheckoutSessionLineItemParams{ 66 | { 67 | Name: stripe.String(fmt.Sprintf("%s Sponsored Ad", r.siteName)), 68 | Amount: stripe.Int64(int64(amount)), 69 | Currency: stripe.String(currency), 70 | Quantity: stripe.Int64(1), 71 | }, 72 | }, 73 | SuccessURL: stripe.String(fmt.Sprintf("https://%s/x/j/p/1", r.siteHost)), 74 | CancelURL: stripe.String(fmt.Sprintf("https://%s/x/j/p/0", r.siteHost)), 75 | CustomerEmail: &email, 76 | } 77 | 78 | session, err := session.New(params) 79 | if err != nil { 80 | return nil, fmt.Errorf("unable to create stripe session: %+v", err) 81 | } 82 | 83 | return session, nil 84 | } 85 | 86 | func (r Repository) CreateJobAdSession(jobRq *job.JobRq, jobToken string, monthlyAmount int64, numMonths int64) (*stripe.CheckoutSession, error) { 87 | stripe.Key = r.stripeKey 88 | params := &stripe.CheckoutSessionParams{ 89 | BillingAddressCollection: stripe.String("required"), 90 | PaymentMethodTypes: stripe.StringSlice([]string{ 91 | "card", 92 | }), 93 | LineItems: []*stripe.CheckoutSessionLineItemParams{ 94 | { 95 | Name: stripe.String(fmt.Sprintf("%s Job Ad %s Plan", r.siteName, strings.Title(jobRq.PlanType))), 96 | Amount: stripe.Int64(monthlyAmount), 97 | Currency: stripe.String("usd"), 98 | Quantity: stripe.Int64(numMonths), 99 | }, 100 | }, 101 | SuccessURL: stripe.String(fmt.Sprintf("%s%s/edit/%s?payment=1&callback=1", r.siteProtocol, r.siteHost, jobToken)), 102 | CancelURL: stripe.String(fmt.Sprintf("%s%s/edit/%s?payment=0&callback=1", r.siteProtocol, r.siteHost, jobToken)), 103 | CustomerEmail: &jobRq.Email, 104 | } 105 | 106 | session, err := session.New(params) 107 | if err != nil { 108 | return nil, fmt.Errorf("unable to create stripe session: %+v", err) 109 | } 110 | 111 | return session, nil 112 | } 113 | 114 | func (r Repository) CreateDevDirectorySession(email string, userID string, monthlyAmount int64, numMonths int64, isRenew bool) (*stripe.CheckoutSession, error) { 115 | stripe.Key = r.stripeKey 116 | successURL := stripe.String(fmt.Sprintf("%s%s/auth?payment=1&email=%s", r.siteProtocol, r.siteHost, email)) 117 | cancelURL := stripe.String(fmt.Sprintf("%s%s/auth?payment=0&email=%s", r.siteProtocol, r.siteHost, email)) 118 | if isRenew { 119 | successURL = stripe.String(fmt.Sprintf("%s%s/profile/home?payment=1", r.siteProtocol, r.siteHost)) 120 | cancelURL = stripe.String(fmt.Sprintf("%s%s/profile/home?payment=0", r.siteProtocol, r.siteHost)) 121 | } 122 | params := &stripe.CheckoutSessionParams{ 123 | BillingAddressCollection: stripe.String("required"), 124 | PaymentMethodTypes: stripe.StringSlice([]string{ 125 | "card", 126 | }), 127 | LineItems: []*stripe.CheckoutSessionLineItemParams{ 128 | { 129 | Name: stripe.String(fmt.Sprintf("%s Developer Directory %d Months Plan", r.siteName, numMonths)), 130 | Amount: stripe.Int64(monthlyAmount), 131 | Currency: stripe.String("usd"), 132 | Quantity: stripe.Int64(numMonths), 133 | }, 134 | }, 135 | SuccessURL: successURL, 136 | CancelURL: cancelURL, 137 | CustomerEmail: &email, 138 | } 139 | 140 | session, err := session.New(params) 141 | if err != nil { 142 | return nil, fmt.Errorf("unable to create stripe session: %+v", err) 143 | } 144 | 145 | return session, nil 146 | } 147 | 148 | func HandleCheckoutSessionComplete(body []byte, endpointSecret, stripeSig string) (*stripe.CheckoutSession, error) { 149 | event, err := webhook.ConstructEvent(body, stripeSig, endpointSecret) 150 | if err != nil { 151 | return nil, fmt.Errorf("error verifying webhook signature: %v\n", err) 152 | } 153 | // Handle the checkout.session.completed event 154 | if event.Type == "checkout.session.completed" { 155 | var session stripe.CheckoutSession 156 | err := json.Unmarshal(event.Data.Raw, &session) 157 | if err != nil { 158 | return nil, fmt.Errorf("error parsing webhook JSON: %v\n", err) 159 | } 160 | return &session, nil 161 | } 162 | return nil, nil 163 | } 164 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: true 3 | 4 | linters: 5 | enable: 6 | - errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases [fast: true, auto-fix: false] 7 | - govet # (vet, vetshadow): Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string [fast: true, auto-fix: false] 8 | - errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases [fast: true, auto-fix: false] 9 | - staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks [fast: false, auto-fix: false] 10 | - unused # Checks Go code for unused constants, variables, functions and types [fast: false, auto-fix: false] 11 | - gosimple # Linter for Go source code that specializes in simplifying a code [fast: false, auto-fix: false] 12 | - structcheck # Finds an unused struct fields [fast: true, auto-fix: false] 13 | - varcheck # Finds unused global variables and constants [fast: true, auto-fix: false] 14 | - ineffassign # Detects when assignments to existing variables are not used [fast: true, auto-fix: false] 15 | - deadcode # Finds unused code [fast: true, auto-fix: false] 16 | - typecheck # Like the front-end of a Go compiler, parses and type-checks Go code [fast: true, auto-fix: false] 17 | - golint # Golint differs from gofmt. Gofmt reformats Go source code, whereas golint prints out style mistakes [fast: true, auto-fix: false] 18 | - gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification [fast: true, auto-fix: true] 19 | 20 | # - gosec # Inspects source code for security problems [fast: true, auto-fix: false] 21 | - interfacer # Linter that suggests narrower interface types [fast: false, auto-fix: false] 22 | - unconvert # Remove unnecessary type conversions [fast: true, auto-fix: false] 23 | - dupl # Tool for code clone detection [fast: true, auto-fix: false] 24 | - goconst # Finds repeated strings that could be replaced by a constant [fast: true, auto-fix: false] 25 | 26 | - gocyclo # Computes and checks the cyclomatic complexity of functions [fast: true, auto-fix: false] 27 | - lll # Reports long lines [fast: true, auto-fix: false] 28 | - unparam # Reports unused function parameters [fast: false, auto-fix: false] 29 | - nakedret # Finds naked returns in functions greater than a specified function length [fast: true, auto-fix: false] 30 | - megacheck 31 | 32 | - prealloc # Finds slice declarations that could potentially be preallocated [fast: true, auto-fix: false] 33 | # - scopelint # Scopelint checks for unpinned variables in go programs [fast: true, auto-fix: false] 34 | - gocritic # The most opinionated Go source code linter [fast: true, auto-fix: false] 35 | # - gochecknoinits # Checks that no init functions are present in Go code [fast: true, auto-fix: false] 36 | - gochecknoglobals # Checks that no globals are present in Go code [fast: true, auto-fix: false] 37 | 38 | enable-all: false 39 | disable-all: true 40 | fast: false 41 | 42 | # all available settings of specific linters 43 | linters-settings: 44 | govet: 45 | # report about shadowed variables 46 | check-shadowing: true 47 | errcheck: 48 | # report about not checking of errors in type assetions: `a := b.(MyStruct)`; 49 | # default is false: such cases aren't reported by default. 50 | check-type-assertions: true 51 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 52 | # default is false: such cases aren't reported by default. 53 | check-blank: true 54 | staticcheck: 55 | fast: true 56 | unused: 57 | fast: true 58 | gosimple: 59 | fast: true 60 | golint: 61 | # minimal confidence for issues, default is 0.8 62 | min-confidence: 1.0 63 | gofmt: 64 | # simplify code: gofmt with `-s` option, true by default 65 | simplify: true 66 | misspell: 67 | # Correct spellings using locale preferences for US or UK. 68 | # Default is to use a neutral variety of English. 69 | # Setting locale to US will correct the British spelling of 'colour' to 'color'. 70 | locale: UK 71 | gocyclo: 72 | min-complexity: 13 73 | interfacer: 74 | fast: true 75 | lll: 76 | # max line length, lines longer will be reported. Default is 120. 77 | # '\t' is counted as 1 character by default, and can be changed with the tab-width option 78 | line-length: 1024 79 | fast: true 80 | unparam: 81 | fast: true 82 | 83 | 84 | # see: https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml#L47 85 | # 86 | # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": 87 | # If invoked with -mod=readonly, the go command is disallowed from the implicit 88 | # automatic updating of go.mod described above. Instead, it fails when any changes 89 | # to go.mod are needed. This setting is most useful to check that go.mod does 90 | # not need updates, such as in a continuous integration and testing system. 91 | # If invoked with -mod=vendor, the go command assumes that the vendor 92 | # directory holds the correct copies of dependencies and ignores 93 | # the dependency descriptions in go.mod. 94 | modules-download-mode: readonly 95 | 96 | # output configuration options 97 | output: 98 | # colored-line-number|line-number|json|tab|checkstyle, default is "colored-line-number" 99 | format: colored-line-number 100 | 101 | # print lines of code with issue, default is true 102 | print-issued-lines: false 103 | 104 | # print linter name in the end of issue text, default is true 105 | print-linter-name: true 106 | 107 | issues: 108 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 109 | max-per-linter: 0 110 | 111 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 112 | max-same-issues: 0 113 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/golang-cafe/job-board/internal/gzip" 11 | 12 | jwt "github.com/dgrijalva/jwt-go" 13 | "github.com/gorilla/sessions" 14 | "github.com/rs/zerolog" 15 | ) 16 | 17 | func HTTPSMiddleware(next http.Handler, env string) http.Handler { 18 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | if env != "dev" && r.Header.Get("X-Forwarded-Proto") != "https" { 20 | target := "https://" + r.Host + r.URL.Path 21 | http.Redirect(w, r, target, http.StatusMovedPermanently) 22 | } 23 | 24 | next.ServeHTTP(w, r) 25 | }) 26 | } 27 | 28 | func LoggingMiddleware(next http.Handler) http.Handler { 29 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 | logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}). 31 | With(). 32 | Timestamp(). 33 | Logger() 34 | logger.Info(). 35 | Str("Host", r.Host). 36 | Str("method", r.Method). 37 | Stringer("url", r.URL). 38 | Str("x-forwarded-for", r.Header.Get("x-forwarded-for")). 39 | Msg("req") 40 | next.ServeHTTP(w, r) 41 | }) 42 | } 43 | 44 | func HeadersMiddleware(next http.Handler, env string) http.Handler { 45 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | if env != "dev" { 47 | // filter out HeadlessChrome user agent 48 | if strings.Contains(r.Header.Get("User-Agent"), "HeadlessChrome") { 49 | w.WriteHeader(http.StatusTeapot) 50 | return 51 | } 52 | w.Header().Set("Content-Security-Policy", "upgrade-insecure-requests") 53 | w.Header().Set("X-Frame-Options", "deny") 54 | w.Header().Set("X-XSS-Protection", "1; mode=block") 55 | w.Header().Set("X-Content-Type-Options", "nosniff") 56 | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") 57 | w.Header().Set("Referrer-Policy", "origin") 58 | } 59 | next.ServeHTTP(w, r) 60 | }) 61 | } 62 | 63 | func GzipMiddleware(next http.Handler) http.Handler { 64 | return gzip.GzipHandler(next) 65 | } 66 | 67 | type UserJWT struct { 68 | IsAdmin bool `json:"is_admin"` 69 | IsRecruiter bool `json:"is_recruiter"` 70 | IsDeveloper bool `json:"is_developer"` 71 | UserID string `json:"user_id"` 72 | Email string `json:"email"` 73 | Type string `json:"type"` 74 | CreatedAt time.Time `json:"created_at"` 75 | jwt.StandardClaims 76 | } 77 | 78 | func AdminAuthenticatedMiddleware(sessionStore *sessions.CookieStore, jwtKey []byte, next http.HandlerFunc) http.HandlerFunc { 79 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 80 | sess, err := sessionStore.Get(r, "____gc") 81 | if err != nil { 82 | http.Redirect(w, r, "/auth", http.StatusUnauthorized) 83 | return 84 | } 85 | tk, ok := sess.Values["jwt"].(string) 86 | if !ok { 87 | http.Redirect(w, r, "/auth", http.StatusUnauthorized) 88 | return 89 | } 90 | token, err := jwt.ParseWithClaims(tk, &UserJWT{}, func(token *jwt.Token) (interface{}, error) { 91 | return jwtKey, nil 92 | }) 93 | if !token.Valid { 94 | http.Redirect(w, r, "/auth", http.StatusUnauthorized) 95 | return 96 | } 97 | claims, ok := token.Claims.(*UserJWT) 98 | if !ok { 99 | http.Redirect(w, r, "/auth", http.StatusUnauthorized) 100 | return 101 | } 102 | if !claims.IsAdmin { 103 | http.Redirect(w, r, "/auth", http.StatusUnauthorized) 104 | return 105 | } 106 | next(w, r) 107 | }) 108 | } 109 | 110 | func MachineAuthenticatedMiddleware(machineToken string, next http.HandlerFunc) http.HandlerFunc { 111 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 112 | token := r.Header.Get("x-machine-token") 113 | if token != machineToken { 114 | w.WriteHeader(http.StatusUnauthorized) 115 | return 116 | } 117 | next(w, r) 118 | }) 119 | } 120 | 121 | func UserAuthenticatedMiddleware(sessionStore *sessions.CookieStore, jwtKey []byte, next http.HandlerFunc) http.HandlerFunc { 122 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 123 | sess, err := sessionStore.Get(r, "____gc") 124 | if err != nil { 125 | http.Redirect(w, r, "/auth", http.StatusUnauthorized) 126 | return 127 | } 128 | tk, ok := sess.Values["jwt"].(string) 129 | if !ok { 130 | http.Redirect(w, r, "/auth", http.StatusUnauthorized) 131 | return 132 | } 133 | token, err := jwt.ParseWithClaims(tk, &UserJWT{}, func(token *jwt.Token) (interface{}, error) { 134 | return jwtKey, nil 135 | }) 136 | if !token.Valid { 137 | http.Redirect(w, r, "/auth", http.StatusUnauthorized) 138 | return 139 | } 140 | claims, ok := token.Claims.(*UserJWT) 141 | if !ok || claims.Email == "" { 142 | http.Redirect(w, r, "/auth", http.StatusUnauthorized) 143 | return 144 | } 145 | next(w, r) 146 | }) 147 | } 148 | 149 | func GetUserFromJWT(r *http.Request, sessionStore *sessions.CookieStore, jwtKey []byte) (*UserJWT, error) { 150 | sess, err := sessionStore.Get(r, "____gc") 151 | if err != nil { 152 | return nil, errors.New("could not find cookie") 153 | } 154 | tk, ok := sess.Values["jwt"].(string) 155 | if !ok { 156 | return nil, errors.New("could not find jwt in session") 157 | } 158 | token, err := jwt.ParseWithClaims(tk, &UserJWT{}, func(token *jwt.Token) (interface{}, error) { 159 | return jwtKey, nil 160 | }) 161 | if !token.Valid { 162 | return nil, errors.New("token is expired") 163 | } 164 | claims, ok := token.Claims.(*UserJWT) 165 | if !ok { 166 | return nil, errors.New("could not convert jwt claims to UserJWT") 167 | } 168 | return claims, nil 169 | } 170 | 171 | func IsSignedOn(r *http.Request, sessionStore *sessions.CookieStore, jwtKey []byte) bool { 172 | sess, err := sessionStore.Get(r, "____gc") 173 | if err != nil { 174 | return false 175 | } 176 | tk, ok := sess.Values["jwt"].(string) 177 | if !ok { 178 | return false 179 | } 180 | token, err := jwt.ParseWithClaims(tk, &UserJWT{}, func(token *jwt.Token) (interface{}, error) { 181 | return jwtKey, nil 182 | }) 183 | if !token.Valid { 184 | return false 185 | } 186 | if !ok { 187 | return false 188 | } 189 | return true 190 | } 191 | -------------------------------------------------------------------------------- /internal/job/model.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/lib/pq" 7 | ) 8 | 9 | const ( 10 | jobEventPageView = "page_view" 11 | jobEventClickout = "clickout" 12 | 13 | SearchTypeJob = "job" 14 | SearchTypeSalary = "salary" 15 | ) 16 | 17 | const ( 18 | JobAdBasic = iota 19 | JobAdSponsoredBackground 20 | JobAdSponsoredPinnedFor30Days 21 | JobAdSponsoredPinnedFor7Days 22 | JobAdWithCompanyLogo 23 | JobAdSponsoredPinnedFor60Days 24 | JobAdSponsoredPinnedFor90Days 25 | ) 26 | 27 | type Job struct { 28 | CreatedAt int64 29 | JobTitle string 30 | Company string 31 | SalaryMin string 32 | SalaryMax string 33 | SalaryCurrency string 34 | SalaryPeriod string 35 | SalaryRange string 36 | Location string 37 | Description string 38 | Perks string 39 | InterviewProcess string 40 | HowToApply string 41 | Email string 42 | Expired bool 43 | LastWeekClickouts int 44 | PlanType string 45 | PlanDuration int 46 | NewsletterEligibilityExpiredAt time.Time 47 | BlogEligibilityExpiredAt time.Time 48 | SocialMediaEligibilityExpiredAt time.Time 49 | FrontPageEligibilityExpiredAt time.Time 50 | CompanyPageEligibilityExpiredAt time.Time 51 | PlanExpiredAt time.Time 52 | } 53 | 54 | type JobRq struct { 55 | JobTitle string `json:"job_title"` 56 | Location string `json:"job_location"` 57 | Company string `json:"company_name"` 58 | CompanyURL string `json:"company_url"` 59 | SalaryMin string `json:"salary_min"` 60 | SalaryMax string `json:"salary_max"` 61 | SalaryCurrency string `json:"salary_currency"` 62 | Description string `json:"job_description"` 63 | HowToApply string `json:"how_to_apply"` 64 | Perks string `json:"perks"` 65 | InterviewProcess string `json:"interview_process,omitempty"` 66 | Email string `json:"company_email"` 67 | StripeToken string `json:"stripe_token,omitempty"` 68 | PlanType string `json:"plan_type"` 69 | PlanDurationStr string `json:"plan_duration"` 70 | PlanDuration int 71 | CurrencyCode string `json:"currency_code"` 72 | CompanyIconID string `json:"company_icon_id,omitempty"` 73 | SalaryCurrencyISO string `json:"salary_currency_iso"` 74 | VisaSponsorship bool `json:"visa_sponsorship,omitempty"` 75 | } 76 | 77 | const ( 78 | JobPlanTypeBasic = "basic" 79 | JobPlanTypePro = "pro" 80 | JobPlanTypePlatinum = "platinum" 81 | ) 82 | 83 | type JobRqUpsell struct { 84 | Token string `json:"token"` 85 | Email string `json:"email"` 86 | StripeToken string `json:"stripe_token,omitempty"` 87 | PlanType string `json:"plan_type"` 88 | PlanDuration int 89 | PlanDurationStr string `json:"plan_duration"` 90 | } 91 | 92 | type JobRqUpdate struct { 93 | JobTitle string `json:"job_title"` 94 | Location string `json:"job_location"` 95 | Company string `json:"company_name"` 96 | CompanyURL string `json:"company_url"` 97 | SalaryMin string `json:"salary_min"` 98 | SalaryMax string `json:"salary_max"` 99 | SalaryCurrency string `json:"salary_currency"` 100 | Description string `json:"job_description"` 101 | HowToApply string `json:"how_to_apply"` 102 | Perks string `json:"perks"` 103 | InterviewProcess string `json:"interview_process"` 104 | Email string `json:"company_email"` 105 | Token string `json:"token"` 106 | CompanyIconID string `json:"company_icon_id,omitempty"` 107 | SalaryPeriod string `json:"salary_period"` 108 | } 109 | 110 | type JobPost struct { 111 | ID int 112 | CreatedAt int64 113 | TimeAgo string 114 | JobTitle string 115 | Company string 116 | CompanyURL string 117 | SalaryRange string 118 | Location string 119 | JobDescription string 120 | Perks string 121 | InterviewProcess string 122 | HowToApply string 123 | Slug string 124 | SalaryCurrency string 125 | SalaryMin int64 126 | SalaryMax int64 127 | CompanyIconID string 128 | ExternalID string 129 | EditToken string 130 | IsQuickApply bool 131 | ApprovedAt *time.Time 132 | CompanyEmail string 133 | SalaryPeriod string 134 | CompanyURLEnc string 135 | Expired bool 136 | LastWeekClickouts int 137 | PlanType string 138 | PlanDuration int 139 | NewsletterEligibilityExpiredAt time.Time 140 | BlogEligibilityExpiredAt time.Time 141 | SocialMediaEligibilityExpiredAt time.Time 142 | FrontPageEligibilityExpiredAt time.Time 143 | CompanyPageEligibilityExpiredAt time.Time 144 | PlanExpiredAt time.Time 145 | JobDescriptionHTML interface{} 146 | InterviewProcessHTML interface{} 147 | PerksHTML interface{} 148 | } 149 | 150 | type JobPostForEdit struct { 151 | ID int 152 | JobTitle, Company, CompanyEmail, CompanyURL, Location string 153 | SalaryMin, SalaryMax int 154 | SalaryCurrency, JobDescription, Perks, InterviewProcess, HowToApply, Slug string 155 | CreatedAt time.Time 156 | ApprovedAt pq.NullTime 157 | CompanyIconID string 158 | ExternalID string 159 | SalaryPeriod string 160 | PlanType string 161 | PlanDuration int 162 | NewsletterEligibilityExpiredAt time.Time 163 | BlogEligibilityExpiredAt time.Time 164 | SocialMediaEligibilityExpiredAt time.Time 165 | FrontPageEligibilityExpiredAt time.Time 166 | CompanyPageEligibilityExpiredAt time.Time 167 | PlanExpiredAt time.Time 168 | } 169 | 170 | type JobStat struct { 171 | Date string `json:"date"` 172 | Clickouts int `json:"clickouts"` 173 | PageViews int `json:"pageviews"` 174 | } 175 | 176 | type JobApplyURL struct { 177 | ID int 178 | URL string 179 | } 180 | 181 | type Applicant struct { 182 | Token string 183 | Cv []byte 184 | Email string 185 | CreatedAt time.Time 186 | ConfirmedAt pq.NullTime 187 | CvSize int 188 | } 189 | -------------------------------------------------------------------------------- /internal/company/repository.go: -------------------------------------------------------------------------------- 1 | package company 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | const ( 9 | companyEventPageView = "company_page_view" 10 | ) 11 | 12 | type Repository struct { 13 | db *sql.DB 14 | } 15 | 16 | func NewRepository(db *sql.DB) *Repository { 17 | return &Repository{db} 18 | } 19 | 20 | // smart group by to map lower/upper case to same map entry with many entries and pickup the upper case one 21 | // smart group by to find typos 22 | func (r *Repository) InferCompaniesFromJobs(since time.Time) ([]Company, error) { 23 | stmt := `SELECT trim(from company), 24 | max(company_url) AS company_url, 25 | max(location) AS locations, 26 | max(company_icon_image_id) AS company_icon_id, 27 | max(created_at) AS last_job_created_at, 28 | count(id) AS job_count, 29 | count(approved_at IS NOT NULL) AS live_jobs_count, 30 | max(company_page_eligibility_expired_at) AS company_page_eligibility_expired_at 31 | FROM job 32 | WHERE company_icon_image_id IS NOT NULL 33 | AND created_at > $1 34 | AND approved_at IS NOT NULL 35 | GROUP BY trim(FROM company) 36 | ORDER BY trim(FROM company)` 37 | rows, err := r.db.Query(stmt, since) 38 | res := make([]Company, 0) 39 | if err == sql.ErrNoRows { 40 | return res, nil 41 | } 42 | if err != nil { 43 | return res, err 44 | } 45 | for rows.Next() { 46 | var c Company 47 | if err := rows.Scan( 48 | &c.Name, 49 | &c.URL, 50 | &c.Locations, 51 | &c.IconImageID, 52 | &c.LastJobCreatedAt, 53 | &c.TotalJobCount, 54 | &c.ActiveJobCount, 55 | &c.CompanyPageEligibilityExpiredAt, 56 | ); err != nil { 57 | return res, err 58 | } 59 | res = append(res, c) 60 | } 61 | 62 | return res, nil 63 | } 64 | 65 | func (r *Repository) SaveCompany(c Company) error { 66 | if c.CompanyPageEligibilityExpiredAt.Before(time.Now()) { 67 | c.CompanyPageEligibilityExpiredAt = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) 68 | } 69 | var err error 70 | stmt := `INSERT INTO company (id, name, url, locations, icon_image_id, last_job_created_at, total_job_count, active_job_count, description, slug, twitter, linkedin, github, company_page_eligibility_expired_at) 71 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) 72 | ON CONFLICT (name) 73 | DO UPDATE SET url = $3, locations = $4, icon_image_id = $5, last_job_created_at = $6, total_job_count = $7, active_job_count = $8, slug = $10, company_page_eligibility_expired_at = $14` 74 | 75 | _, err = r.db.Exec( 76 | stmt, 77 | c.ID, 78 | c.Name, 79 | c.URL, 80 | c.Locations, 81 | c.IconImageID, 82 | c.LastJobCreatedAt, 83 | c.TotalJobCount, 84 | c.ActiveJobCount, 85 | c.Description, 86 | c.Slug, 87 | c.Twitter, 88 | c.Linkedin, 89 | c.Github, 90 | c.CompanyPageEligibilityExpiredAt, 91 | ) 92 | 93 | return err 94 | } 95 | 96 | func (r *Repository) TrackCompanyView(company *Company) error { 97 | stmt := `INSERT INTO company_event (event_type, company_id, created_at) VALUES ($1, $2, NOW())` 98 | _, err := r.db.Exec(stmt, companyEventPageView, company.ID) 99 | return err 100 | } 101 | 102 | func (r *Repository) CompanyBySlug(slug string) (*Company, error) { 103 | company := &Company{} 104 | row := r.db.QueryRow(`SELECT id, name, url, locations, last_job_created_at, icon_image_id, total_job_count, active_job_count, description, featured_post_a_job, slug, github, linkedin, twitter FROM company WHERE slug = $1`, slug) 105 | if err := row.Scan(&company.ID, &company.Name, &company.URL, &company.Locations, &company.LastJobCreatedAt, &company.IconImageID, &company.TotalJobCount, &company.ActiveJobCount, &company.Description, &company.Featured, &company.Slug, &company.Github, &company.Linkedin, &company.Twitter); err != nil { 106 | return company, err 107 | } 108 | 109 | return company, nil 110 | } 111 | 112 | func (r *Repository) CompaniesByQuery(location string, pageID, companiesPerPage int) ([]Company, int, error) { 113 | companies := []Company{} 114 | var rows *sql.Rows 115 | offset := pageID*companiesPerPage - companiesPerPage 116 | rows, err := getCompanyQueryForArgs(r.db, location, offset, companiesPerPage) 117 | if err != nil { 118 | return companies, 0, err 119 | } 120 | defer rows.Close() 121 | var fullRowsCount int 122 | for rows.Next() { 123 | c := Company{} 124 | var description, twitter, github, linkedin sql.NullString 125 | err = rows.Scan( 126 | &fullRowsCount, 127 | &c.ID, 128 | &c.Name, 129 | &c.URL, 130 | &c.Locations, 131 | &c.IconImageID, 132 | &c.LastJobCreatedAt, 133 | &c.TotalJobCount, 134 | &c.ActiveJobCount, 135 | &description, 136 | &c.Slug, 137 | &twitter, 138 | &github, 139 | &linkedin, 140 | &c.CompanyPageEligibilityExpiredAt, 141 | ) 142 | if err != nil { 143 | return companies, fullRowsCount, err 144 | } 145 | if description.Valid { 146 | c.Description = &description.String 147 | } 148 | if twitter.Valid { 149 | c.Twitter = &twitter.String 150 | } 151 | if github.Valid { 152 | c.Github = &github.String 153 | } 154 | if linkedin.Valid { 155 | c.Linkedin = &linkedin.String 156 | } 157 | companies = append(companies, c) 158 | } 159 | err = rows.Err() 160 | if err != nil { 161 | return companies, fullRowsCount, err 162 | } 163 | return companies, fullRowsCount, nil 164 | } 165 | 166 | func (r *Repository) FeaturedCompaniesPostAJob() ([]Company, error) { 167 | companies := []Company{} 168 | rows, err := r.db.Query(`SELECT name, icon_image_id FROM company WHERE featured_post_a_job IS TRUE LIMIT 15`) 169 | if err != nil { 170 | return companies, err 171 | } 172 | defer rows.Close() 173 | for rows.Next() { 174 | c := Company{} 175 | err = rows.Scan( 176 | &c.Name, 177 | &c.IconImageID, 178 | ) 179 | if err != nil { 180 | return companies, err 181 | } 182 | companies = append(companies, c) 183 | } 184 | err = rows.Err() 185 | if err != nil { 186 | return companies, err 187 | } 188 | return companies, nil 189 | } 190 | 191 | func (r *Repository) GetCompanySlugs() ([]string, error) { 192 | slugs := make([]string, 0) 193 | var rows *sql.Rows 194 | rows, err := r.db.Query(`SELECT slug FROM company WHERE description IS NOT NULL`) 195 | if err != nil { 196 | return slugs, err 197 | } 198 | defer rows.Close() 199 | for rows.Next() { 200 | var slug string 201 | if err := rows.Scan(&slug); err != nil { 202 | return slugs, err 203 | } 204 | slugs = append(slugs, slug) 205 | } 206 | 207 | return slugs, nil 208 | } 209 | 210 | func (r *Repository) CompanyExists(company string) (bool, error) { 211 | var count int 212 | row := r.db.QueryRow(`SELECT COUNT(*) as c FROM job WHERE company ILIKE '%` + company + `%'`) 213 | err := row.Scan(&count) 214 | if count > 0 { 215 | return true, err 216 | } 217 | 218 | return false, err 219 | } 220 | 221 | func (r *Repository) DeleteStaleImages(logoID string) error { 222 | stmt := `DELETE FROM image WHERE id NOT IN (SELECT company_icon_image_id FROM job WHERE company_icon_image_id IS NOT NULL) AND id NOT IN (SELECT icon_image_id FROM company) AND id NOT IN (SELECT image_id FROM developer_profile) AND id NOT IN ($1)` 223 | _, err := r.db.Exec(stmt, logoID) 224 | return err 225 | } 226 | 227 | func getCompanyQueryForArgs(conn *sql.DB, location string, offset, max int) (*sql.Rows, error) { 228 | if location == "" { 229 | return conn.Query(` 230 | SELECT Count(*) 231 | OVER() AS full_count, 232 | id, 233 | NAME, 234 | url, 235 | locations, 236 | icon_image_id, 237 | last_job_created_at, 238 | total_job_count, 239 | active_job_count, 240 | description, 241 | slug, 242 | twitter, 243 | github, 244 | linkedin, 245 | company_page_eligibility_expired_at 246 | FROM company 247 | ORDER BY company_page_eligibility_expired_at DESC, last_job_created_at DESC 248 | LIMIT $2 OFFSET $1`, offset, max) 249 | } 250 | 251 | return conn.Query(` 252 | SELECT Count(*) 253 | OVER() AS full_count, 254 | id, 255 | NAME, 256 | url, 257 | locations, 258 | icon_image_id, 259 | last_job_created_at, 260 | total_job_count, 261 | active_job_count, 262 | description, 263 | slug, 264 | twitter, 265 | github, 266 | linkedin, 267 | company_page_eligibility_expired_at 268 | FROM company 269 | WHERE locations ILIKE '%' || $1 || '%' 270 | ORDER BY company_page_eligibility_expired_at DESC, last_job_created_at DESC 271 | LIMIT $3 OFFSET $2`, location, offset, max) 272 | } 273 | -------------------------------------------------------------------------------- /static/views/view-blogpost.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .BlogPost.Title }} | {{ .SiteName }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 29 | 30 | 31 | {{ template "google-analytics" }} 32 | 33 | 34 | {{ template "header-html" . }} 35 |
36 |
37 |

{{ .BlogPost.Title }}

38 | {{ .BlogPostTextHTML }} 39 |
40 |
41 |
Join the {{ .SiteJobCategory }} Developers Community on {{ .SiteName }}
42 | 43 |
44 |
45 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /static/views/post-a-job-error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Post a {{ .SiteJobCategory }} Job now and reach thousands of candidates 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {{ template "google-analytics" }} 34 | 35 | 36 |
37 |
38 |

39 |

Oops we are sorry :(

40 | Oops, something went wrong - most likely we have catched the error and we are working on the fix. Please try again later.
41 | Please reach us out {{ .SupportEmail }} if you need to Hire Go Developers now 42 |

43 |
44 |
45 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /static/views/apply-message.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .Title }} 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {{ template "google-analytics" }} 32 | 33 | 34 |
35 |
36 |

37 |

{{ .Title }}

38 |

{{ .Description }}

39 |

40 |
41 |

42 | 43 |

44 |
45 |
46 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /static/views/post-a-job-success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Post a {{ .SiteJobCategory }} Job now and reach thousands of candidates 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {{ template "google-analytics" }} 34 | 35 | 36 |
37 |
38 |

39 |

Thanks for advertising on {{ .SiteName }}

40 | Your job ad was submitted successfully.
41 |

What happens next

42 | A member of staff is reviewing the job ad and will notify you as soon as it is approved.
43 | Job ads are usually approved within a few hours.
44 |

VAT Invoices

45 | You will automatically receive a receipt of purchase in your inbox. For customers located in the United Kingdom the purchase is inclusive of VAT. If you need a VAT invoice for your records, you can request one at {{ .SupportEmail }}. 46 |

47 |

48 | 49 |

50 |
51 |
52 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /internal/seo/seo.go: -------------------------------------------------------------------------------- 1 | package seo 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/golang-cafe/job-board/internal/blog" 10 | "github.com/golang-cafe/job-board/internal/company" 11 | "github.com/golang-cafe/job-board/internal/database" 12 | "github.com/golang-cafe/job-board/internal/developer" 13 | ) 14 | 15 | func StaticPages(siteJobCategory string) []string { 16 | return []string{ 17 | "hire-" + siteJobCategory + "-developers", 18 | "privacy-policy", 19 | "terms-of-service", 20 | "about", 21 | "newsletter", 22 | "blog", 23 | "support", 24 | } 25 | } 26 | 27 | type BlogPost struct { 28 | Title, Path string 29 | } 30 | 31 | func BlogPages(blogRepo *blog.Repository) ([]BlogPost, error) { 32 | posts := make([]BlogPost, 0, 100) 33 | blogs, err := blogRepo.GetAllPublished() 34 | if err != nil { 35 | return posts, err 36 | } 37 | for _, b := range blogs { 38 | posts = append(posts, BlogPost{ 39 | Title: b.Title, 40 | Path: b.Slug, 41 | }) 42 | } 43 | 44 | return posts, nil 45 | } 46 | 47 | func GeneratePostAJobSEOLandingPages(conn *sql.DB, siteJobCategory string) ([]string, error) { 48 | siteJobCategory = strings.Title(siteJobCategory) 49 | var seoLandingPages []string 50 | locs, err := database.GetSEOLocations(conn) 51 | if err != nil { 52 | return seoLandingPages, err 53 | } 54 | for _, loc := range locs { 55 | seoLandingPages = appendPostAJobSEOLandingPageForLocation(siteJobCategory, seoLandingPages, loc.Name) 56 | } 57 | 58 | return seoLandingPages, nil 59 | } 60 | 61 | func GenerateSalarySEOLandingPages(conn *sql.DB, siteJobCategory string) ([]string, error) { 62 | siteJobCategory = strings.Title(siteJobCategory) 63 | var landingPages []string 64 | locs, err := database.GetSEOLocations(conn) 65 | if err != nil { 66 | return landingPages, err 67 | } 68 | for _, loc := range locs { 69 | landingPages = appendSalarySEOLandingPageForLocation(siteJobCategory, landingPages, loc.Name) 70 | } 71 | 72 | return landingPages, nil 73 | } 74 | 75 | func GenerateCompaniesLandingPages(conn *sql.DB, siteJobCategory string) ([]string, error) { 76 | siteJobCategory = strings.Title(siteJobCategory) 77 | var landingPages []string 78 | locs, err := database.GetSEOLocations(conn) 79 | if err != nil { 80 | return landingPages, err 81 | } 82 | for _, loc := range locs { 83 | landingPages = appendCompaniesLandingPagesForLocation(siteJobCategory, landingPages, loc.Name) 84 | } 85 | 86 | return landingPages, nil 87 | } 88 | 89 | func appendSalarySEOLandingPageForLocation(siteJobCategory string, landingPages []string, loc string) []string { 90 | tmpl := `%s-Developer-Salary-%s` 91 | if strings.ToLower(loc) == "remote" { 92 | return append(landingPages, fmt.Sprintf(`Remote-%s-Developer-Salary`, siteJobCategory)) 93 | } 94 | return append(landingPages, fmt.Sprintf(tmpl, siteJobCategory, strings.ReplaceAll(loc, " ", "-"))) 95 | } 96 | 97 | func appendPostAJobSEOLandingPageForLocation(siteJobCategory string, seoLandingPages []string, loc string) []string { 98 | tmpl := `Hire-%s-Developers-In-%s` 99 | if strings.ToLower(loc) == "remote" { 100 | return append(seoLandingPages, fmt.Sprintf(`Hire-Remote-%s-Developers`, siteJobCategory)) 101 | } 102 | return append(seoLandingPages, fmt.Sprintf(tmpl, siteJobCategory, strings.ReplaceAll(loc, " ", "-"))) 103 | } 104 | 105 | func appendCompaniesLandingPagesForLocation(siteJobCategory string, landingPages []string, loc string) []string { 106 | tmpl := `Companies-Using-%s-In-%s` 107 | if strings.ToLower(loc) == "remote" { 108 | return append(landingPages, fmt.Sprintf(`Remote-Companies-Using-%s`, siteJobCategory)) 109 | } 110 | return append(landingPages, fmt.Sprintf(tmpl, siteJobCategory, strings.ReplaceAll(loc, " ", "-"))) 111 | } 112 | 113 | func appendSearchSEOSalaryLandingPageForLocation(siteJobCategory string, seoLandingPages []database.SEOLandingPage, loc database.SEOLocation) []database.SEOLandingPage { 114 | salaryBands := []string{"50000", "10000", "150000", "200000"} 115 | tmp := make([]database.SEOLandingPage, 0, len(salaryBands)) 116 | if loc.Name == "" { 117 | for _, salaryBand := range salaryBands { 118 | tmp = append(tmp, database.SEOLandingPage{ 119 | URI: fmt.Sprintf("%s-Jobs-Paying-%s-USD-year", siteJobCategory, salaryBand), 120 | }) 121 | } 122 | 123 | return append(seoLandingPages, tmp...) 124 | } 125 | 126 | if loc.Population < 1000000 { 127 | return seoLandingPages 128 | } 129 | 130 | for _, salaryBand := range salaryBands { 131 | tmp = append(tmp, database.SEOLandingPage{ 132 | URI: fmt.Sprintf("%s-Jobs-In-%s-Paying-%s-USD-year", siteJobCategory, url.PathEscape(strings.ReplaceAll(loc.Name, " ", "-")), salaryBand), 133 | }) 134 | } 135 | 136 | return append(seoLandingPages, tmp...) 137 | } 138 | 139 | func GenerateSearchSEOLandingPages(conn *sql.DB, siteJobCategory string) ([]database.SEOLandingPage, error) { 140 | siteJobCategory = strings.Title(siteJobCategory) 141 | var seoLandingPages []database.SEOLandingPage 142 | locs, err := database.GetSEOLocations(conn) 143 | if err != nil { 144 | return seoLandingPages, err 145 | } 146 | skills, err := database.GetSEOskills(conn) 147 | if err != nil { 148 | return seoLandingPages, err 149 | } 150 | 151 | seoLandingPages = appendSearchSEOSalaryLandingPageForLocation(siteJobCategory, seoLandingPages, database.SEOLocation{}) 152 | 153 | for _, loc := range locs { 154 | seoLandingPages = appendSearchSEOLandingPageForLocationAndSkill(siteJobCategory, seoLandingPages, loc, database.SEOSkill{}) 155 | seoLandingPages = appendSearchSEOSalaryLandingPageForLocation(siteJobCategory, seoLandingPages, loc) 156 | } 157 | for _, skill := range skills { 158 | seoLandingPages = appendSearchSEOLandingPageForLocationAndSkill(siteJobCategory, seoLandingPages, database.SEOLocation{}, skill) 159 | } 160 | 161 | return seoLandingPages, nil 162 | } 163 | 164 | func GenerateDevelopersSkillLandingPages(repo *developer.Repository, siteJobCategory string) ([]string, error) { 165 | siteJobCategory = strings.Title(siteJobCategory) 166 | var landingPages []string 167 | devSkills, err := repo.GetDeveloperSkills() 168 | if err != nil { 169 | return landingPages, err 170 | } 171 | for _, skill := range devSkills { 172 | devSkills = append(devSkills, fmt.Sprintf("%s-%s-Developers", siteJobCategory, url.PathEscape(skill))) 173 | } 174 | 175 | return landingPages, nil 176 | } 177 | 178 | func GenerateDevelopersLocationPages(conn *sql.DB, siteJobCategory string) ([]string, error) { 179 | siteJobCategory = strings.Title(siteJobCategory) 180 | var landingPages []string 181 | locs, err := database.GetSEOLocations(conn) 182 | if err != nil { 183 | return landingPages, err 184 | } 185 | for _, loc := range locs { 186 | landingPages = append(landingPages, fmt.Sprintf("%s-Developers-In-%s", siteJobCategory, url.PathEscape(loc.Name))) 187 | } 188 | 189 | return landingPages, nil 190 | } 191 | 192 | func GenerateDevelopersProfileLandingPages(repo *developer.Repository) ([]string, error) { 193 | var landingPages []string 194 | profiles, err := repo.GetDeveloperSlugs() 195 | if err != nil { 196 | return landingPages, err 197 | } 198 | for _, slug := range profiles { 199 | landingPages = append(landingPages, fmt.Sprintf("developer/%s", url.PathEscape(slug))) 200 | } 201 | 202 | return landingPages, nil 203 | } 204 | 205 | func GenerateCompanyProfileLandingPages(companyRepo *company.Repository) ([]string, error) { 206 | var landingPages []string 207 | companies, err := companyRepo.GetCompanySlugs() 208 | if err != nil { 209 | return landingPages, err 210 | } 211 | for _, slug := range companies { 212 | landingPages = append(landingPages, fmt.Sprintf("company/%s", url.PathEscape(slug))) 213 | } 214 | 215 | return landingPages, nil 216 | } 217 | 218 | func appendSearchSEOLandingPageForLocationAndSkill(siteJobCategory string, seoLandingPages []database.SEOLandingPage, loc database.SEOLocation, skill database.SEOSkill) []database.SEOLandingPage { 219 | templateBoth := siteJobCategory + `-%s-Jobs-In-%s` 220 | templateSkill := siteJobCategory + `-%s-Jobs` 221 | templateLoc := siteJobCategory + `-Jobs-In-%s` 222 | 223 | templateRemoteLoc := `Remote-` + siteJobCategory + `-Jobs` 224 | templateRemoteBoth := `Remote-` + siteJobCategory + `-%s-Jobs` 225 | loc.Name = strings.ReplaceAll(loc.Name, " ", "-") 226 | skill.Name = strings.ReplaceAll(skill.Name, " ", "-") 227 | 228 | // Skill only 229 | if loc.Name == "" { 230 | return append(seoLandingPages, database.SEOLandingPage{ 231 | URI: fmt.Sprintf(templateSkill, url.PathEscape(skill.Name)), 232 | Skill: skill.Name, 233 | }) 234 | } 235 | 236 | // Remote is special case 237 | if loc.Name == "Remote" { 238 | if skill.Name != "" { 239 | return append(seoLandingPages, database.SEOLandingPage{ 240 | URI: fmt.Sprintf(templateRemoteBoth, url.PathEscape(skill.Name)), 241 | Location: loc.Name, 242 | }) 243 | } else { 244 | return append(seoLandingPages, database.SEOLandingPage{ 245 | URI: templateRemoteLoc, 246 | Location: loc.Name, 247 | Skill: skill.Name, 248 | }) 249 | } 250 | } 251 | 252 | // Location only 253 | if skill.Name == "" { 254 | return append(seoLandingPages, database.SEOLandingPage{ 255 | URI: fmt.Sprintf(templateLoc, url.PathEscape(loc.Name)), 256 | Location: loc.Name, 257 | }) 258 | } 259 | 260 | // Both 261 | return append(seoLandingPages, database.SEOLandingPage{ 262 | URI: fmt.Sprintf(templateBoth, url.PathEscape(skill.Name), url.PathEscape(loc.Name)), 263 | Skill: skill.Name, 264 | Location: loc.Name, 265 | }) 266 | } 267 | -------------------------------------------------------------------------------- /static/views/support.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .SiteName }} Supoport 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 35 | 36 | {{ template "google-analytics" }} 37 | 38 | 39 | {{ template "header-html" . }} 40 |
41 |
42 |

{{ .SiteName }} Support

43 |

44 | Please send us an email at {{ .SupportEmail }}. We will reply within 24 hours. 45 |

46 |
47 |
48 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /static/views/privacy-policy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .SiteName }} Privacy Policy 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {{ template "google-analytics" }} 33 | 34 | 35 |
36 |
37 |

{{ .SiteName }} Privacy Policy

38 |

DeveloperJobs LLC ("Us" or "We") provides {{ .SiteName }} (https://{{ .SiteHost }}) and other related services (collectively, "{{ .SiteName }}"). Your privacy is important to us. It is {{ .SiteName }}'s policy to respect your privacy regarding any information we may collect from you across our website, http://{{ .SiteHost }}, and other sites we own and operate.

39 |

We only ask for personal information when we truly need it to provide a service to you. We collect it by fair and lawful means, with your knowledge and consent. We also let you know why we’re collecting it and how it will be used.

40 |

We only retain collected information for as long as necessary to provide you with your requested service. What data we store, we’ll protect within commercially acceptable means to prevent loss and theft, as well as unauthorised access, disclosure, copying, use or modification.

41 |

We don’t share any personally identifying information publicly or with third-parties, except when required to by law.

42 |

Our website may link to external sites that are not operated by us. Please be aware that we have no control over the content and practices of these sites, and cannot accept responsibility or liability for their respective privacy policies.

43 |

You are free to refuse our request for your personal information, with the understanding that we may be unable to provide you with some of your desired services.

44 |

Your continued use of our website will be regarded as acceptance of our practices around privacy and personal information. If you have any questions about how we handle user data and personal information, feel free to contact us.

45 |

This policy is effective as of 1 October 2018.

46 |
47 |
48 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /static/views/recruiter-job-posts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .SiteName }} Profile Home 5 | 6 | 7 | 8 | 9 | 26 | 27 | {{ template "google-analytics" }} 28 | 29 | 30 |
31 | 46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |

Jobs Posted

54 | {{ $jobsFound := len .Jobs }} 55 | {{ if eq $jobsFound 0 }} 56 |

No jobs posted yet

57 | 58 | {{ end }} 59 | 66 |
67 |
68 | 78 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /static/views/sent-messages.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .SiteName }} Sent Messages | {{ .MonthAndYear }} 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 44 | 45 | {{ template "google-analytics" }} 46 | 47 | 48 | {{ template "header-html" . }} 49 |
50 |

Sent Messages

51 | 52 | {{ if not .Messages }} 53 |
54 |

You haven't sent any messages.

55 |
56 | {{ else }} 57 | {{ range $i, $j := .Messages }} 58 |
59 | To: {{ .RecipientName }}
60 | From: {{ .Email }}
61 | Sent: {{ .CreatedAt.Format "Jan 02, 2006 15:04:05 UTC" }}

62 | Message:
63 |
{{ .Content }}
64 |
65 | {{ end }} 66 | {{ end }} 67 |
68 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /static/views/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | About | {{ .SiteName }} 5 | 6 | 7 | 8 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {{ template "google-analytics" }} 36 | 37 | 38 | {{ template "header-html" . }} 39 |
40 |
41 |

42 |

{{ .SiteName }}


43 | {{ .SiteName }} is the first {{ .SiteJobCategory }} job board with no recruiters and clear salary ranges. You can apply directly to companies. 44 | 45 |

Salary Range information in each Job Ad

46 | {{ .SiteName }} will always have salary range information in each job ad. You should not waste your and the company's time if your expectations don't match with the salary range. 47 | 48 |

No Recruitment Agencies

49 | Job Ads from third party recruitment agencies are not allowed on {{ .SiteName }}. 50 | Candidates speak directly to companies 51 | 52 |

{{ .SiteJobCategory }} only Job Ads

53 | {{ .SiteName }} will be only for jobs where the main programming language is Go. 54 | 55 |

High-Quality Hand-Picked Jobs

56 | All submitted jobs are manually reviewed and updated. All jobs that don't follow the standards above are not allowed on the platform. 57 | 58 |

Open Source

59 | {{ .SiteName }} is open source and licensed under BSD 3-Clause. It's written entirely in Go/HTML/CSS/JavaScript. 60 | As of today the app is using PostgreSQL as primary data store and it's being hosted on DigitalOcean. 61 | 62 |

{{ .SiteName }} Socials

63 |
    64 | {{ if .SiteGithub }}
  • GitHub
  • {{ end }} 65 | {{ if .SiteTwitter }}
  • Twitter
  • {{ end }} 66 | {{ if .SiteLinkedin }}
  • LinkedIn
  • {{ end }} 67 | {{ if .SiteYoutube }}
  • YouTube
  • {{ end }} 68 | {{ if .SiteTelegramChannel }}
  • Telegram
  • {{ end }} 69 |
  • Blog
  • 70 |
71 | 72 |

Contact

73 | If you have any questions, feedback or support request please feel free to reach us out {{ .SiteName }} Support 74 |

75 |
76 |
77 | 93 | 94 | 95 | 104 | 105 | -------------------------------------------------------------------------------- /static/views/edit-recruiter-profile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Edit Your Recruiter Profile | {{ .SiteName }} 5 | 6 | 7 | 8 | 27 | 28 | 29 | 30 | 31 | 32 | {{ template "google-analytics" }} 33 | 34 | 35 | {{ template "header-html" . }} 36 |
37 |
38 |
39 |
40 |
41 |
42 |

43 |

Your Profile is Live on {{ .SiteName }}

44 | 45 | Created: {{ .RecruiterProfile.CreatedAt.Format "Jan 02, 2006 15:04:05 UTC" }}
46 | Last Updated: {{ .RecruiterProfile.UpdatedAt.Format "Jan 02, 2006 15:04:05 UTC" }}
47 | View Profile: https://{{ .SiteHost }}/developer/{{ .RecruiterProfile.Slug }}
48 |


49 |

50 |
51 |
52 |

53 |

Edit Your Recruiter Profile

54 |
55 |
56 |
57 |
58 |
59 |
60 | 61 |

62 |
63 |
64 | 74 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ChimeraCoder/anaconda v2.0.0+incompatible h1:F0eD7CHXieZ+VLboCD5UAqCeAzJZxcr90zSCcuJopJs= 2 | github.com/ChimeraCoder/anaconda v2.0.0+incompatible/go.mod h1:TCt3MijIq3Qqo9SBtuW/rrM4x7rDfWqYWHj8T7hLcLg= 3 | github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7 h1:r+EmXjfPosKO4wfiMLe1XQictsIlhErTufbWUsjOTZs= 4 | github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7/go.mod h1:b2EuEMLSG9q3bZ95ql1+8oVqzzrTNSiOQqSXWFBzxeI= 5 | github.com/PuerkitoBio/goquery v1.6.0 h1:j7taAbelrdcsOlGeMenZxc2AWXD5fieT1/znArdnx94= 6 | github.com/PuerkitoBio/goquery v1.6.0/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= 7 | github.com/aclements/go-moremath v0.0.0-20161014184102-0ff62e0875ff h1:txKOXqsFQUyi7Ht0Prto4QMU4O/0Gby6v5RFqMS0/PM= 8 | github.com/aclements/go-moremath v0.0.0-20161014184102-0ff62e0875ff/go.mod h1:idZL3yvz4kzx1dsBOAC+oYv6L92P1oFEhUXUB1A/lwQ= 9 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= 10 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 11 | github.com/allegro/bigcache/v2 v2.2.5 h1:mRc8r6GQjuJsmSKQNPsR5jQVXc8IJ1xsW5YXUYMLfqI= 12 | github.com/allegro/bigcache/v2 v2.2.5/go.mod h1:FppZsIO+IZk7gCuj5FiIDHGygD9xvWQcqg1uIPMb6tY= 13 | github.com/allegro/bigcache/v3 v3.0.0 h1:5Hxq+GTy8gHEeQccCZZDCfZRTydUfErdUf0iVDcMAFg= 14 | github.com/allegro/bigcache/v3 v3.0.0/go.mod h1:t5TAJn1B9qvf/VlJrSM1r6NlFAYoFDubYUsCuIO9nUQ= 15 | github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= 16 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 17 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 18 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 19 | github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 h1:ekDALXAVvY/Ub1UtNta3inKQwZ/jMB/zpOtD8rAYh78= 20 | github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330/go.mod h1:nH+k0SvAt3HeiYyOlJpLLv1HG1p7KWP7qU9QPp2/pCo= 21 | github.com/bot-api/telegram v0.0.0-20170115211335-b7abf87c449e h1:EtE7HLXzhoHdEO3+yhthgODswSuTNZxjMtDN0VbuSh0= 22 | github.com/bot-api/telegram v0.0.0-20170115211335-b7abf87c449e/go.mod h1:Gq2rcr09H5r99XS2sXg4cFhpeRUeQoCjRzbxWCPD9pg= 23 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 28 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 29 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 30 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 31 | github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc h1:tP7tkU+vIsEOKiK+l/NSLN4uUtkyuxc6hgYpQeCWAeI= 32 | github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc/go.mod h1:ORH5Qp2bskd9NzSfKqAF7tKfONsEkCarTE5ESr/RVBw= 33 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA= 34 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ= 35 | github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= 36 | github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= 37 | github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 h1:GOfMz6cRgTJ9jWV0qAezv642OhPnKEG7gtUjJSdStHE= 38 | github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17/go.mod h1:HfkOCN6fkKKaPSAeNq/er3xObxTW4VLeY6UUK895gLQ= 39 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 40 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 41 | github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= 42 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 43 | github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= 44 | github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= 45 | github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= 46 | github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 47 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 48 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 49 | github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= 50 | github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 51 | github.com/gosimple/slug v1.3.0 h1:NKQyQMjKkgCpD/Vd+wKtFc7N60bJNCLDubKU/UDKMFI= 52 | github.com/gosimple/slug v1.3.0/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0= 53 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 54 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 55 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 56 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 57 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 58 | github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= 59 | github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 60 | github.com/m0sth8/httpmock v0.0.0-20160716183344-e00e64b1d782 h1:pqbHCG9ZF64EgG5XnQdTx4ff5+LEs1Qe0eleOpNKfS0= 61 | github.com/m0sth8/httpmock v0.0.0-20160716183344-e00e64b1d782/go.mod h1:0NPQVherJPwDzTgwk6W3lcXwwxw2B3bVbMOEyYb35iw= 62 | github.com/machinebox/graphql v0.2.2 h1:dWKpJligYKhYKO5A2gvNhkJdQMNZeChZYyBbrZkBZfo= 63 | github.com/machinebox/graphql v0.2.2/go.mod h1:F+kbVMHuwrQ5tYgU9JXlnskM8nOaFxCAEolaQybkjWA= 64 | github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= 65 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 66 | github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc= 67 | github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= 68 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 69 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 70 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 71 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 72 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 73 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 74 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= 75 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= 76 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 77 | github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs= 78 | github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo= 79 | github.com/segmentio/ksuid v1.0.2 h1:9yBfKyw4ECGTdALaF09Snw3sLJmYIX6AbPJrAy6MrDc= 80 | github.com/segmentio/ksuid v1.0.2/go.mod h1:BXuJDr2byAiHuQaQtSKoXh1J0YmUDurywOXgB2w+OSU= 81 | github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY= 82 | github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 83 | github.com/snabb/diagio v0.0.0-20170305182244-0ef68e3dbf01 h1:aRo8cSRou2qrhengtKsw7m1OHxV9/JPczsTLRc5nz5I= 84 | github.com/snabb/diagio v0.0.0-20170305182244-0ef68e3dbf01/go.mod h1:ZyGaWFhfBVqstGUw6laYetzeTwZ2xxVPqTALx1QQa1w= 85 | github.com/snabb/sitemap v0.0.0-20171225173334-36baa8b39ef4 h1:lGJ/oWOzoZa7si57pmJJhndGAbkfIksoQfAxAyO6qpk= 86 | github.com/snabb/sitemap v0.0.0-20171225173334-36baa8b39ef4/go.mod h1:NkYN9/5dboCWl3kEzEBy0ihhK0kTq5l7eQiID36t4ME= 87 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 88 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 89 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 90 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 91 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 92 | github.com/stripe/stripe-go v62.10.0+incompatible h1:NA0ZdyXlogeRY1YRmx3xL3/4r3vQjAU3Y175+IwFoYo= 93 | github.com/stripe/stripe-go v62.10.0+incompatible/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY= 94 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 95 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 96 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 97 | golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI= 98 | golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= 99 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 100 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 101 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 102 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 103 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 104 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 105 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= 106 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 107 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 108 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 109 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 110 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 111 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 112 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 114 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 115 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 116 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 117 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 118 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 119 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 120 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 121 | golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= 122 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 123 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 124 | golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 125 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 126 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 127 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 128 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 129 | gopkg.in/russross/blackfriday.v2 v2.0.0 h1:+FlnIV8DSQnT7NZ43hcVKcdJdzZoeCmJj4Ql8gq5keA= 130 | gopkg.in/russross/blackfriday.v2 v2.0.0/go.mod h1:6sSBNz/GtOm/pJTuh5UmBK2ZHfmnxGbl2NZg1UliSOI= 131 | gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M= 132 | gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU= 133 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 134 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 135 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 136 | --------------------------------------------------------------------------------