Skip to content

Commit 62a52f2

Browse files
committed
Initial commit
0 parents  commit 62a52f2

File tree

7 files changed

+443
-0
lines changed

7 files changed

+443
-0
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.envrc

Readme.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# GitHub Polls
2+
3+
User polls for GitHub powered by [Up](github.com/apex/up).
4+
5+
[![](https://m131jyck4m.execute-api.us-west-2.amazonaws.com/prod/poll/01BM2T00TMSDTZWJ1RP6TQF200/Option%20A)](https://m131jyck4m.execute-api.us-west-2.amazonaws.com/prod/poll/01BM2T00TMSDTZWJ1RP6TQF200/Option%20A/vote)
6+
[![](https://m131jyck4m.execute-api.us-west-2.amazonaws.com/prod/poll/01BM2T00TMSDTZWJ1RP6TQF200/Option%20B)](https://m131jyck4m.execute-api.us-west-2.amazonaws.com/prod/poll/01BM2T00TMSDTZWJ1RP6TQF200/Option%20B/vote)
7+
[![](https://m131jyck4m.execute-api.us-west-2.amazonaws.com/prod/poll/01BM2T00TMSDTZWJ1RP6TQF200/Option%20C)](https://m131jyck4m.execute-api.us-west-2.amazonaws.com/prod/poll/01BM2T00TMSDTZWJ1RP6TQF200/Option%20C/vote)
8+
9+
## About
10+
11+
These polls work by pasting individual markdown SVG images into your issue, each wrapped with a link that tracks a vote. A single vote per IP is allowed for a given poll, which are stored in DynamoDB.
12+
13+
What do they look like? The poll above is live, click one out and give it a try! Please don't abuse it or I'll have to take it down :).
14+
15+
---
16+
17+
[![GoDoc](https://godoc.org/github.com/tj/gh-polls?status.svg)](https://godoc.org/github.com/tj/gh-polls)
18+
![](https://img.shields.io/badge/license-MIT-blue.svg)
19+
![](https://img.shields.io/badge/status-experimental-orange.svg)
20+
21+
<a href="https://apex.sh"><img src="http://tjholowaychuk.com:6000/svg/sponsor"></a>

main.go

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package main
2+
3+
import (
4+
"crypto/md5"
5+
"encoding/hex"
6+
"encoding/json"
7+
"net/http"
8+
9+
"github.com/apex/log"
10+
"github.com/bmizerany/pat"
11+
"github.com/gohttp/response"
12+
"github.com/segmentio/go-env"
13+
14+
"github.com/tj/gh-polls/poll"
15+
)
16+
17+
func main() {
18+
app := pat.New()
19+
app.Post("/poll", http.HandlerFunc(addPoll))
20+
app.Get("/poll/:id/:option", http.HandlerFunc(getPollOption))
21+
app.Get("/poll/:id/:option/vote", http.HandlerFunc(getPollOptionVote))
22+
addr := env.MustGet("UP_ADDR")
23+
if err := http.ListenAndServe(addr, app); err != nil {
24+
log.WithError(err).Fatal("binding")
25+
}
26+
}
27+
28+
// addPoll creates a poll, responds with .id.
29+
func addPoll(w http.ResponseWriter, r *http.Request) {
30+
p := poll.New()
31+
32+
var body struct {
33+
Options []string `json:"options"`
34+
}
35+
36+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
37+
log.WithError(err).Error("parsing body")
38+
response.BadRequest(w, "Malformed request body.")
39+
return
40+
}
41+
42+
err := p.Create(body.Options)
43+
if err != nil {
44+
log.WithError(err).Error("creating poll")
45+
response.InternalServerError(w)
46+
return
47+
}
48+
49+
response.OK(w, map[string]string{
50+
"id": p.ID,
51+
})
52+
}
53+
54+
// getPollOptionVote performs a vote.
55+
func getPollOptionVote(w http.ResponseWriter, r *http.Request) {
56+
id := r.URL.Query().Get(":id")
57+
option := r.URL.Query().Get(":option")
58+
user := r.Header.Get("X-Real-IP")
59+
60+
ctx := log.WithFields(log.Fields{
61+
"id": id,
62+
"option": option,
63+
"user": user,
64+
})
65+
66+
p := poll.Poll{
67+
ID: id,
68+
}
69+
70+
err := p.Vote(user, option)
71+
72+
if err == poll.ErrAlreadyVoted {
73+
ctx.WithError(err).Warn("already voted")
74+
response.BadRequest(w, "Cheater!")
75+
return
76+
}
77+
78+
if err != nil {
79+
ctx.WithError(err).Error("voting")
80+
response.InternalServerError(w, "Error voting.")
81+
return
82+
}
83+
84+
response.OK(w, "Voted!")
85+
}
86+
87+
// getPollOption responds with a poll option svg.
88+
func getPollOption(w http.ResponseWriter, r *http.Request) {
89+
id := r.URL.Query().Get(":id")
90+
option := r.URL.Query().Get(":option")
91+
92+
ctx := log.WithFields(log.Fields{
93+
"id": id,
94+
"option": option,
95+
})
96+
97+
p := poll.Poll{
98+
ID: id,
99+
}
100+
101+
if err := p.Load(); err != nil {
102+
ctx.WithError(err).Error("loading poll")
103+
response.InternalServerError(w, "Error loading poll.")
104+
return
105+
}
106+
107+
votes, ok := p.Options[option]
108+
if !ok {
109+
ctx.Warn("option does not exist")
110+
response.NotFound(w, "Option does not exist.")
111+
return
112+
}
113+
114+
barWidth := 188
115+
percent := 0
116+
width := 0
117+
118+
if p.Votes > 0 {
119+
percent = int(float64(votes) / float64(p.Votes) * 100)
120+
width = int(float64(barWidth) * (float64(votes) / float64(p.Votes)))
121+
}
122+
123+
opt := poll.Option{
124+
Name: option,
125+
Votes: votes,
126+
Percent: percent,
127+
Width: width,
128+
}
129+
130+
b, err := opt.Render()
131+
if err != nil {
132+
http.Error(w, "Error rendering poll option.", http.StatusInternalServerError)
133+
return
134+
}
135+
136+
setETag(w, b)
137+
setCacheControl(w)
138+
w.Header().Set("Content-Type", "image/svg+xml")
139+
w.Write(b)
140+
}
141+
142+
func setCacheControl(w http.ResponseWriter) {
143+
w.Header().Set("Cache-Control", "private")
144+
}
145+
146+
func setETag(w http.ResponseWriter, body []byte) {
147+
hash := md5.New()
148+
hash.Write(body)
149+
etag := hex.EncodeToString(hash.Sum(nil))
150+
w.Header().Set("ETag", `w/"`+etag+`"`)
151+
}

poll/poll.go

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package poll
2+
3+
import (
4+
"math/rand"
5+
"strings"
6+
"time"
7+
8+
"github.com/oklog/ulid"
9+
"github.com/pkg/errors"
10+
11+
"github.com/aws/aws-sdk-go/aws"
12+
"github.com/aws/aws-sdk-go/aws/session"
13+
"github.com/aws/aws-sdk-go/service/dynamodb"
14+
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
15+
)
16+
17+
// Config.
18+
var (
19+
table = "polls"
20+
client = dynamodb.New(session.New(aws.NewConfig()))
21+
entropy = rand.New(rand.NewSource(time.Now().UnixNano()))
22+
)
23+
24+
// Errors.
25+
var (
26+
ErrAlreadyVoted = errors.New("already voted")
27+
)
28+
29+
// newID returns a new id.
30+
func newID() string {
31+
return ulid.MustNew(ulid.Now(), entropy).String()
32+
}
33+
34+
// Poll represents a poll and its options.
35+
type Poll struct {
36+
ID string `json:"id"`
37+
Votes int `json:"votes"`
38+
Voters []string `json:"voters"`
39+
Options map[string]int `json:"options"`
40+
}
41+
42+
// New poll.
43+
func New() *Poll {
44+
return &Poll{
45+
ID: newID(),
46+
}
47+
}
48+
49+
// Create the poll.
50+
func (p *Poll) Create(options []string) error {
51+
opts := map[string]*dynamodb.AttributeValue{}
52+
53+
for _, name := range options {
54+
opts[name] = &dynamodb.AttributeValue{
55+
N: aws.String("0"),
56+
}
57+
}
58+
59+
item := map[string]*dynamodb.AttributeValue{
60+
"id": {
61+
S: &p.ID,
62+
},
63+
"options": {
64+
M: opts,
65+
},
66+
}
67+
68+
_, err := client.PutItem(&dynamodb.PutItemInput{
69+
TableName: &table,
70+
Item: item,
71+
})
72+
73+
return err
74+
}
75+
76+
// Remove the poll.
77+
func (p *Poll) Remove() error {
78+
key := map[string]*dynamodb.AttributeValue{
79+
"id": {
80+
S: &p.ID,
81+
},
82+
}
83+
84+
_, err := client.DeleteItem(&dynamodb.DeleteItemInput{
85+
TableName: &table,
86+
Key: key,
87+
})
88+
89+
return err
90+
}
91+
92+
// Load the poll.
93+
func (p *Poll) Load() error {
94+
key := map[string]*dynamodb.AttributeValue{
95+
"id": {
96+
S: &p.ID,
97+
},
98+
}
99+
100+
res, err := client.GetItem(&dynamodb.GetItemInput{
101+
TableName: &table,
102+
Key: key,
103+
ConsistentRead: aws.Bool(true),
104+
})
105+
106+
if err != nil {
107+
return errors.Wrap(err, "getting item")
108+
}
109+
110+
if err := dynamodbattribute.UnmarshalMap(res.Item, &p); err != nil {
111+
return errors.Wrap(err, "unmarshaling item")
112+
}
113+
114+
return nil
115+
}
116+
117+
// Vote places a vote for `userID` against `option`.
118+
// If the user has already voted then
119+
// ErrAlreadyVoted is returned.
120+
func (p *Poll) Vote(userID, option string) error {
121+
key := map[string]*dynamodb.AttributeValue{
122+
"id": {
123+
S: &p.ID,
124+
},
125+
}
126+
127+
vals := map[string]*dynamodb.AttributeValue{
128+
":votes": {
129+
N: aws.String("1"),
130+
},
131+
":voter_set": {
132+
SS: aws.StringSlice([]string{userID}),
133+
},
134+
":voter": {
135+
S: &userID,
136+
},
137+
}
138+
139+
names := map[string]*string{
140+
"#options": aws.String("options"),
141+
"#option": &option,
142+
}
143+
144+
_, err := client.UpdateItem(&dynamodb.UpdateItemInput{
145+
TableName: &table,
146+
Key: key,
147+
UpdateExpression: aws.String(`ADD votes :votes, voters :voter_set SET #options.#option = #options.#option + :votes`),
148+
ConditionExpression: aws.String(`not contains(voters, :voter)`),
149+
ExpressionAttributeValues: vals,
150+
ExpressionAttributeNames: names,
151+
})
152+
153+
if err != nil && strings.Contains(err.Error(), "ConditionalCheckFailedException") {
154+
return ErrAlreadyVoted
155+
}
156+
157+
return err
158+
}

poll/poll_option.go

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package poll
2+
3+
import (
4+
"bytes"
5+
"html/template"
6+
7+
"github.com/pkg/errors"
8+
)
9+
10+
// font family.
11+
var fontFamily = `-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif`
12+
13+
// option svg.
14+
var option = `<svg width="448px" height="62px" viewBox="0 0 448 62" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
15+
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
16+
<g id="poll">
17+
<g id="Group" transform="translate(29.000000, 15.000000)">
18+
<rect id="Rectangle" fill="#F1F3F5" x="0" y="19" width="188" height="14" rx="2"></rect>
19+
<rect id="Rectangle" fill="#7950F2" x="0" y="19" width="{{.Width}}" height="14" rx="2"></rect>
20+
<text id="100%" font-family="{{.FontFamily}}" font-size="12" font-weight="normal" letter-spacing="1.857333" fill="#212529">
21+
<tspan x="199" y="30">{{.Percent}}%</tspan>
22+
</text>
23+
<text id="Option-A" font-family="{{.FontFamily}}" font-size="12" font-weight="normal" letter-spacing="1" fill="#212529">
24+
<tspan x="0" y="12">{{.Name}}</tspan>
25+
</text>
26+
<text id="150-votes" font-family="{{.FontFamily}}" font-size="12" font-weight="normal" letter-spacing="1" fill="#868E96">
27+
<tspan x="243" y="30">{{.Votes}} votes</tspan>
28+
</text>
29+
</g>
30+
</g>
31+
</g>
32+
</svg>`
33+
34+
// Option represents a single poll option.
35+
type Option struct {
36+
Name string
37+
Votes int
38+
Percent int
39+
40+
Width int
41+
FontFamily string
42+
}
43+
44+
// Render option as svg.
45+
func (o *Option) Render() ([]byte, error) {
46+
o.FontFamily = fontFamily
47+
48+
tmpl, err := template.New("poll").Parse(option)
49+
if err != nil {
50+
return nil, errors.Wrap(err, "parsing")
51+
}
52+
53+
var buf bytes.Buffer
54+
if err := tmpl.Execute(&buf, o); err != nil {
55+
return nil, errors.Wrap(err, "executing")
56+
}
57+
58+
return buf.Bytes(), nil
59+
}

0 commit comments

Comments
 (0)