Skip to content

Commit 3cb4c6c

Browse files
bradfitzwillnorris
authored andcommitted
cmd/golink: start of our http://go/ shortlink service
Change-Id: Ica3c73ba4f288baada2960144ebe70727ab12f4c Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
0 parents  commit 3cb4c6c

File tree

1 file changed

+183
-0
lines changed

1 file changed

+183
-0
lines changed

golink.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// The golink server runs http://go/, a company shortlink service.
2+
package main
3+
4+
import (
5+
"encoding/json"
6+
"flag"
7+
"fmt"
8+
"html"
9+
"log"
10+
"net/http"
11+
"net/url"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
"text/template"
16+
"time"
17+
18+
"tailscale.com/client/tailscale"
19+
"tailscale.com/tsnet"
20+
)
21+
22+
var (
23+
verbose = flag.Bool("verbose", false, "be verbose")
24+
linkDir = flag.String("linkdir", "", "the directory to store one JSON file per go/ shortlink")
25+
)
26+
27+
var localClient *tailscale.LocalClient
28+
29+
// DiskLink is the JSON structure stored on disk in a file for each go short link.
30+
type DiskLink struct {
31+
Short string // the "foo" part of http://go/foo
32+
Long string // the target URL
33+
Created time.Time
34+
LastEdit time.Time // when the link was created
35+
Owner string // foo@tailscale.com
36+
}
37+
38+
func main() {
39+
flag.Parse()
40+
41+
if *linkDir == "" {
42+
log.Fatalf("--linkdir is required")
43+
}
44+
if fi, err := os.Stat(*linkDir); err != nil {
45+
log.Fatal(err)
46+
} else if !fi.IsDir() {
47+
log.Fatalf("--linkdir %q is not a directory", *linkDir)
48+
}
49+
50+
srv := &tsnet.Server{
51+
Hostname: "go",
52+
Logf: func(format string, args ...interface{}) {},
53+
}
54+
if *verbose {
55+
srv.Logf = log.Printf
56+
}
57+
if err := srv.Start(); err != nil {
58+
log.Fatal(err)
59+
}
60+
localClient, _ = srv.LocalClient()
61+
62+
l80, err := srv.Listen("tcp", ":80")
63+
if err != nil {
64+
log.Fatal(err)
65+
}
66+
67+
log.Printf("Serving http://go/ ...")
68+
if err := http.Serve(l80, http.HandlerFunc(serveGo)); err != nil {
69+
log.Fatal(err)
70+
}
71+
}
72+
73+
// homeCreate is the template used by the http://go/ index page where you can
74+
// create or edit links.
75+
var homeCreate *template.Template
76+
77+
// homeData is the data used by the homeCreate template.
78+
type homeData struct {
79+
Short string
80+
}
81+
82+
func init() {
83+
homeCreate = template.Must(template.New("home").Parse(`<html>
84+
<body>
85+
<h1>go/</h1>
86+
shortlink service.
87+
88+
<h2>create</h2>
89+
<form method="POST" action="/">
90+
http://go/<input name=short size=20 value="{{.Short}}"> ==&gt; <input name=long size=40> <input type=submit value="create">
91+
</form>
92+
`))
93+
}
94+
95+
func linkPath(short string) string {
96+
name := url.PathEscape(strings.ToLower(short))
97+
name = strings.ReplaceAll(name, ".", "%2e")
98+
return filepath.Join(*linkDir, name)
99+
}
100+
101+
func loadLink(short string) (*DiskLink, error) {
102+
data, err := os.ReadFile(linkPath(short))
103+
if err != nil {
104+
return nil, err
105+
}
106+
dl := new(DiskLink)
107+
if err := json.Unmarshal(data, dl); err != nil {
108+
return nil, err
109+
}
110+
return dl, nil
111+
}
112+
113+
func serveGo(w http.ResponseWriter, r *http.Request) {
114+
if r.RequestURI == "/" {
115+
switch r.Method {
116+
case "GET":
117+
homeCreate.Execute(w, homeData{
118+
Short: "",
119+
})
120+
case "POST":
121+
serveSave(w, r)
122+
}
123+
return
124+
}
125+
126+
short := strings.ToLower(strings.TrimPrefix(r.RequestURI, "/"))
127+
128+
dl, err := loadLink(short)
129+
if os.IsNotExist(err) {
130+
homeCreate.Execute(w, homeData{
131+
Short: short,
132+
})
133+
return
134+
}
135+
if err != nil {
136+
log.Printf("serving %q: %v", short, err)
137+
http.Error(w, err.Error(), http.StatusInternalServerError)
138+
return
139+
}
140+
141+
http.Redirect(w, r, dl.Long, http.StatusFound)
142+
}
143+
144+
func serveSave(w http.ResponseWriter, r *http.Request) {
145+
short, long := r.FormValue("short"), r.FormValue("long")
146+
if short == "" || long == "" {
147+
http.Error(w, "short and long required", http.StatusBadRequest)
148+
return
149+
}
150+
res, err := localClient.WhoIs(r.Context(), r.RemoteAddr)
151+
if err != nil {
152+
http.Error(w, err.Error(), http.StatusInternalServerError)
153+
return
154+
}
155+
login := res.UserProfile.LoginName
156+
157+
dl, err := loadLink(short)
158+
if err == nil && dl.Owner != login {
159+
http.Error(w, "not your link; owned by "+dl.Owner, http.StatusForbidden)
160+
return
161+
}
162+
163+
now := time.Now()
164+
if dl == nil {
165+
dl = &DiskLink{
166+
Short: short,
167+
Created: now,
168+
}
169+
}
170+
dl.Long = long
171+
dl.LastEdit = now
172+
dl.Owner = login
173+
j, err := json.MarshalIndent(dl, "", " ")
174+
if err != nil {
175+
http.Error(w, err.Error(), http.StatusInternalServerError)
176+
return
177+
}
178+
if err := os.WriteFile(linkPath(short), j, 0600); err != nil {
179+
http.Error(w, err.Error(), http.StatusInternalServerError)
180+
return
181+
}
182+
fmt.Fprintf(w, "<h1>saved</h1>made <a href='http://go/%s'>http://go/%s</a>", html.EscapeString(short), html.EscapeString(short))
183+
}

0 commit comments

Comments
 (0)