initial pass

This commit is contained in:
Andre Medeiros 2021-05-09 20:51:15 -04:00
commit 7fad5ac9e4
30 changed files with 1033 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
tmp/
.env

9
Brewfile Normal file
View file

@ -0,0 +1,9 @@
tap 'contribsys/faktory'
brew 'faktory'
brew 'foreman'
brew 'golang'
brew 'golang-migrate'
brew 'postgres'
brew 'redis'

177
Brewfile.lock.json Normal file
View file

@ -0,0 +1,177 @@
{
"entries": {
"brew": {
"postgres": {
"version": "13.2_2",
"bottle": {
"rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core",
"files": {
"arm64_big_sur": {
"cellar": "/opt/homebrew/Cellar",
"url": "https://ghcr.io/v2/homebrew/core/postgresql/blobs/sha256:5bb80b319443cc57a44a9a9a8507a5255728d2caf359ef7e931c79c2c833b900",
"sha256": "5bb80b319443cc57a44a9a9a8507a5255728d2caf359ef7e931c79c2c833b900"
},
"big_sur": {
"cellar": "/usr/local/Cellar",
"url": "https://ghcr.io/v2/homebrew/core/postgresql/blobs/sha256:ee460f1f8beb4d877121faf4ebcbf8069f86a8ef4ae05fa342bc2fcdde75bb47",
"sha256": "ee460f1f8beb4d877121faf4ebcbf8069f86a8ef4ae05fa342bc2fcdde75bb47"
},
"catalina": {
"cellar": "/usr/local/Cellar",
"url": "https://ghcr.io/v2/homebrew/core/postgresql/blobs/sha256:1f50565ab7a703d85ecc37179e8d461dc82de4e3e6266ba085dd19a0b95ed5db",
"sha256": "1f50565ab7a703d85ecc37179e8d461dc82de4e3e6266ba085dd19a0b95ed5db"
},
"mojave": {
"cellar": "/usr/local/Cellar",
"url": "https://ghcr.io/v2/homebrew/core/postgresql/blobs/sha256:c0a5828cb5caef09e7b4acd99b450b6d4ceb3a0d265a95b147e63e23fb6f7596",
"sha256": "c0a5828cb5caef09e7b4acd99b450b6d4ceb3a0d265a95b147e63e23fb6f7596"
}
}
}
},
"golang": {
"version": "1.16.3",
"bottle": {
"rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core",
"files": {
"arm64_big_sur": {
"cellar": "/opt/homebrew/Cellar",
"url": "https://ghcr.io/v2/homebrew/core/go/blobs/sha256:e7c1efdd09e951eb46d01a3200b01e7fa55ce285b75470051be7fef34f4233ce",
"sha256": "e7c1efdd09e951eb46d01a3200b01e7fa55ce285b75470051be7fef34f4233ce"
},
"big_sur": {
"cellar": "/usr/local/Cellar",
"url": "https://ghcr.io/v2/homebrew/core/go/blobs/sha256:ea37f33fd27369612a3e4e6db6adc46db0e8bdf6fac1332bf51bafaa66d43969",
"sha256": "ea37f33fd27369612a3e4e6db6adc46db0e8bdf6fac1332bf51bafaa66d43969"
},
"catalina": {
"cellar": "/usr/local/Cellar",
"url": "https://ghcr.io/v2/homebrew/core/go/blobs/sha256:69c28f5e60612801c66e51e93d32068f822b245ab83246cb6cb374572eb59e15",
"sha256": "69c28f5e60612801c66e51e93d32068f822b245ab83246cb6cb374572eb59e15"
},
"mojave": {
"cellar": "/usr/local/Cellar",
"url": "https://ghcr.io/v2/homebrew/core/go/blobs/sha256:bf1e90ed1680b8ee1acb49f2f99426c8a8ac3e49efd63c7f3b41e57e7214dd19",
"sha256": "bf1e90ed1680b8ee1acb49f2f99426c8a8ac3e49efd63c7f3b41e57e7214dd19"
}
}
}
},
"foreman": {
"version": "0.87.2",
"bottle": {
"rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core",
"files": {
"arm64_big_sur": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/foreman/blobs/sha256:575f9fbc16eca16cf479196ce44d87bb817ddb1e2eed59869ffe158d98d08a9f",
"sha256": "575f9fbc16eca16cf479196ce44d87bb817ddb1e2eed59869ffe158d98d08a9f"
},
"big_sur": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/foreman/blobs/sha256:70c762dd642d8f5aa3ca5a28e420b6c9f7befaf7699de073b7d62e174fdee88f",
"sha256": "70c762dd642d8f5aa3ca5a28e420b6c9f7befaf7699de073b7d62e174fdee88f"
},
"catalina": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/foreman/blobs/sha256:5c2b39c1f7e9667b9ebc6b7228b6cf31f06c2261c85019028272cfdda7073ea5",
"sha256": "5c2b39c1f7e9667b9ebc6b7228b6cf31f06c2261c85019028272cfdda7073ea5"
},
"mojave": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/foreman/blobs/sha256:674b5fc005986f47294acedccba6b2a2bcdc1d423e392a356f8d58cc88a2c81a",
"sha256": "674b5fc005986f47294acedccba6b2a2bcdc1d423e392a356f8d58cc88a2c81a"
},
"high_sierra": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/foreman/blobs/sha256:b0d289ff31caf33f3d549af6dd615e37588aadb243355395380c4df5b0e52d63",
"sha256": "b0d289ff31caf33f3d549af6dd615e37588aadb243355395380c4df5b0e52d63"
}
}
}
},
"faktory": {
"version": "1.5.1-1",
"bottle": false
},
"redis": {
"version": "6.2.3",
"bottle": {
"rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core",
"files": {
"arm64_big_sur": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/redis/blobs/sha256:b2b3cfeca2d5f110507e9e7a7af8918786f2853e39e49b0b39de68762e5b5030",
"sha256": "b2b3cfeca2d5f110507e9e7a7af8918786f2853e39e49b0b39de68762e5b5030"
},
"big_sur": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/redis/blobs/sha256:d891c5b376746c3895098fd384fb4edba972b532848f63cbad5be20e611458ac",
"sha256": "d891c5b376746c3895098fd384fb4edba972b532848f63cbad5be20e611458ac"
},
"catalina": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/redis/blobs/sha256:a269e87b26515775a7034d9d6cb996ed63d783b5a6d681b64bab92ce93bed55b",
"sha256": "a269e87b26515775a7034d9d6cb996ed63d783b5a6d681b64bab92ce93bed55b"
},
"mojave": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/redis/blobs/sha256:3373d834552eef5f6c71889299124693de6b5d5b887e520d6db96ab51da81020",
"sha256": "3373d834552eef5f6c71889299124693de6b5d5b887e520d6db96ab51da81020"
}
}
}
},
"golang-migrate": {
"version": "4.14.1",
"bottle": {
"rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core",
"files": {
"arm64_big_sur": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/golang-migrate/blobs/sha256:3565f7a03cfd1eeec3110aa8d56f03baa79b0de2718103c0095e51187ecd37ee",
"sha256": "3565f7a03cfd1eeec3110aa8d56f03baa79b0de2718103c0095e51187ecd37ee"
},
"big_sur": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/golang-migrate/blobs/sha256:5c61a106d9970b0f9b14e78e1523894d57b50cd0473f7d5a1fb1a9161dbff159",
"sha256": "5c61a106d9970b0f9b14e78e1523894d57b50cd0473f7d5a1fb1a9161dbff159"
},
"catalina": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/golang-migrate/blobs/sha256:a77af5282af35e0d073e82140b091eedf0b478c19aea36f1b06738690989cebb",
"sha256": "a77af5282af35e0d073e82140b091eedf0b478c19aea36f1b06738690989cebb"
},
"mojave": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/golang-migrate/blobs/sha256:8fa3758e979f09c171388887c831a6518e3f8df67b07668b6c8cebf76b19a653",
"sha256": "8fa3758e979f09c171388887c831a6518e3f8df67b07668b6c8cebf76b19a653"
}
}
}
}
},
"tap": {
"contribsys/faktory": {
"revision": "ada2f8b18fe79a40a906a7371d36c1d750422ca3"
}
}
},
"system": {
"macos": {
"big_sur": {
"HOMEBREW_VERSION": "3.1.5",
"HOMEBREW_PREFIX": "/usr/local",
"Homebrew/homebrew-core": "17fb25009a4876e9369cb621e798b758eb0c7fe6",
"CLT": "12.5.0.0.1.1617976050",
"Xcode": "12.5",
"macOS": "11.3.1"
}
}
}
}

4
Procfile Normal file
View file

@ -0,0 +1,4 @@
postgres: postgres -D tmp/postgresql
faktory: faktory
server: go run github.com/andremedeiros/apollo/cmd/apollo-api
worker: go run github.com/andremedeiros/apollo/cmd/apollo-worker

14
README.md Normal file
View file

@ -0,0 +1,14 @@
# Apollo API
## Getting started
```sh
$ script/bootstrap
$ cp .env.example .env
```
## Running the server
```sh
$ script/server
```

View file

@ -0,0 +1,62 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/julienschmidt/httprouter"
"github.com/andremedeiros/apollo/internal/data"
)
func (app *application) upsertAccountHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
a := &data.Account{}
if err := json.NewDecoder(r.Body).Decode(a); err != nil {
fmt.Println("failing on decoding json")
app.errorResponse(w, r, 500, err.Error())
return
}
a.ExpiresAt = time.Now().Unix() + 3300
// Here we check whether the account is supplied with a valid token.
ac := app.client.NewAuthenticatedClient(a.RefreshToken, a.AccessToken)
me, err := ac.Me()
if err != nil {
fmt.Println("failing on fetching remote user")
app.errorResponse(w, r, 500, err.Error())
return
}
if me.NormalizedUsername() != a.NormalizedUsername() {
fmt.Println("failing on account username comparison")
app.errorResponse(w, r, 500, "nice try")
return
}
// Upsert account
if err := app.models.Accounts.Upsert(a); err != nil {
fmt.Println("failing on account upsert")
app.errorResponse(w, r, 500, err.Error())
return
}
// Associate
d, err := app.models.Devices.GetByAPNSToken(ps.ByName("apns"))
if err != nil {
fmt.Println("failing on apns")
app.errorResponse(w, r, 500, err.Error())
return
}
if err := app.models.DevicesAccounts.Associate(a.ID, d.ID); err != nil {
fmt.Println("failing on associate")
app.errorResponse(w, r, 500, err.Error())
return
}
w.WriteHeader(http.StatusOK)
}

25
cmd/apollo-api/devices.go Normal file
View file

@ -0,0 +1,25 @@
package main
import (
"encoding/json"
"net/http"
"github.com/julienschmidt/httprouter"
"github.com/andremedeiros/apollo/internal/data"
)
func (app *application) upsertDeviceHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
d := &data.Device{}
if err := json.NewDecoder(r.Body).Decode(d); err != nil {
app.errorResponse(w, r, 500, err.Error())
return
}
if err := app.models.Devices.Upsert(d); err != nil {
app.errorResponse(w, r, 500, err.Error())
return
}
w.WriteHeader(http.StatusOK)
}

8
cmd/apollo-api/errors.go Normal file
View file

@ -0,0 +1,8 @@
package main
import "net/http"
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message string) {
w.WriteHeader(status)
w.Write([]byte(message))
}

18
cmd/apollo-api/health.go Normal file
View file

@ -0,0 +1,18 @@
package main
import (
"encoding/json"
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) healthCheckHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
data := map[string]string{
"status": "available",
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}

81
cmd/apollo-api/main.go Normal file
View file

@ -0,0 +1,81 @@
package main
import (
"database/sql"
"flag"
"fmt"
"log"
"net/http"
"os"
"github.com/andremedeiros/apollo/internal/data"
"github.com/andremedeiros/apollo/internal/reddit"
faktory "github.com/contribsys/faktory/client"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
)
type config struct {
port int
}
type application struct {
cfg config
logger *log.Logger
db *sql.DB
faktory *faktory.Client
models *data.Models
client *reddit.Client
}
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
var cfg config
flag.IntVar(&cfg.port, "port", 4000, "API server port")
flag.Parse()
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime)
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
faktory, err := faktory.Open()
if err != nil {
log.Fatal(err)
}
defer faktory.Close()
rc := reddit.NewClient(os.Getenv("REDDIT_CLIENT_ID"), os.Getenv("REDDIT_CLIENT_SECRET"))
app := &application{
cfg,
logger,
db,
faktory,
data.NewModels(db),
rc,
}
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.port),
Handler: app.routes(),
}
logger.Printf("starting server on %s", srv.Addr)
err = srv.ListenAndServe()
logger.Fatal(err)
/*
rc := reddit.NewClient("C7MjYkx1czyRDA", "I2AsVWbrf8h4vdQxVa5Pvf84vScF1w")
rac := rc.NewAuthenticatedClient("2532458-kGp6OeR-LMoQNrUXRL-7UNfyBbViRA", "2532458-UE7IvJK3-VuTdMJB0bgMv58fxKQhww")
rac.MessageInbox()
*/
}

16
cmd/apollo-api/routes.go Normal file
View file

@ -0,0 +1,16 @@
package main
import (
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() *httprouter.Router {
router := httprouter.New()
router.GET("/v1/health", app.healthCheckHandler)
router.POST("/v1/device", app.upsertDeviceHandler)
router.POST("/v1/device/:apns/account", app.upsertAccountHandler)
return router
}

49
cmd/apollo-worker/main.go Normal file
View file

@ -0,0 +1,49 @@
package main
import (
"database/sql"
"log"
"os"
worker "github.com/contribsys/faktory_worker_go"
"github.com/joho/godotenv"
"github.com/andremedeiros/apollo/internal/data"
"github.com/andremedeiros/apollo/internal/reddit"
)
type application struct {
logger *log.Logger
db *sql.DB
models *data.Models
client *reddit.Client
}
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime)
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
rc := reddit.NewClient(os.Getenv("REDDIT_CLIENT_ID"), os.Getenv("REDDIT_CLIENT_SECRET"))
app := &application{
logger,
db,
data.NewModels(db),
rc,
}
mgr := worker.NewManager()
mgr.ProcessStrictPriorityQueues("critical", "default", "bulk")
mgr.Concurrency = 20
mgr.Run()
}

4
env.example Normal file
View file

@ -0,0 +1,4 @@
DATABASE_URL=postgres://apollo:@localhost/apollo?sslmode=disable
FAKTORY_URL=tcp://localhost:7419
REDDIT_CLIENT_ID=C7MjYkx1czyRDA
REDDIT_CLIENT_SECRET=I2AsVWbrf8h4vdQxVa5Pvf84vScF1w

13
go.mod Normal file
View file

@ -0,0 +1,13 @@
module github.com/andremedeiros/apollo
go 1.16
require (
github.com/contribsys/faktory v1.5.1
github.com/contribsys/faktory_worker_go v1.4.2
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/joho/godotenv v1.3.0
github.com/julienschmidt/httprouter v1.3.0
github.com/lib/pq v1.10.1
github.com/stretchr/testify v1.5.1 // indirect
)

91
go.sum Normal file
View file

@ -0,0 +1,91 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/benbjohnson/ego v0.4.0/go.mod h1:6Qzj43pKX58WdlHDZKZS4Q/XXYwz6toIqXqNjzt4mNA=
github.com/contribsys/faktory v1.3.0-1/go.mod h1:qCp3bcPrIT7mb/aR5KendsK/Nyg7yE3JOg4sETojQN8=
github.com/contribsys/faktory v1.5.1 h1:Ac22RjbR8USVEwRV7jvz0+ytp2NquL5j7HC0IRM1K34=
github.com/contribsys/faktory v1.5.1/go.mod h1:f0dpwzksA7TmHquOcU9/j1NUUz8YNdx8LYDoNFIU9gw=
github.com/contribsys/faktory_worker_go v1.4.2 h1:IJD4wGk6iZSOkLEOHX42s6R8YvnQI2gEzN6WeZO9BBg=
github.com/contribsys/faktory_worker_go v1.4.2/go.mod h1:v53dJh0lrk18B/3zFJjAWM6/Lgq341sRricsBUZ/umc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo=
github.com/go-redis/redis v6.15.7+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/justinas/nosurf v1.1.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

51
internal/data/accounts.go Normal file
View file

@ -0,0 +1,51 @@
package data
import (
"database/sql"
"strings"
)
type Account struct {
ID int64
Username string
AccessToken string
RefreshToken string
ExpiresAt int64
LastMessageID string
LastCheckedAt int64
}
func (a *Account) NormalizedUsername() string {
return strings.ToLower(a.Username)
}
type AccountModel struct {
DB *sql.DB
}
func (am *AccountModel) Upsert(a *Account) error {
query := `
INSERT INTO accounts (username, access_token, refresh_token, expires_at, last_message_id, device_count, last_checked_at)
VALUES ($1, $2, $3, $4, '', 0, 0)
ON CONFLICT(username)
DO
UPDATE SET access_token = $2, refresh_token = $3, expires_at = $4, last_message_id = $5, last_checked_at = $6
RETURNING id`
args := []interface{}{a.NormalizedUsername(), a.AccessToken, a.RefreshToken, a.ExpiresAt, a.LastMessageID, a.LastCheckedAt}
return am.DB.QueryRow(query, args...).Scan(&a.ID)
}
func (am *AccountModel) Delete(id int64) error {
return nil
}
type MockAccountModel struct{}
func (mam *MockAccountModel) Upsert(a *Account) error {
return nil
}
func (mam *MockAccountModel) Delete(id int64) error {
return nil
}

View file

@ -0,0 +1,42 @@
package data
import "database/sql"
type DeviceAccount struct {
ID int64
AccountID int64
DeviceID int64
}
type DeviceAccountModel struct {
DB *sql.DB
}
func (dam *DeviceAccountModel) Associate(accountID int64, deviceID int64) error {
query := `
INSERT INTO devices_accounts (account_id, device_id)
VALUES ($1, $2)
ON CONFLICT (account_id, device_id) DO NOTHING
RETURNING id`
args := []interface{}{accountID, deviceID}
if err := dam.DB.QueryRow(query, args...).Err(); err != nil {
return err
}
// Update account counter
query = `
UPDATE accounts
SET device_count = (
SELECT COUNT(*) FROM devices_accounts WHERE account_id = $1
)
WHERE id = $1`
args = []interface{}{accountID}
return dam.DB.QueryRow(query, args...).Err()
}
type MockDeviceAccountModel struct{}
func (mdam *MockDeviceAccountModel) Associate(accountID int64, deviceID int64) error {
return nil
}

67
internal/data/devices.go Normal file
View file

@ -0,0 +1,67 @@
package data
import (
"database/sql"
"errors"
"time"
)
type Device struct {
ID int64
APNSToken string
Sandbox bool
LastPingedAt int64
}
type DeviceModel struct {
DB *sql.DB
}
func (dm *DeviceModel) Upsert(d *Device) error {
d.LastPingedAt = time.Now().Unix()
query := `
INSERT INTO devices (apns_token, sandbox, last_pinged_at)
VALUES ($1, $2, $3)
ON CONFLICT(apns_token)
DO
UPDATE SET last_pinged_at = $3
RETURNING id`
args := []interface{}{d.APNSToken, d.Sandbox, d.LastPingedAt}
return dm.DB.QueryRow(query, args...).Scan(&d.ID)
}
func (dm *DeviceModel) GetByAPNSToken(token string) (*Device, error) {
query := `
SELECT id, apns_token, sandbox, last_pinged_at
FROM devices
WHERE apns_token = $1`
device := &Device{}
err := dm.DB.QueryRow(query, token).Scan(
&device.ID,
&device.APNSToken,
&device.Sandbox,
&device.LastPingedAt,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return device, nil
}
type MockDeviceModel struct{}
func (mdm *MockDeviceModel) Upsert(d *Device) error {
return nil
}
func (mdm *MockDeviceModel) GetByAPNSToken(token string) (*Device, error) {
return nil, nil
}

42
internal/data/models.go Normal file
View file

@ -0,0 +1,42 @@
package data
import (
"database/sql"
"errors"
)
var (
ErrRecordNotFound = errors.New("record not found")
)
type Models struct {
Accounts interface {
Upsert(a *Account) error
Delete(id int64) error
}
Devices interface {
Upsert(*Device) error
GetByAPNSToken(string) (*Device, error)
}
DevicesAccounts interface {
Associate(int64, int64) error
}
}
func NewModels(db *sql.DB) *Models {
return &Models{
Accounts: &AccountModel{DB: db},
Devices: &DeviceModel{DB: db},
DevicesAccounts: &DeviceAccountModel{DB: db},
}
}
func NewMockModels() *Models {
return &Models{
Accounts: &MockAccountModel{},
Devices: &MockDeviceModel{},
DevicesAccounts: &MockDeviceAccountModel{},
}
}

105
internal/reddit/client.go Normal file
View file

@ -0,0 +1,105 @@
package reddit
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
)
const (
tokenURL = "https://www.reddit.com/api/v1/access_token"
)
type Client struct {
id string
secret string
}
func NewClient(id, secret string) *Client {
return &Client{id, secret}
}
type AuthenticatedClient struct {
*Client
refreshToken string
accessToken string
expiry *time.Time
}
func (rc *Client) NewAuthenticatedClient(refreshToken, accessToken string) *AuthenticatedClient {
return &AuthenticatedClient{rc, refreshToken, accessToken, nil}
}
func (rac *AuthenticatedClient) request(r *Request) ([]byte, error) {
tr := &http.Transport{}
client := &http.Client{Transport: tr}
req, err := r.HTTPRequest()
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
return ioutil.ReadAll(resp.Body)
}
func (rac *AuthenticatedClient) refreshTokens() (string, string, error) {
req := NewRequest(
WithMethod("POST"),
WithURL(tokenURL),
WithBody("grant_type", "refresh_token"),
WithBody("refresh_token", rac.refreshToken),
WithBasicAuth(rac.id, rac.secret),
)
body, err := rac.request(req)
fmt.Println(string(body))
return "", "", err
}
func (rac *AuthenticatedClient) MessageInbox() error {
req := NewRequest(
WithMethod("GET"),
WithToken(rac.accessToken),
WithURL("https://oauth.reddit.com/message/inbox.json"),
)
body, _ := rac.request(req)
fmt.Println(string(body))
return nil
}
type MeResponse struct {
Name string
}
func (mr *MeResponse) NormalizedUsername() string {
return strings.ToLower(mr.Name)
}
func (rac *AuthenticatedClient) Me() (*MeResponse, error) {
req := NewRequest(
WithMethod("GET"),
WithToken(rac.accessToken),
WithURL("https://oauth.reddit.com/api/v1/me"),
)
body, err := rac.request(req)
if err != nil {
return nil, err
}
mr := &MeResponse{}
err = json.Unmarshal(body, mr)
return mr, err
}

View file

@ -0,0 +1,76 @@
package reddit
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
)
const userAgent = "server:test-api:v0.0.1 (by /u/changelog)"
type Request struct {
body url.Values
method string
token string
url string
auth string
}
type RequestOption func(*Request)
func NewRequest(opts ...RequestOption) *Request {
req := &Request{url.Values{}, "GET", "", "", ""}
for _, opt := range opts {
opt(req)
}
return req
}
func (r *Request) HTTPRequest() (*http.Request, error) {
req, err := http.NewRequest(r.method, r.url, strings.NewReader(r.body.Encode()))
req.Header.Add("User-Agent", userAgent)
if r.token != "" {
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", r.token))
}
if r.auth != "" {
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", r.auth))
}
return req, err
}
func WithMethod(method string) RequestOption {
return func(req *Request) {
req.method = method
}
}
func WithURL(url string) RequestOption {
return func(req *Request) {
req.url = url
}
}
func WithBasicAuth(user, password string) RequestOption {
return func(req *Request) {
encoded := base64.StdEncoding.EncodeToString([]byte(user + ":" + password))
req.auth = encoded
}
}
func WithToken(token string) RequestOption {
return func(req *Request) {
req.token = token
}
}
func WithBody(key, val string) RequestOption {
return func(req *Request) {
req.body.Set(key, val)
}
}

View file

@ -0,0 +1 @@
DROP TABLE devices;

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS devices (
id SERIAL PRIMARY KEY,
apns_token character(100) UNIQUE,
sandbox boolean,
last_pinged_at integer
);
CREATE UNIQUE INDEX IF NOT EXISTS devices_pkey ON devices(id int4_ops);
CREATE UNIQUE INDEX IF NOT EXISTS devices_apns_token_key ON devices(apns_token bpchar_ops);

View file

@ -0,0 +1 @@
DROP TABLE accounts;

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
username character(20),
access_token character(64),
refresh_token character(64),
expires_at integer,
last_message_id character(32),
device_count integer,
last_checked_at integer
);
CREATE UNIQUE INDEX IF NOT EXISTS accounts_pkey ON accounts(id int4_ops);
CREATE UNIQUE INDEX IF NOT EXISTS accounts_username_key ON accounts(username bpchar_ops);

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS devices_accounts (
id SERIAL PRIMARY KEY,
account_id integer,
device_id integer
);
CREATE UNIQUE INDEX IF NOT EXISTS devices_accounts_pkey ON devices_accounts(id int4_ops);
CREATE UNIQUE INDEX IF NOT EXISTS devices_accounts_account_id_device_id_idx ON devices_accounts(account_id int4_ops,device_id int4_ops);

20
script/bootstrap Executable file
View file

@ -0,0 +1,20 @@
#!/bin/sh
set -e
cd "$(dirname "$0")/.."
brew bundle check >/dev/null 2>&1 || {
echo "==> Installing Homebrew dependencies..."
brew bundle
}
[ -d "tmp/postgresql" ] || {
echo "===> Setting up database..."
initdb -D tmp/postgresql -U apollo
}
go mod verify >/dev/null 2>&1 || {
echo "==> Installing Go dependencies..."
go mod download
}

16
script/migrate Executable file
View file

@ -0,0 +1,16 @@
#!/bin/sh
set -e
cd "$(dirname "$0")/.."
# ensure everything in the app is up to date.
script/bootstrap
[ -f ".env" ] && {
source .env
echo "===> Running migrations..."
createdb -U apollo apollo || true
migrate -path=./migrations -database=$DATABASE_URL up
}

11
script/server Executable file
View file

@ -0,0 +1,11 @@
#!/bin/sh
set -e
cd "$(dirname "$0")/.."
# ensure everything in the app is up to date.
script/bootstrap
# boot the app and any other necessary processes.
foreman start