diff --git a/.github/workflows/comment-test.yml b/.github/workflows/comment-test.yml deleted file mode 100644 index 93addaa..0000000 --- a/.github/workflows/comment-test.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: My Comment Tester - -on: - issue_comment: - types: [created, edited] - -jobs: - debug: - name: Debug - runs-on: ubuntu-latest - steps: - - run: echo 'hi there' && echo ${{ github.event.comment.body }} - - on-branch: - name: Branch Test - runs-on: ubuntu-latest - steps: - - name: Get PR branch - uses: xt0rted/pull-request-comment-branch@v1 - id: comment-branch - - - name: Checkout - uses: actions/checkout@v3 - with: - ref: ${{ steps.comment-branch.outputs.head_ref }} - - - run: echo my head ref is ${{ steps.comment-branch.outputs.head_ref }} - - - run: cat robots.txt - - - name: Add comment to PR - uses: actions/github-script@v6 - if: always() - with: - script: | - const name = '${{ github.workflow }}'; - const url = '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'; - const success = '${{ job.status }}' === 'success'; - const body = `${name}: ${success ? 'succeeded ✅' : 'failed ❌'}\n${url}`; - - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body - }) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 165db16..fb0320e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -7,8 +7,8 @@ on: [push] jobs: - build: - runs-on: ubuntu-latest + build-and-push: + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 @@ -20,26 +20,22 @@ jobs: - name: Build run: make build + - name: what gblic? + run: ldd --version ldd + - name: Test run: go test ./... - push: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup - run: mkdir -p bin/linux && cp bin/webapp bib/linux_amd64/webapp - - - name: Get shortsha - id: vars - run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - - - name: Push to GCR - uses: RafikFarhad/push-to-gcr-github-action@v4.1 - with: - gcloud_service_key: ${{ secrets.GCP_SERVICE_ACCOUNT }} - registry: gcr.io - project_id: ian-rose - image_name: us-central1-docker.pkg.dev/ian-rose/docker-1/webapp - image_tag: latest,${{ steps.vars.outputs.sha_short }} + - name: Get shortsha + id: vars + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Push to GCR + uses: RafikFarhad/push-to-gcr-github-action@v4.1 + with: + gcloud_service_key: ${{ secrets.GCP_SERVICE_ACCOUNT }} + registry: us-central1-docker.pkg.dev + project_id: ian-rose/docker-1 + image_name: webapp + image_tag: latest,${{ steps.vars.outputs.sha_short }} + build_args: BIN=bin/webapp diff --git a/.gitignore b/.gitignore index 123fd60..675b7b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,25 @@ -*.pyc -secrets.go +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Custom stuff from here down: +bin +*.sqlite +.idea +secrets.yaml +var + +# from docs here: https://github.com/google-github-actions/auth#prerequisites +gha-creds-*.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4110cb0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM ubuntu:22.04 +ARG BIN +WORKDIR /root/ +RUN apt-get update +RUN apt-get install -y ca-certificates + +# make cgo happy, see https://github.com/mattn/go-sqlite3/issues/855#issuecomment-1496136603 +RUN apt-get install -y build-essential +COPY $BIN /root diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..809c630 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: build webapp init + +build: webapp + +webapp: + @mkdir -p bin + go build -o bin/ ./cmd/webapp + +webapp-linux: + @mkdir -p bin/linux_amd64/ + GOOS=linux GOARCH=amd64 go build -o bin/linux_amd64/ ./cmd/webapp + +sql: + ./bin/sqlc -f internal/storage/sqlc.yaml generate + +# to set things up initially +init: + GOBIN=`pwd`/bin go install github.com/kyleconroy/sqlc/cmd/sqlc@latest diff --git a/cmd/webapp/handlers.go b/cmd/webapp/handlers.go new file mode 100644 index 0000000..68672a2 --- /dev/null +++ b/cmd/webapp/handlers.go @@ -0,0 +1,171 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "html/template" + "io" + "io/ioutil" + "log" + "net/http" + "net/url" + "strings" + + "github.com/ianrose14/website/internal" +) + +const ( + AlbumsConfigUrl = "https://www.dropbox.com/s/kr8ewc68husts57/albums.json?dl=1" +) + +var ( + albumsTemplate = template.Must(template.ParseFS(templatesFS, "templates/albums.html")) +) + +type album struct { + Name string `json:"name"` + Url string `json:"url"` + CoverPath string `json:"cover_path"` + CoverUrl string +} + +// albumsHandler serves a page that lists all available photo albums. +func (svr *server) albumsHandler(w http.ResponseWriter, _ *http.Request) { + rsp, err := http.Get(AlbumsConfigUrl) + if err != nil { + internal.HttpError(w, http.StatusInternalServerError, "failed to fetch albums config from dropbox: %s", err) + return + } + defer internal.DrainAndClose(rsp.Body) + + if err := internal.CheckResponse(rsp); err != nil { + internal.HttpError(w, http.StatusInternalServerError, "failed to fetch albums config from dropbox: %s", err) + return + } + + var results struct { + Albums []*album `json:"albums"` + } + + if err := json.NewDecoder(rsp.Body).Decode(&results); err != nil { + internal.HttpError(w, http.StatusInternalServerError, "failed to json-decode response: %s", err) + return + } + + for _, album := range results.Albums { + album.CoverUrl = fmt.Sprintf("/albums/thumbnail?path=%s", url.QueryEscape(album.CoverPath)) + } + + if err := albumsTemplate.Execute(w, results.Albums); err != nil { + internal.HttpError(w, http.StatusInternalServerError, "failed to render template: %s", err) + return + } +} + +func (svr *server) dumpHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + + writeIt := func(s string, args ...interface{}) { + log.Printf(s, args...) + fmt.Fprintf(w, s+"\n", args...) + } + + writeIt("URL: %s", r.URL) + writeIt("Method: %s", r.Method) + writeIt("Proto: %s", r.Proto) + writeIt("RemoteAddr: %s", r.RemoteAddr) + + var buf bytes.Buffer + buf.WriteString("Headers:\n") + for k, v := range r.Header { + fmt.Fprintf(&buf, "%s: %v\n", k, v) + } + writeIt("%s", buf.String()) + fmt.Fprintln(w, "") // write blank line to response body + + buf.Reset() + buf.WriteString("Cookies:\n") + for _, c := range r.Cookies() { + fmt.Fprintf(&buf, "%s\n", c) + } + writeIt("%s", buf.String()) + fmt.Fprintln(w, "") // write blank line to response body + + b, err := ioutil.ReadAll(r.Body) + if err != nil { + writeIt("error: failed to read body: %s", err) + } else { + buf.Reset() + if _, err := base64.NewEncoder(base64.StdEncoding, &buf).Write(b); err != nil { + writeIt("error: failed to base64-encode body: %s", err) + } else { + writeIt("Body (base64):\n%s\n", buf.String()) + } + fmt.Fprintln(w, "") // write blank line to response body + + writeIt("Body (raw):\n%s", string(b)) + fmt.Fprintln(w, "") // write blank line to response body + } +} + +func (svr *server) thumbnailHandler(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + if path == "" { + internal.HttpError(w, http.StatusBadRequest, "no path query") + return + } + + if !strings.HasPrefix(path, "/photos/") { + internal.HttpError(w, http.StatusBadRequest, "rejecting forbidden path %s", path) + return + } + + params := struct { + Path string `json:"path"` + Format string `json:"format"` + Size string `json:"size"` + }{ + Path: path, + Format: "jpeg", + Size: "w640h480", + } + + jstr, err := json.Marshal(¶ms) + if err != nil { + internal.HttpError(w, http.StatusInternalServerError, "failed to json-encode params: %s", err) + return + } + + qs := make(url.Values) + qs.Add("arg", string(jstr)) + + urls := "https://api-content.dropbox.com/2/files/get_thumbnail?" + qs.Encode() + log.Printf("fetching %s", urls) + + req, err := http.NewRequest("GET", urls, nil) + if err != nil { + internal.HttpError(w, http.StatusInternalServerError, "failed to make new http request") + return + } + + req.Header.Set("Authorization", "Bearer "+svr.secrets.Dropbox.AccessToken) + + rsp, err := http.DefaultClient.Do(req) + if err != nil { + internal.HttpError(w, http.StatusInternalServerError, "failed to fetch thumbnail from dropbox: %s", err) + return + } + defer internal.DrainAndClose(rsp.Body) + + if err := internal.CheckResponse(rsp); err != nil { + internal.HttpError(w, http.StatusInternalServerError, "failed to fetch thumbnail from dropbox: %s", err) + return + } + + w.Header().Set("Content-Type", rsp.Header.Get("Content-Type")) + if _, err := io.Copy(w, rsp.Body); err != nil { + log.Printf("failed to copy thumbnail body to response stream: %s", err) + } +} diff --git a/cmd/webapp/main.go b/cmd/webapp/main.go new file mode 100644 index 0000000..9470df1 --- /dev/null +++ b/cmd/webapp/main.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "database/sql" + "embed" + _ "embed" + "flag" + "html/template" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "runtime" + "syscall" + "time" + + "github.com/ianrose14/website/internal" + "github.com/ianrose14/website/internal/storage" + "github.com/ianrose14/website/internal/strava" + _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/acme/autocert" +) + +const inDev = runtime.GOOS == "darwin" + +var ( + //go:embed static/* + staticFS embed.FS + + //go:embed pubs/* + publicationsFS embed.FS + + //go:embed talks/* + talksFS embed.FS + + //go:embed templates/* + templatesFS embed.FS + + //stravaVars = &internal.MemoryDatabase{vals: make(map[string]*internal.StravaTokens)} + stravaTemplate = template.Must(template.ParseFS(templatesFS, "templates/strava.html")) +) + +func init() { + log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds | log.Llongfile) +} + +func main() { + certsDir := flag.String("certs", "certs", "Directory to store letsencrypt certs") + dbfile := flag.String("db", "store.sqlite", "sqlite database file") + host := flag.String("host", "", "Optional hostname for webserver") + secretsFile := flag.String("secrets", "config/secrets.yaml", "Path to local secrets file") + flag.Parse() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + sig := <-c + log.Printf("received %q signal", sig) + cancel() + }() + + if *host == "" { + *host = "localhost" + } + + s, err := filepath.Abs(*certsDir) + if err != nil { + log.Fatalf("failed to get absolute path of %q: %+v", *certsDir, err) + } + certsDir = &s + + secrets, err := internal.ParseSecrets(*secretsFile) + if err != nil { + log.Fatalf("failed to parse secrets: %s", err) + } + + db, err := sql.Open("sqlite3", "file:"+*dbfile+"?cache=shared") + if err != nil { + log.Fatalf("failed to open sqlite connection: %s", err) + } + defer func() { + if err := db.Close(); err != nil { + log.Printf("warning: failed to cleanly close database: %s", err) + } + }() + + if err := storage.UpsertDatabaseTables(ctx, db); err != nil { + log.Fatalf("failed to upsert database tables: %s", err) + } + + svr := &server{ + db: db, + secrets: secrets, + host: *host, + } + + stravaAccount := &strava.ApiParams{ + ClientId: secrets.Strava.ClientID, + ClientSecret: secrets.Strava.ClientSecret, + Hostname: *host, + } + + httpFS := func(files embed.FS, subdir string) http.Handler { + d, err := fs.Sub(files, subdir) + if err != nil { + log.Fatalf("static file config error: %+v", err) + } + return http.FileServer(http.FS(d)) + } + + mux := http.NewServeMux() + mux.Handle("/", httpFS(staticFS, "static")) + mux.Handle("/pubs/", http.FileServer(http.FS(publicationsFS))) + mux.Handle("/talks/", http.FileServer(http.FS(talksFS))) + + mux.HandleFunc("/albums/", svr.albumsHandler) + mux.HandleFunc("/albums/thumbnail/", svr.thumbnailHandler) + mux.HandleFunc("/dump/", svr.dumpHandler) + + stravaDb := strava.NewSqliteDb(db) + mux.HandleFunc("/strava/exchange_token/", func(w http.ResponseWriter, r *http.Request) { + strava.TokenHandler(w, r, stravaDb, stravaAccount) + }) + { + h := func(w http.ResponseWriter, r *http.Request) { + strava.Handler(w, r, stravaTemplate, stravaDb, stravaAccount) + } + mux.HandleFunc("/running/", h) + mux.HandleFunc("/strava/", h) + } + + mux.Handle("/favicon.ico", httpFS(staticFS, "static")) + + var httpHandler http.Handler = mux + + // TODO: in a handler wrapper, redirect http to https (in production only) + + if !inDev { + log.Printf("starting autocert manager with certsDir=%v", *certsDir) + if err := os.MkdirAll(*certsDir, 0777); err != nil { + log.Fatalf("failed to create certs dir: %s", err) + } + + httpsSrv := makeHTTPServer(mux) + certManager := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + Email: "ianrose14+autocert@gmail.com", + HostPolicy: func(ctx context.Context, host string) error { + log.Printf("autocert query for host %q, responding with %v", host, []string{svr.host, "www." + svr.host}) + return autocert.HostWhitelist(svr.host, "www."+svr.host)(ctx, host) + }, + Cache: autocert.DirCache(*certsDir), + } + httpsSrv.Addr = ":https" + httpsSrv.TLSConfig = certManager.TLSConfig() + + httpHandler = certManager.HTTPHandler(mux) + + lis, err := net.Listen("tcp", ":https") + if err != nil { + log.Fatalf("failed to listen on port 443: %+v", err) + } + + log.Printf("listening on %s", lis.Addr()) + srv := &http.Server{Handler: httpHandler} + + go func() { + <-ctx.Done() + if err := srv.Close(); err != nil { + log.Printf("error: failed to close https server: %+v", err) + } + }() + + go func() { + if err := httpsSrv.ServeTLS(lis, "", ""); err != nil { + if err != http.ErrServerClosed { + log.Fatalf("failure in https server: %+v", err) + } + } + }() + } + + lis, err := net.Listen("tcp", ":http") + if err != nil { + log.Fatalf("failed to listen on port 80: %+v", err) + } + + log.Printf("listening on %s", lis.Addr()) + srv := &http.Server{Handler: httpHandler} + go func() { + <-ctx.Done() + if err := srv.Close(); err != nil { + log.Printf("error: failed to close http server: %+v", err) + } + }() + + if err := srv.Serve(lis); err != nil { + if err != http.ErrServerClosed { + log.Fatalf("failure in http server: %+v", err) + } + } + + log.Printf("clean exit - goodbye!") +} + +func makeHTTPServer(mux *http.ServeMux) *http.Server { + // set timeouts so that a slow or malicious client can't hold resources forever + return &http.Server{ + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 120 * time.Second, + Handler: mux, + } +} + +type server struct { + db *sql.DB + secrets *internal.SecretsFile + host string +} diff --git a/pubs/argos-sensys10.pdf b/cmd/webapp/pubs/argos-sensys10.pdf similarity index 100% rename from pubs/argos-sensys10.pdf rename to cmd/webapp/pubs/argos-sensys10.pdf diff --git a/pubs/citysense-ieeehst08.pdf b/cmd/webapp/pubs/citysense-ieeehst08.pdf similarity index 100% rename from pubs/citysense-ieeehst08.pdf rename to cmd/webapp/pubs/citysense-ieeehst08.pdf diff --git a/pubs/cobra-nsdi07.pdf b/cmd/webapp/pubs/cobra-nsdi07.pdf similarity index 100% rename from pubs/cobra-nsdi07.pdf rename to cmd/webapp/pubs/cobra-nsdi07.pdf diff --git a/pubs/desync-ipsn07.pdf b/cmd/webapp/pubs/desync-ipsn07.pdf similarity index 100% rename from pubs/desync-ipsn07.pdf rename to cmd/webapp/pubs/desync-ipsn07.pdf diff --git a/pubs/ianrose-dissertation.pdf b/cmd/webapp/pubs/ianrose-dissertation.pdf similarity index 100% rename from pubs/ianrose-dissertation.pdf rename to cmd/webapp/pubs/ianrose-dissertation.pdf diff --git a/static/css/albums.css b/cmd/webapp/static/css/albums.css similarity index 100% rename from static/css/albums.css rename to cmd/webapp/static/css/albums.css diff --git a/static/css/kids.css b/cmd/webapp/static/css/kids.css similarity index 100% rename from static/css/kids.css rename to cmd/webapp/static/css/kids.css diff --git a/static/css/main.css b/cmd/webapp/static/css/main.css similarity index 100% rename from static/css/main.css rename to cmd/webapp/static/css/main.css diff --git a/static/css/strava.css b/cmd/webapp/static/css/strava.css similarity index 100% rename from static/css/strava.css rename to cmd/webapp/static/css/strava.css diff --git a/favicon.ico b/cmd/webapp/static/favicon.ico similarity index 100% rename from favicon.ico rename to cmd/webapp/static/favicon.ico diff --git a/static/images/benfranklin.jpg b/cmd/webapp/static/images/benfranklin.jpg similarity index 100% rename from static/images/benfranklin.jpg rename to cmd/webapp/static/images/benfranklin.jpg diff --git a/static/images/bg/brickwall.png b/cmd/webapp/static/images/bg/brickwall.png similarity index 100% rename from static/images/bg/brickwall.png rename to cmd/webapp/static/images/bg/brickwall.png diff --git a/static/images/bg/brickwall2.png b/cmd/webapp/static/images/bg/brickwall2.png similarity index 100% rename from static/images/bg/brickwall2.png rename to cmd/webapp/static/images/bg/brickwall2.png diff --git a/static/images/bg/brushed.png b/cmd/webapp/static/images/bg/brushed.png similarity index 100% rename from static/images/bg/brushed.png rename to cmd/webapp/static/images/bg/brushed.png diff --git a/static/images/bg/creampaper.png b/cmd/webapp/static/images/bg/creampaper.png similarity index 100% rename from static/images/bg/creampaper.png rename to cmd/webapp/static/images/bg/creampaper.png diff --git a/static/images/bg/notebook.png b/cmd/webapp/static/images/bg/notebook.png similarity index 100% rename from static/images/bg/notebook.png rename to cmd/webapp/static/images/bg/notebook.png diff --git a/static/images/bg/notebook2.png b/cmd/webapp/static/images/bg/notebook2.png similarity index 100% rename from static/images/bg/notebook2.png rename to cmd/webapp/static/images/bg/notebook2.png diff --git a/static/images/bg/old_moon.png b/cmd/webapp/static/images/bg/old_moon.png similarity index 100% rename from static/images/bg/old_moon.png rename to cmd/webapp/static/images/bg/old_moon.png diff --git a/static/images/bg/sos.png b/cmd/webapp/static/images/bg/sos.png similarity index 100% rename from static/images/bg/sos.png rename to cmd/webapp/static/images/bg/sos.png diff --git a/static/images/bg/squared_metal.png b/cmd/webapp/static/images/bg/squared_metal.png similarity index 100% rename from static/images/bg/squared_metal.png rename to cmd/webapp/static/images/bg/squared_metal.png diff --git a/static/images/bg/textured_paper.png b/cmd/webapp/static/images/bg/textured_paper.png similarity index 100% rename from static/images/bg/textured_paper.png rename to cmd/webapp/static/images/bg/textured_paper.png diff --git a/static/images/dropbox-icon.png b/cmd/webapp/static/images/dropbox-icon.png similarity index 100% rename from static/images/dropbox-icon.png rename to cmd/webapp/static/images/dropbox-icon.png diff --git a/static/images/gplus-icon.svg b/cmd/webapp/static/images/gplus-icon.svg similarity index 100% rename from static/images/gplus-icon.svg rename to cmd/webapp/static/images/gplus-icon.svg diff --git a/static/images/gplus.png b/cmd/webapp/static/images/gplus.png similarity index 100% rename from static/images/gplus.png rename to cmd/webapp/static/images/gplus.png diff --git a/static/images/harv_grad.jpg b/cmd/webapp/static/images/harv_grad.jpg similarity index 100% rename from static/images/harv_grad.jpg rename to cmd/webapp/static/images/harv_grad.jpg diff --git a/static/images/linkedin.png b/cmd/webapp/static/images/linkedin.png similarity index 100% rename from static/images/linkedin.png rename to cmd/webapp/static/images/linkedin.png diff --git a/static/images/me_camping.jpg b/cmd/webapp/static/images/me_camping.jpg similarity index 100% rename from static/images/me_camping.jpg rename to cmd/webapp/static/images/me_camping.jpg diff --git a/static/images/me_in_wyoming.jpg b/cmd/webapp/static/images/me_in_wyoming.jpg similarity index 100% rename from static/images/me_in_wyoming.jpg rename to cmd/webapp/static/images/me_in_wyoming.jpg diff --git a/static/images/my_email.jpg b/cmd/webapp/static/images/my_email.jpg similarity index 100% rename from static/images/my_email.jpg rename to cmd/webapp/static/images/my_email.jpg diff --git a/static/images/placeholder-cards.png b/cmd/webapp/static/images/placeholder-cards.png similarity index 100% rename from static/images/placeholder-cards.png rename to cmd/webapp/static/images/placeholder-cards.png diff --git a/static/images/twitter.png b/cmd/webapp/static/images/twitter.png similarity index 100% rename from static/images/twitter.png rename to cmd/webapp/static/images/twitter.png diff --git a/index.html b/cmd/webapp/static/index.html similarity index 92% rename from index.html rename to cmd/webapp/static/index.html index 375bb3d..7736b3f 100644 --- a/index.html +++ b/cmd/webapp/static/index.html @@ -41,23 +41,27 @@

Ian Rose, PhD

-

I'm a software engineer based in Atlanta, GA. I help build and run FullStory, - an awesome tool for understanding your website visitors' behavior in detail. Think: pixel-perfect playback plus powerful find-anything search.

+

I'm a software engineer based in Atlanta, GA. I help build and run Wattch, + a complete monitoring & controls hardware/software stack for mid-sized (e.g. commercial and industrial scale) solar installations.

+ +

Previously, I was a founding engineer at FullStory, where I helped grow + the company from the original 8 of us to over 500 employees.

In 2011 I graduated from Harvard with a PhD in computer science, focusing on distributed systems and in particular on wireless networks (both 802.11 and 802.15.4). I then spent a year at Google working on development tools, with an emphasis on backend development. In July of 2012, I left Google to join 6 other ex-googlers at FullStory.

-

For all of the gory details, please see my - C.V.. You can also follow my witty banter on Twitter. +

For all of the gory details, please see my (seldom updated) + C.V. or LinkedIn profile.

- Harvard Graduation + Me Camping

Experience

-

2012-present: Software Engineer / Tech Lead, FullStory

+

2022-present: Senior Engineer, Wattch

+

2012-2022: Senior Engineer / Tech Lead / Engineering Manager, FullStory

2011-2012: Software Engineer, Google

2005-2011: PhD graduate student, Harvard University (advisor: Matt Welsh)

2007: Intern, Sun Microsystems, Project Darkstar diff --git a/static/js/pubs.js b/cmd/webapp/static/js/pubs.js similarity index 100% rename from static/js/pubs.js rename to cmd/webapp/static/js/pubs.js diff --git a/talks/argos-sensys10.ppt b/cmd/webapp/talks/argos-sensys10.ppt similarity index 100% rename from talks/argos-sensys10.ppt rename to cmd/webapp/talks/argos-sensys10.ppt diff --git a/talks/cobra-nsdi07.ppt b/cmd/webapp/talks/cobra-nsdi07.ppt similarity index 100% rename from talks/cobra-nsdi07.ppt rename to cmd/webapp/talks/cobra-nsdi07.ppt diff --git a/talks/network-monitoring-apr16.ppt b/cmd/webapp/talks/network-monitoring-apr16.ppt similarity index 100% rename from talks/network-monitoring-apr16.ppt rename to cmd/webapp/talks/network-monitoring-apr16.ppt diff --git a/talks/ssr-update-feb08.odp b/cmd/webapp/talks/ssr-update-feb08.odp similarity index 100% rename from talks/ssr-update-feb08.odp rename to cmd/webapp/talks/ssr-update-feb08.odp diff --git a/templates/albums.html b/cmd/webapp/templates/albums.html similarity index 100% rename from templates/albums.html rename to cmd/webapp/templates/albums.html diff --git a/templates/strava.html b/cmd/webapp/templates/strava.html similarity index 100% rename from templates/strava.html rename to cmd/webapp/templates/strava.html diff --git a/go.mod b/go.mod index 4f9387d..5285618 100644 --- a/go.mod +++ b/go.mod @@ -2,26 +2,13 @@ module github.com/ianrose14/website go 1.18 -require cloud.google.com/go/datastore v1.10.0 +require ( + github.com/mattn/go-sqlite3 v1.14.17 + golang.org/x/crypto v0.12.0 + gopkg.in/yaml.v3 v3.0.1 +) require ( - cloud.google.com/go v0.108.0 // indirect - cloud.google.com/go/compute v1.12.1 // indirect - cloud.google.com/go/compute/metadata v0.2.2 // indirect - github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect - github.com/golang/protobuf v1.5.2 // indirect - github.com/google/go-cmp v0.5.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect - github.com/googleapis/gax-go/v2 v2.7.0 // indirect - go.opencensus.io v0.24.0 // indirect - golang.org/x/net v0.0.0-20221014081412-f15817d10f9b // indirect - golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 // indirect - golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect - golang.org/x/text v0.4.0 // indirect - golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - google.golang.org/api v0.103.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3 // indirect - google.golang.org/grpc v1.51.0 // indirect - google.golang.org/protobuf v1.28.1 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/text v0.12.0 // indirect ) diff --git a/go.sum b/go.sum index 3fb901f..a1c1813 100644 --- a/go.sum +++ b/go.sum @@ -1,137 +1,12 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.108.0 h1:xntQwnfn8oHGX0crLVinvHM+AhXvi3QHQIEcX/2hiWk= -cloud.google.com/go v0.108.0/go.mod h1:lNUfQqusBJp0bgAg6qrHgYFYbTB+dOiob1itwnlD33Q= -cloud.google.com/go/compute v1.12.1 h1:gKVJMEyqV5c/UnpzjjQbo3Rjvvqpr9B1DFSbJC4OXr0= -cloud.google.com/go/compute v1.12.1/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= -cloud.google.com/go/compute/metadata v0.2.2 h1:aWKAjYaBaOSrpKl57+jnS/3fJRQnxL7TvR/u1VVbt6k= -cloud.google.com/go/compute/metadata v0.2.2/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= -cloud.google.com/go/datastore v1.10.0 h1:4siQRf4zTiAVt/oeH4GureGkApgb2vtPQAtOmhpqQwE= -cloud.google.com/go/datastore v1.10.0/go.mod h1:PC5UzAmDEkAmkfaknstTYbNpgE49HAgW2J1gcgUfmdM= -cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/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.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -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/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.2.0 h1:y8Yozv7SZtlU//QXbezB6QkpuE6jMD2/gfzk4AftXjs= -github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= -github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ= -github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20221014081412-f15817d10f9b h1:tvrvnPFcdzp294diPnrdZZZ8XUt2Tyj7svb7X52iDuU= -golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 h1:nt+Q6cXKz4MosCSpnbMtqiQ8Oz0pxTef2B4Vca2lvfk= -golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/api v0.103.0 h1:9yuVqlu2JCvcLg9p8S3fcFLZij8EPSyvODIY1rkMizQ= -google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3 h1:BCcW+lhENGqZ2R2MsM9oty220E8vY9E4QC1Tq05hN1E= -google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U= -google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= -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.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/secrets.go b/internal/secrets.go new file mode 100644 index 0000000..0d10c55 --- /dev/null +++ b/internal/secrets.go @@ -0,0 +1,33 @@ +package internal + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type SecretsFile struct { + Dropbox struct { + AccessToken string `yaml:"accessToken"` + } + Strava struct { + ClientID string `yaml:"clientId"` + ClientSecret string `yaml:"clientSecret"` + } +} + +func ParseSecrets(filename string) (*SecretsFile, error) { + fp, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer fp.Close() + + var dst SecretsFile + if err := yaml.NewDecoder(fp).Decode(&dst); err != nil { + return nil, fmt.Errorf("failed to parse file: %w", err) + } + + return &dst, nil +} diff --git a/internal/storage/db.go b/internal/storage/db.go new file mode 100644 index 0000000..e0565ca --- /dev/null +++ b/internal/storage/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.16.0 + +package storage + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/storage/models.go b/internal/storage/models.go new file mode 100644 index 0000000..8b6d28b --- /dev/null +++ b/internal/storage/models.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.16.0 + +package storage + +import ( + "time" +) + +type StravaToken struct { + Username string + AccessToken string + RefreshToken string + CreatedTime time.Time + ExpiresAt time.Time +} diff --git a/internal/storage/query.sql b/internal/storage/query.sql new file mode 100644 index 0000000..758cea9 --- /dev/null +++ b/internal/storage/query.sql @@ -0,0 +1,7 @@ +-- name: InsertStravaTokens :exec +INSERT OR REPLACE INTO strava_tokens(username, access_token, refresh_token, created_time, expires_at) VALUES (?,?,?,?,?); + +-- name: FetchStravaTokens :one +SELECT access_token, refresh_token, created_time, expires_at + FROM strava_tokens + WHERE username=?; diff --git a/internal/storage/query.sql.go b/internal/storage/query.sql.go new file mode 100644 index 0000000..cb225d2 --- /dev/null +++ b/internal/storage/query.sql.go @@ -0,0 +1,59 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.16.0 +// source: query.sql + +package storage + +import ( + "context" + "time" +) + +const fetchStravaTokens = `-- name: FetchStravaTokens :one +SELECT access_token, refresh_token, created_time, expires_at + FROM strava_tokens + WHERE username=? +` + +type FetchStravaTokensRow struct { + AccessToken string + RefreshToken string + CreatedTime time.Time + ExpiresAt time.Time +} + +func (q *Queries) FetchStravaTokens(ctx context.Context, username string) (FetchStravaTokensRow, error) { + row := q.db.QueryRowContext(ctx, fetchStravaTokens, username) + var i FetchStravaTokensRow + err := row.Scan( + &i.AccessToken, + &i.RefreshToken, + &i.CreatedTime, + &i.ExpiresAt, + ) + return i, err +} + +const insertStravaTokens = `-- name: InsertStravaTokens :exec +INSERT OR REPLACE INTO strava_tokens(username, access_token, refresh_token, created_time, expires_at) VALUES (?,?,?,?,?) +` + +type InsertStravaTokensParams struct { + Username string + AccessToken string + RefreshToken string + CreatedTime time.Time + ExpiresAt time.Time +} + +func (q *Queries) InsertStravaTokens(ctx context.Context, arg InsertStravaTokensParams) error { + _, err := q.db.ExecContext(ctx, insertStravaTokens, + arg.Username, + arg.AccessToken, + arg.RefreshToken, + arg.CreatedTime, + arg.ExpiresAt, + ) + return err +} diff --git a/internal/storage/schema.sql b/internal/storage/schema.sql new file mode 100644 index 0000000..840a669 --- /dev/null +++ b/internal/storage/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS strava_tokens ( + username TEXT NOT NULL PRIMARY KEY, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + created_time DATE NOT NULL, + expires_at DATE NOT NULL +); diff --git a/internal/storage/sqlc.yaml b/internal/storage/sqlc.yaml new file mode 100644 index 0000000..6028ce0 --- /dev/null +++ b/internal/storage/sqlc.yaml @@ -0,0 +1,7 @@ +version: 1 +packages: + - path: "." + name: "storage" + engine: "sqlite" + schema: "schema.sql" + queries: "query.sql" diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..e1ba81c --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,34 @@ +package storage + +import ( + "context" + "database/sql" + _ "embed" + "fmt" +) + +var ( + //go:embed schema.sql + schema string +) + +func Str(s string) sql.NullString { + if s != "" { + return sql.NullString{String: s, Valid: true} + } + return sql.NullString{} +} + +func UpsertDatabaseTables(ctx context.Context, db *sql.DB) error { + conn, err := db.Conn(ctx) + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + defer conn.Close() + + _, err = conn.ExecContext(ctx, schema) + if err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + return nil +} diff --git a/internal/strava/handlers.go b/internal/strava/handlers.go new file mode 100644 index 0000000..1881010 --- /dev/null +++ b/internal/strava/handlers.go @@ -0,0 +1,160 @@ +package strava + +import ( + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/ianrose14/website/internal" + "github.com/ianrose14/website/internal/storage" +) + +func Handler(w http.ResponseWriter, r *http.Request, tmpl *template.Template, db KVDB, account *ApiParams) { + year := time.Now().Year() + if s := r.URL.Query().Get("year"); s != "" { + if i, err := strconv.Atoi(s); err == nil { + year = i + } + } + + username := r.URL.Query().Get("username") + if username == "" { + c, err := r.Cookie("username") + if err == nil { + username = c.Value + } + } + + if username == "" { + http.Redirect(w, r, getAuthUrl(account), http.StatusTemporaryRedirect) + return + } + + accessToken, err := readAccessToken(r.Context(), username, db, account) + if err != nil { + if err == ErrNeedsAuth { + http.Redirect(w, r, getAuthUrl(account), http.StatusTemporaryRedirect) + return + } + + http.Error(w, fmt.Sprintf("failed to read access token: %s", err), http.StatusInternalServerError) + return + } + + profile, err := getProfile(accessToken) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get profile info: %s", err), http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "username", + Value: username, + Expires: time.Now().Add(7 * 24 * time.Hour), + }) + + now := time.Now() + goalMiles := defaultGoalMiles[year] + queryStart := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC) + + var scaledGoalMiles float64 + var queryEnd time.Time + + if now.Year() == year { + scaledGoalMiles = float64(goalMiles) * float64(now.YearDay()) / 365 + queryEnd = queryStart.AddDate(0, 0, now.YearDay()) // finish is intentionally midnight at the END of the day + } else { + scaledGoalMiles = float64(goalMiles) + queryEnd = time.Date(year+1, time.January, 1, 0, 0, 0, 0, now.Location()) // Midnight, start of new years day + } + + activities, err := doStravaQuery(r.Context(), username, queryStart, queryEnd, db, account) + if err != nil { + http.Error(w, fmt.Sprintf("failed to query strava: %s", err), http.StatusInternalServerError) + return + } + + args := struct { + Username string + Activities []string + MilesTotal string + MilesYearGoal int + MilesScaledGoal string + Progress string + GaugeRotate int + }{ + Username: profile.Username, + MilesYearGoal: goalMiles, + MilesScaledGoal: fmt.Sprintf("%.1f", scaledGoalMiles), + } + + var sumMiles float64 + for _, activity := range activities { + if activity.Type != "Run" { + continue + } + secondsPerMile := int64(activity.MovingTime/activity.Miles() + 0.5000001) + args.Activities = append(args.Activities, + fmt.Sprintf("%s: %.1fK (%.1f miles) in %s (%d:%02d pace) on %s", activity.Name, + activity.DistanceMeters/1000., activity.Miles(), + formatSeconds(activity.MovingTime), secondsPerMile/60, secondsPerMile%60, + activity.StartDate)) + + sumMiles += activity.Miles() + } + + progress := 100 * sumMiles / scaledGoalMiles + + args.MilesTotal = fmt.Sprintf("%.1f", sumMiles) + args.Progress = fmt.Sprintf("%.0f", progress) + args.GaugeRotate = int(90.0 * progress / 100) + + if err := tmpl.Execute(w, &args); err != nil { + internal.HttpError(w, http.StatusInternalServerError, "failed to render template: %s", err) + return + } +} + +func TokenHandler(w http.ResponseWriter, r *http.Request, db KVDB, account *ApiParams) { + code := r.URL.Query().Get("code") + if code == "" { + return + } + + rsp, err := exchangeToken(code, account) + if err != nil { + http.Error(w, fmt.Sprintf("failure in token exchange: %s", err), http.StatusInternalServerError) + return + } + + arg := storage.InsertStravaTokensParams{ + AccessToken: rsp.AccessToken, + RefreshToken: rsp.RefreshToken, + CreatedTime: time.Now(), + ExpiresAt: time.Unix(rsp.ExpiresAt, 0), + } + + profile, err := getProfile(rsp.AccessToken) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get profile info: %s", err), http.StatusInternalServerError) + return + } + + arg.Username = profile.Username + + if err := db.Write(r.Context(), &arg); err != nil { + http.Error(w, fmt.Sprintf("failure in write tokens to db: %s", err), http.StatusInternalServerError) + return + } + + qs := make(url.Values) + qs.Set("username", profile.Username) + qs.Set("year", strconv.Itoa(time.Now().Year())) + urlStr := "/running/?" + qs.Encode() + log.Printf("successful token exchange, redirecting to %s", urlStr) + http.Redirect(w, r, urlStr, http.StatusTemporaryRedirect) +} diff --git a/strava.go b/internal/strava/strava.go similarity index 65% rename from strava.go rename to internal/strava/strava.go index 8ea9966..fd47501 100644 --- a/strava.go +++ b/internal/strava/strava.go @@ -1,8 +1,8 @@ -package main +package strava import ( - "bufio" "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -10,16 +10,11 @@ import ( "log" "net/http" "net/url" - "os" - "strings" "sync" "time" - "cloud.google.com/go/datastore" -) - -const ( - StravaClientId = "59096" + "github.com/ianrose14/website/internal" + "github.com/ianrose14/website/internal/storage" ) var ( @@ -34,23 +29,23 @@ var ( } ) -type StravaTokens struct { - AccessToken string - ExpiresAt time.Time - RefreshToken string +type ApiParams struct { + ClientId string + ClientSecret string + Hostname string } type KVDB interface { - Read(ctx context.Context, key string) (*StravaTokens, error) - Write(ctx context.Context, key string, tokens *StravaTokens) error + Read(ctx context.Context, username string) (*storage.FetchStravaTokensRow, error) + Write(ctx context.Context, tokens *storage.InsertStravaTokensParams) error } type FileDatabase struct { filepath string } -func (db *FileDatabase) Read(ctx context.Context, key string) (*StravaTokens, error) { - if !FileExists(db.filepath) { +func (db *FileDatabase) Read(_ context.Context, key string) (*storage.FetchStravaTokensRow, error) { + if !internal.FileExists(db.filepath) { return nil, nil } @@ -59,7 +54,7 @@ func (db *FileDatabase) Read(ctx context.Context, key string) (*StravaTokens, er return nil, err } - m := make(map[string]*StravaTokens) + m := make(map[string]*storage.FetchStravaTokensRow) if err := json.Unmarshal(contents, &m); err != nil { return nil, err } @@ -67,10 +62,10 @@ func (db *FileDatabase) Read(ctx context.Context, key string) (*StravaTokens, er return m[key], nil } -func (db *FileDatabase) Write(ctx context.Context, key string, tokens *StravaTokens) error { - m := make(map[string]*StravaTokens) +func (db *FileDatabase) Write(_ context.Context, key string, tokens *storage.FetchStravaTokensRow) error { + m := make(map[string]*storage.FetchStravaTokensRow) - if FileExists(db.filepath) { + if internal.FileExists(db.filepath) { contents, err := ioutil.ReadFile(db.filepath) if err != nil { return err @@ -91,43 +86,50 @@ func (db *FileDatabase) Write(ctx context.Context, key string, tokens *StravaTok } type MemoryDatabase struct { - vals map[string]*StravaTokens + vals map[string]*storage.FetchStravaTokensRow mu sync.Mutex } -func (db *MemoryDatabase) Read(ctx context.Context, key string) (*StravaTokens, error) { +func (db *MemoryDatabase) Read(_ context.Context, username string) (*storage.FetchStravaTokensRow, error) { db.mu.Lock() defer db.mu.Unlock() - return db.vals[key], nil + return db.vals[username], nil } -func (db *MemoryDatabase) Write(ctx context.Context, key string, tokens *StravaTokens) error { +func (db *MemoryDatabase) Write(_ context.Context, tokens *storage.InsertStravaTokensParams) error { db.mu.Lock() defer db.mu.Unlock() - db.vals[key] = tokens + db.vals[tokens.Username] = &storage.FetchStravaTokensRow{ + AccessToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + CreatedTime: time.Now(), + ExpiresAt: tokens.ExpiresAt, + } return nil } -type DatastoreDb struct { - client *datastore.Client +type SqliteDb struct { + query *storage.Queries +} + +func NewSqliteDb(db *sql.DB) KVDB { + return &SqliteDb{query: storage.New(db)} } -func (db *DatastoreDb) Read(ctx context.Context, key string) (*StravaTokens, error) { - k := datastore.NameKey("StravaTokens", key, nil) - var tokens StravaTokens - if err := db.client.Get(ctx, k, &tokens); err != nil { - if err == datastore.ErrNoSuchEntity { +func (db *SqliteDb) Read(ctx context.Context, username string) (*storage.FetchStravaTokensRow, error) { + row, err := db.query.FetchStravaTokens(ctx, username) + if err != nil { + if err == sql.ErrNoRows { return nil, nil } return nil, err } - return &tokens, nil + + return &row, nil } -func (db *DatastoreDb) Write(ctx context.Context, key string, tokens *StravaTokens) error { - k := datastore.NameKey("StravaTokens", key, nil) - _, err := db.client.Put(ctx, k, tokens) - return err +func (db *SqliteDb) Write(ctx context.Context, tokens *storage.InsertStravaTokensParams) error { + return db.query.InsertStravaTokens(ctx, *tokens) } type Activity struct { @@ -154,8 +156,8 @@ type AuthResponse struct { AccessToken string `json:"access_token"` } -func doStravaQuery(ctx context.Context, sessionId string, start, finish time.Time, db KVDB) ([]Activity, error) { - accessToken, err := readAccessToken(ctx, sessionId, db) +func doStravaQuery(ctx context.Context, sessionId string, start, finish time.Time, db KVDB, account *ApiParams) ([]Activity, error) { + accessToken, err := readAccessToken(ctx, sessionId, db, account) if err != nil { return nil, fmt.Errorf("failed to read access token: %s", err) } @@ -163,7 +165,7 @@ func doStravaQuery(ctx context.Context, sessionId string, start, finish time.Tim return getActivities(accessToken, start, finish) } -func readAccessToken(ctx context.Context, username string, db KVDB) (string, error) { +func readAccessToken(ctx context.Context, username string, db KVDB, account *ApiParams) (string, error) { // read most recent refresh token tokens, err := db.Read(ctx, username) if err != nil { @@ -174,8 +176,8 @@ func readAccessToken(ctx context.Context, username string, db KVDB) (string, err } vals := make(url.Values) - vals.Set("client_id", StravaClientId) - vals.Set("client_secret", StravaClientSecret) + vals.Set("client_id", account.ClientId) + vals.Set("client_secret", account.ClientSecret) vals.Set("grant_type", "refresh_token") vals.Set("refresh_token", tokens.RefreshToken) @@ -183,7 +185,7 @@ func readAccessToken(ctx context.Context, username string, db KVDB) (string, err if err != nil { return "", fmt.Errorf("failed to post: %s", err) } - if err := CheckResponse(rsp); err != nil { + if err := internal.CheckResponse(rsp); err != nil { return "", fmt.Errorf("failed to post: %s", err) } @@ -198,25 +200,64 @@ func readAccessToken(ctx context.Context, username string, db KVDB) (string, err if err := json.NewDecoder(rsp.Body).Decode(&update); err != nil { return "", fmt.Errorf("failed to parse response: %s", err) } - DrainAndClose(rsp.Body) + internal.DrainAndClose(rsp.Body) if update.TokenType != "Bearer" { return "", fmt.Errorf("unexpected returned TokenType: %q", update.TokenType) } - tokens = &StravaTokens{ + arg := storage.InsertStravaTokensParams{ + Username: username, AccessToken: update.AccessToken, - ExpiresAt: time.Unix(update.ExpiresAt, 0), RefreshToken: update.RefreshToken, + CreatedTime: time.Now(), + ExpiresAt: time.Unix(update.ExpiresAt, 0), } - if err := db.Write(ctx, username, tokens); err != nil { + if err := db.Write(ctx, &arg); err != nil { return "", fmt.Errorf("failed to write tokens to db: %s", err) } return update.AccessToken, nil } +func exchangeToken(code string, account *ApiParams) (*AuthResponse, error) { + vals := make(url.Values) + vals.Set("client_id", account.ClientId) + vals.Set("client_secret", account.ClientSecret) + vals.Set("code", code) + vals.Set("grant_type", "authorization_code") + + rsp, err := http.DefaultClient.PostForm("https://www.strava.com/oauth/token", vals) + if err != nil { + return nil, fmt.Errorf("failed to post: %s", err) + } + if err := internal.CheckResponse(rsp); err != nil { + return nil, fmt.Errorf("failed to post: %s", err) + } + + var authResp AuthResponse + if err := json.NewDecoder(rsp.Body).Decode(&authResp); err != nil { + return nil, err + } + + return &authResp, nil +} + +func formatSeconds(s float64) string { + minutes := int(s / 60.) + return fmt.Sprintf("%d:%02.0f", minutes, s-float64(60*minutes)) +} + +func getAuthUrl(account *ApiParams) string { + host := account.Hostname + if host == "" { + + } + + return fmt.Sprintf("https://www.strava.com/oauth/authorize?client_id=" + account.ClientId + "&response_type=code&redirect_uri=https://" + account.Hostname + "/strava/exchange_token/&approval_prompt=force&scope=activity:read_all") +} + func getActivities(accessToken string, start, finish time.Time) ([]Activity, error) { urls := fmt.Sprintf("https://www.strava.com/api/v3/athlete/activities?per_page=200&before=%d&after=%d", finish.Unix(), start.Unix()) req, err := http.NewRequest("GET", urls, nil) @@ -229,9 +270,9 @@ func getActivities(accessToken string, start, finish time.Time) ([]Activity, err if err != nil { return nil, fmt.Errorf("failed to get: %s", err) } - defer DrainAndClose(rsp.Body) + defer internal.DrainAndClose(rsp.Body) - if err := CheckResponse(rsp); err != nil { + if err := internal.CheckResponse(rsp); err != nil { return nil, fmt.Errorf("failed to get: %s", err) } @@ -255,9 +296,9 @@ func getProfile(accessToken string) (*ProfileInfo, error) { if err != nil { return nil, fmt.Errorf("failed to get: %s", err) } - defer DrainAndClose(rsp.Body) + defer internal.DrainAndClose(rsp.Body) - if err := CheckResponse(rsp); err != nil { + if err := internal.CheckResponse(rsp); err != nil { return nil, fmt.Errorf("failed to get: %s", err) } @@ -268,55 +309,3 @@ func getProfile(accessToken string) (*ProfileInfo, error) { return &profile, nil } - -func formatSeconds(s float64) string { - minutes := int(s / 60.) - return fmt.Sprintf("%d:%02.0f", minutes, s-float64(60*minutes)) -} - -func StravaAuthUrl(hostname string) string { - return fmt.Sprintf("https://www.strava.com/oauth/authorize?client_id=" + StravaClientId + "&response_type=code&redirect_uri=https://" + hostname + "/strava/exchange_token/&approval_prompt=force&scope=activity:read_all") -} - -func doAuthFlow() error { - fmt.Println("Visit: " + StravaAuthUrl("localhost")) - - fmt.Println("") - fmt.Printf("Enter code from redirect URL: ") - scanner := bufio.NewScanner(os.Stdin) - if !scanner.Scan() { - return errors.New("cancelled") - } - code := strings.TrimSpace(scanner.Text()) - - rsp, err := StravaExchangeToken(code) - if err != nil { - return err - } - - fmt.Printf("%+v\n", rsp) - return nil -} - -func StravaExchangeToken(code string) (*AuthResponse, error) { - vals := make(url.Values) - vals.Set("client_id", StravaClientId) - vals.Set("client_secret", StravaClientSecret) - vals.Set("code", code) - vals.Set("grant_type", "authorization_code") - - rsp, err := http.DefaultClient.PostForm("https://www.strava.com/oauth/token", vals) - if err != nil { - return nil, fmt.Errorf("failed to post: %s", err) - } - if err := CheckResponse(rsp); err != nil { - return nil, fmt.Errorf("failed to post: %s", err) - } - - var authResp AuthResponse - if err := json.NewDecoder(rsp.Body).Decode(&authResp); err != nil { - return nil, err - } - - return &authResp, nil -} diff --git a/util.go b/internal/util.go similarity index 99% rename from util.go rename to internal/util.go index 27304be..b0a18ba 100644 --- a/util.go +++ b/internal/util.go @@ -1,4 +1,4 @@ -package main +package internal import ( "fmt" diff --git a/main.go b/main.go deleted file mode 100644 index db45a0d..0000000 --- a/main.go +++ /dev/null @@ -1,387 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "html/template" - "io" - "io/ioutil" - "log" - "net/http" - "net/url" - "os" - "strconv" - "strings" - "time" - - "cloud.google.com/go/datastore" -) - -const ( - AlbumsConfigUrl = "https://www.dropbox.com/s/kr8ewc68husts57/albums.json?dl=1" - KidLinksConfigUrl = "https://www.dropbox.com/s/5vdvc3l1pkly94f/weblinks.json?dl=1" -) - -var ( - albumsTemplate = template.Must(template.ParseFiles("templates/albums.html")) - kidLinksTemplate = template.Must(template.ParseFiles("templates/kidlinks.html")) - stravaTemplate = template.Must(template.ParseFiles("templates/strava.html")) - - stravaVars = &MemoryDatabase{vals: make(map[string]*StravaTokens)} -) - -type album struct { - Name string `json:"name"` - Url string `json:"url"` - CoverPath string `json:"cover_path"` - CoverUrl string -} - -// Serves a page that lists all available photo albums. -func AlbumsHandler(w http.ResponseWriter, _ *http.Request) { - rsp, err := http.Get(AlbumsConfigUrl) - if err != nil { - HttpError(w, http.StatusInternalServerError, "failed to fetch albums config from dropbox: %s", err) - return - } - defer DrainAndClose(rsp.Body) - - if err := CheckResponse(rsp); err != nil { - HttpError(w, http.StatusInternalServerError, "failed to fetch albums config from dropbox: %s", err) - return - } - - var results struct { - Albums []*album `json:"albums"` - } - - if err := json.NewDecoder(rsp.Body).Decode(&results); err != nil { - HttpError(w, http.StatusInternalServerError, "failed to json-decode response: %s", err) - return - } - - for _, album := range results.Albums { - album.CoverUrl = fmt.Sprintf("/albums/thumbnail?path=%s", url.QueryEscape(album.CoverPath)) - } - - if err := albumsTemplate.Execute(w, results.Albums); err != nil { - HttpError(w, http.StatusInternalServerError, "failed to render template: %s", err) - return - } -} - -func DumpHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - - writeIt := func(s string, args ...interface{}) { - log.Printf(s, args...) - fmt.Fprintf(w, s+"\n", args...) - } - - writeIt("URL: %s", r.URL) - writeIt("Method: %s", r.Method) - writeIt("Proto: %s", r.Proto) - writeIt("RemoteAddr: %s", r.RemoteAddr) - - var buf bytes.Buffer - buf.WriteString("Headers:\n") - for k, v := range r.Header { - fmt.Fprintf(&buf, "%s: %v\n", k, v) - } - writeIt("%s", buf.String()) - fmt.Fprintln(w, "") // write blank line to response body - - buf.Reset() - buf.WriteString("Cookies:\n") - for _, c := range r.Cookies() { - fmt.Fprintf(&buf, "%s\n", c) - } - writeIt("%s", buf.String()) - fmt.Fprintln(w, "") // write blank line to response body - - b, err := ioutil.ReadAll(r.Body) - if err != nil { - writeIt("error: failed to read body: %s", err) - } else { - buf.Reset() - if _, err := base64.NewEncoder(base64.StdEncoding, &buf).Write(b); err != nil { - writeIt("error: failed to base64-encode body: %s", err) - } else { - writeIt("Body (base64):\n%s\n", buf.String()) - } - fmt.Fprintln(w, "") // write blank line to response body - - writeIt("Body (raw):\n%s", string(b)) - fmt.Fprintln(w, "") // write blank line to response body - } -} - -type links struct { - Sections []struct { - Title string `json:"title"` - Links []struct { - Href string `json:"href"` - Text string `json:"text"` - Notes string `json:"notes"` - } `json:"links"` - } `json:"sections"` -} - -func KidsLinksHandler(w http.ResponseWriter, _ *http.Request) { - rsp, err := http.Get(KidLinksConfigUrl) - if err != nil { - HttpError(w, http.StatusInternalServerError, "failed to fetch kid links config from dropbox: %s", err) - return - } - defer DrainAndClose(rsp.Body) - - if err := CheckResponse(rsp); err != nil { - HttpError(w, http.StatusInternalServerError, "failed to fetch kid links config from dropbox: %s", err) - return - } - - var results links - if err := json.NewDecoder(rsp.Body).Decode(&results); err != nil { - HttpError(w, http.StatusInternalServerError, "failed to json-decode response: %s", err) - return - } - - if err := kidLinksTemplate.Execute(w, &results); err != nil { - HttpError(w, http.StatusInternalServerError, "failed to render template: %s", err) - return - } -} - -func StravaHandler(w http.ResponseWriter, r *http.Request, db KVDB) { - year := time.Now().Year() - if s := r.URL.Query().Get("year"); s != "" { - if i, err := strconv.Atoi(s); err == nil { - year = i - } - } - - username := r.URL.Query().Get("username") - if username == "" { - c, err := r.Cookie("username") - if err == nil { - username = c.Value - } - } - - if username == "" { - http.Redirect(w, r, StravaAuthUrl("www.ianthomasrose.com"), http.StatusTemporaryRedirect) - return - } - - accessToken, err := readAccessToken(r.Context(), username, db) - if err != nil { - if err == ErrNeedsAuth { - http.Redirect(w, r, StravaAuthUrl("www.ianthomasrose.com"), http.StatusTemporaryRedirect) - return - } - - http.Error(w, fmt.Sprintf("failed to read access token: %s", err), http.StatusInternalServerError) - return - } - - profile, err := getProfile(accessToken) - if err != nil { - http.Error(w, fmt.Sprintf("failed to get profile info: %s", err), http.StatusInternalServerError) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: "username", - Value: username, - Expires: time.Now().Add(7 * 24 * time.Hour), - }) - - now := time.Now() - goalMiles := defaultGoalMiles[year] - queryStart := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC) - - var scaledGoalMiles float64 - var queryEnd time.Time - - if now.Year() == year { - scaledGoalMiles = float64(goalMiles) * float64(now.YearDay()) / 365 - queryEnd = queryStart.AddDate(0, 0, now.YearDay()) // finish is intentionally midnight at the END of the day - } else { - scaledGoalMiles = float64(goalMiles) - queryEnd = time.Date(year+1, time.January, 1, 0, 0, 0, 0, now.Location()) // Midnight, start of new years day - } - - activities, err := doStravaQuery(r.Context(), username, queryStart, queryEnd, db) - if err != nil { - http.Error(w, fmt.Sprintf("failed to query strava: %s", err), http.StatusInternalServerError) - return - } - - args := struct { - Username string - Activities []string - MilesTotal string - MilesYearGoal int - MilesScaledGoal string - Progress string - GaugeRotate int - }{ - Username: profile.Username, - MilesYearGoal: goalMiles, - MilesScaledGoal: fmt.Sprintf("%.1f", scaledGoalMiles), - } - - var sumMiles float64 - for _, activity := range activities { - if activity.Type != "Run" { - continue - } - secondsPerMile := int64(activity.MovingTime/activity.Miles() + 0.5000001) - args.Activities = append(args.Activities, - fmt.Sprintf("%s: %.1fK (%.1f miles) in %s (%d:%02d pace) on %s", activity.Name, - activity.DistanceMeters/1000., activity.Miles(), - formatSeconds(activity.MovingTime), secondsPerMile/60, secondsPerMile%60, - activity.StartDate)) - - sumMiles += activity.Miles() - } - - progress := 100 * sumMiles / scaledGoalMiles - - args.MilesTotal = fmt.Sprintf("%.1f", sumMiles) - args.Progress = fmt.Sprintf("%.0f", progress) - args.GaugeRotate = int(90.0 * progress / 100) - - if err := stravaTemplate.Execute(w, &args); err != nil { - HttpError(w, http.StatusInternalServerError, "failed to render template: %s", err) - return - } -} - -func StravaTokenHandler(w http.ResponseWriter, r *http.Request, db KVDB) { - code := r.URL.Query().Get("code") - if code == "" { - return - } - - rsp, err := StravaExchangeToken(code) - if err != nil { - http.Error(w, fmt.Sprintf("failure in token exchange: %s", err), http.StatusInternalServerError) - return - } - - tokens := StravaTokens{ - AccessToken: rsp.AccessToken, - ExpiresAt: time.Unix(rsp.ExpiresAt, 0), - RefreshToken: rsp.RefreshToken, - } - - profile, err := getProfile(rsp.AccessToken) - if err != nil { - http.Error(w, fmt.Sprintf("failed to get profile info: %s", err), http.StatusInternalServerError) - return - } - - if err := db.Write(r.Context(), profile.Username, &tokens); err != nil { - http.Error(w, fmt.Sprintf("failure in write tokens to db: %s", err), http.StatusInternalServerError) - return - } - - http.Redirect(w, r, "/running/?username="+url.QueryEscape(profile.Username)+"&year="+strconv.Itoa(time.Now().Year()), http.StatusTemporaryRedirect) -} - -func ThumbnailHandler(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - HttpError(w, http.StatusBadRequest, "no path query") - return - } - - if !strings.HasPrefix(path, "/photos/") { - HttpError(w, http.StatusBadRequest, "rejecting forbidden path %s", path) - return - } - - params := struct { - Path string `json:"path"` - Format string `json:"format"` - Size string `json:"size"` - }{ - Path: path, - Format: "jpeg", - Size: "w640h480", - } - - jstr, err := json.Marshal(¶ms) - if err != nil { - HttpError(w, http.StatusInternalServerError, "failed to json-encode params: %s", err) - return - } - - qs := make(url.Values) - qs.Add("arg", string(jstr)) - - urls := "https://api-content.dropbox.com/2/files/get_thumbnail?" + qs.Encode() - log.Printf("fetching %s", urls) - - req, err := http.NewRequest("GET", urls, nil) - if err != nil { - HttpError(w, http.StatusInternalServerError, "failed to make new http request") - return - } - - req.Header.Set("Authorization", "Bearer "+DropboxAccessToken) - - rsp, err := http.DefaultClient.Do(req) - if err != nil { - HttpError(w, http.StatusInternalServerError, "failed to fetch thumbnail from dropbox: %s", err) - return - } - defer DrainAndClose(rsp.Body) - - if err := CheckResponse(rsp); err != nil { - HttpError(w, http.StatusInternalServerError, "failed to fetch thumbnail from dropbox: %s", err) - return - } - - w.Header().Set("Content-Type", rsp.Header.Get("Content-Type")) - if _, err := io.Copy(w, rsp.Body); err != nil { - log.Printf("failed to copy thumbnail body to response stream: %s", err) - } -} - -func main() { - client, err := datastore.NewClient(context.Background(), os.Getenv("GOOGLE_CLOUD_PROJECT")) - if err != nil { - log.Fatalf("failed to connect to datastore: %s", err) - } - - ddb := &DatastoreDb{ - client: client, - } - - http.HandleFunc("/albums/", AlbumsHandler) - http.HandleFunc("/albums/thumbnail", ThumbnailHandler) - http.HandleFunc("/dump", DumpHandler) - http.HandleFunc("/kids/", KidsLinksHandler) - http.HandleFunc("/running/", func(w http.ResponseWriter, r *http.Request) { - StravaHandler(w, r, ddb) - }) - http.HandleFunc("/strava/exchange_token/", func(w http.ResponseWriter, r *http.Request) { - StravaTokenHandler(w, r, ddb) - }) - - port := os.Getenv("PORT") - if port == "" { - port = "8080" - log.Printf("Defaulting to port %s", port) - } - - log.Printf("Listening on port %s", port) - if err := http.ListenAndServe(":"+port, nil); err != nil { - log.Fatal(err) - } -} diff --git a/robots.txt b/robots.txt deleted file mode 100644 index eb05362..0000000 --- a/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..1164f67 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -e + +# Getting permissions errors on local when trying to push? Run this: +# gcloud auth configure-docker us-central1-docker.pkg.dev + + +# Run on initial instance creation: +# > sudo apt-get update +# > sudo apt-get install podman +# > systemctl --user enable podman.socket +# > loginctl enable-linger ianrose14 +# add "net.ipv4.ip_unprivileged_port_start=80" to /etc/sysctl.conf + +# make webapp-linux + +#docker buildx build --platform linux/amd64 --build-arg BIN=bin/linux_amd64/webapp -t us-central1-docker.pkg.dev/ian-rose/docker-1/webapp:latest . +#docker build -t us-central1-docker.pkg.dev/ian-rose/docker-1/webapp:latest . +#docker push us-central1-docker.pkg.dev/ian-rose/docker-1/webapp:latest + +gcloud compute --project ian-rose ssh ianrose14@instance-1 --zone us-central1-a -- mkdir -p config/ +gcloud compute --project ian-rose scp --zone us-central1-a config/* ianrose14@instance-1:config/ +gcloud compute --project ian-rose scp --zone us-central1-a scripts/startup.sh ianrose14@instance-1: +gcloud compute --project ian-rose ssh ianrose14@instance-1 --zone us-central1-a -- bash ./startup.sh diff --git a/deploy.sh b/scripts/login.sh similarity index 52% rename from deploy.sh rename to scripts/login.sh index eaf417c..5e8f750 100755 --- a/deploy.sh +++ b/scripts/login.sh @@ -3,4 +3,5 @@ set -e gcloud auth login ianrose14@gmail.com -gcloud --project ian-rose app deploy +gcloud config set project ian-rose +gcloud config list diff --git a/scripts/startup.sh b/scripts/startup.sh new file mode 100755 index 0000000..65fdfda --- /dev/null +++ b/scripts/startup.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +(podman stop webapp) || true +(podman rm webapp) || true + +mkdir -p data + +# ref 1: https://stackoverflow.com/questions/63790529/authenticate-to-google-container-registry-with-podman +# ref 2: https://github.com/containers/podman/issues/13691#issuecomment-1081913637 +gcloud auth --quiet print-access-token | podman login -u oauth2accesstoken --password-stdin us-central1-docker.pkg.dev +podman pull us-central1-docker.pkg.dev/ian-rose/docker-1/webapp:latest +podman run -d -p 80:80 -p 443:443 --restart=no \ + -v "$(pwd)/config":/root/config/ -v "$(pwd)/data":/root/data/ --name webapp \ + us-central1-docker.pkg.dev/ian-rose/docker-1/webapp:latest /root/webapp -host ianthomasrose.com -certs data/certs/ diff --git a/templates/kidlinks.html b/templates/kidlinks.html deleted file mode 100644 index 8987091..0000000 --- a/templates/kidlinks.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - -

-
Kids' Links
-
- {{ range .Sections }} -

{{ .Title }}

- - {{ end }} -
-
- -