diff options
Diffstat (limited to 'tools/blog_cli')
-rw-r--r-- | tools/blog_cli/README.md | 41 | ||||
-rw-r--r-- | tools/blog_cli/default.nix | 9 | ||||
-rw-r--r-- | tools/blog_cli/main.go | 209 |
3 files changed, 259 insertions, 0 deletions
diff --git a/tools/blog_cli/README.md b/tools/blog_cli/README.md new file mode 100644 index 000000000000..7afa0fe9207a --- /dev/null +++ b/tools/blog_cli/README.md @@ -0,0 +1,41 @@ +tazblog CLI +=========== + +My blog stores its content in DNS, spread out over three types of `TXT` entries: + +* `TXT _posts.blog.tazj.in.`: A sorted list of posts, serialised as a JSON list of + strings (e.g. `["1486830338", "1476807384"]`) + +* `TXT _chunks.$postID.blog.tazj.in`: JSON chunks containing the blog post text + +* `TXT _meta.$postID.blog.tazj.in`: JSON blob with blog post metadata + +All JSON blobs are base64-encoded. + +This CLI tool helps to update those records. + +Each blog post data is a series of JSON-encoded structures which follow one of +these formats: + +``` +struct metadata { + chunks: int + title: string + date: date +} +``` + +Where `chunks` describes the number of chunks following this format: + +``` +struct chunk { + c: int + t: string +} +``` + +Writing a blog post to DNS means taking its text and metadata, chunking it up +and writing the chunks. + +Reading a blog post means retrieving all data, reading the metadata and then +assembling the chunks in order. diff --git a/tools/blog_cli/default.nix b/tools/blog_cli/default.nix new file mode 100644 index 000000000000..c22e4c949bc1 --- /dev/null +++ b/tools/blog_cli/default.nix @@ -0,0 +1,9 @@ +{ pkgs, ... }: + +pkgs.buildGo.program { + name = "blog_cli"; + srcs = [ ./main.go ]; + deps = with pkgs.third_party; [ + gopkgs."google.golang.org".api.dns.v1.gopkg + ]; +} // { meta.enableCI = true; } diff --git a/tools/blog_cli/main.go b/tools/blog_cli/main.go new file mode 100644 index 000000000000..db64f8378e40 --- /dev/null +++ b/tools/blog_cli/main.go @@ -0,0 +1,209 @@ +// The tazblog CLI implements updating my blog records in DNS, see the +// README in this folder for details. +// +// The post input format is a file with the title on one line, +// followed by the date on a line, followed by an empty line, followed +// by the post text. +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + "time" + + "google.golang.org/api/dns/v1" +) + +var ( + project = flag.String("project", "tazjins-infrastructure", "Target GCP project") + zone = flag.String("zone", "blog-tazj-in", "Target Cloud DNS zone") + title = flag.String("title", "", "Title of the blog post") + date = flag.String("date", "", "Date the post was written on") + infile = flag.String("text", "", "Text file containing the blog post") + id = flag.String("id", "", "Post ID - will be generated if unset") +) + +// Number of runes to include in a single chunk. If any chunks exceed +// the limit of what can be encoded, the chunk size is reduced and we +// try again. +var chunkSize = 200 + +type day time.Time + +func (d day) MarshalJSON() ([]byte, error) { + j := (time.Time(d)).Format(`"2006-01-02"`) + return []byte(j), nil +} + +type metadata struct { + Chunks int `json:"c"` + Title string `json:"t"` + Date day `json:"d"` +} + +type chunk struct { + Chunk int + Text string +} + +type post struct { + ID string + Meta metadata + Chunks []string +} + +func (p *post) writeToDNS() error { + var additions []*dns.ResourceRecordSet + additions = append(additions, &dns.ResourceRecordSet{ + Name: fmt.Sprintf("_meta.%s.blog.tazj.in.", p.ID), + Type: "TXT", + Ttl: 1200, + Rrdatas: []string{ + encodeJSON(p.Meta), + }, + }) + + for i, c := range p.Chunks { + additions = append(additions, &dns.ResourceRecordSet{ + Name: fmt.Sprintf("_%v.%s.blog.tazj.in.", i, p.ID), + Type: "TXT", + Ttl: 1200, + Rrdatas: []string{c}, + }) + } + + ctx := context.Background() + dnsSvc, err := dns.NewService(ctx) + if err != nil { + return err + } + + change := dns.Change{ + Additions: additions, + } + + _, err = dnsSvc.Changes.Create(*project, *zone, &change).Do() + if err != nil { + return err + } + + return nil +} + +// Encode given value as JSON and base64-encode it. +func encodeJSON(v interface{}) string { + outer, err := json.Marshal(v) + if err != nil { + log.Fatalln("Failed to encode JSON", err) + } + + return base64.RawStdEncoding.EncodeToString(outer) +} + +// Encode a chunk and check whether it is too large +func encodeChunk(c chunk) (string, bool) { + tooLarge := false + s := base64.RawStdEncoding.EncodeToString([]byte(c.Text)) + + if len(s) >= 255 { + tooLarge = true + } + + return s, tooLarge +} + +func createPost(id, title, text string, date day) post { + runes := []rune(text) + n := 0 + tooLarge := false + + var chunks []string + + for chunkSize < len(runes) { + c, l := encodeChunk(chunk{ + Chunk: n, + Text: string(runes[0:chunkSize:chunkSize]), + }) + + tooLarge = tooLarge || l + chunks = append(chunks, c) + runes = runes[chunkSize:] + n++ + } + + if len(runes) > 0 { + c, l := encodeChunk(chunk{ + Chunk: n, + Text: string(runes), + }) + + tooLarge = tooLarge || l + chunks = append(chunks, c) + n++ + } + + if tooLarge { + log.Println("Too large at chunk size", chunkSize) + chunkSize -= 5 + return createPost(id, title, text, date) + } + + return post{ + ID: id, + Meta: metadata{ + Chunks: n, + Title: title, + Date: date, + }, + Chunks: chunks, + } +} + +func main() { + flag.Parse() + + if *title == "" { + log.Fatalln("Post title must be set (-title)") + } + + if *infile == "" { + log.Fatalln("Post text file must be set (-text)") + } + + if *id == "" { + log.Fatalln("Post ID must be set (-id)") + } + + var postDate day + if *date != "" { + t, err := time.Parse("2006-01-02", *date) + if err != nil { + log.Fatalln("Invalid post date", err) + } + + postDate = day(t) + } else { + postDate = day(time.Now()) + } + + t, err := ioutil.ReadFile(*infile) + if err != nil { + log.Fatalln("Failed to read post:", err) + } + + post := createPost(*id, *title, string(t), postDate) + + log.Println("Writing post to DNS ...") + err = post.writeToDNS() + + if err != nil { + log.Fatalln("Failed to write post:", err) + } + + log.Println("Successfully wrote entries") +} |