├── 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 |
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 |
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 |
16 | {{ end }}
17 |
18 |
19 |
20 | 48 direct messages sent by companies to developers on {{ .SiteName }}
21 | in the last 30 days
22 |
23 | {{ if .DevelopersRegisteredLastMonth }}
24 | {{ .DevelopersRegisteredLastMonth }} developers joined {{ .SiteName }} in the last 30 days
25 | {{ end }} {{ if .DeveloperProfilePageViewsLastMonth }}
26 |
27 | {{ humannumber .DeveloperProfilePageViewsLastMonth }} developer profiles page views in the last 30 days
28 |
29 | {{ end }}
30 |
31 | Get access to our
32 | Salary Explorer
33 |
34 |
35 | Get access to exclusive discount on Golang courses up to 25% off
36 |
37 |
38 | Last developer joined {{ .LastDevCreatedAtHumanized }}
39 |
40 |
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 |
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 |
47 | {{ .SiteName }}
48 |
49 |
60 |
61 |
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 |
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 |
49 | {{ .SiteName }}
50 |
51 |
62 |
63 |
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 |
49 | {{ .SiteName }}
50 |
51 |
62 |
63 |
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 |
47 |
51 |
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 |
69 | {{ .SiteName }}
70 |
71 |
92 |
93 |
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 |
78 | {{ .SiteName }}
79 |
80 |
91 |
92 |
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 |
40 |
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 |
--------------------------------------------------------------------------------