├── .github └── workflows │ └── go-presubmit.yml ├── LICENSE ├── README.md ├── cmd └── tmemes │ ├── api.go │ ├── main.go │ ├── static │ ├── font │ │ └── Oswald-SemiBold.ttf │ ├── meme.wasm │ ├── script.js │ ├── style.css │ └── wasm_exec.js │ ├── ui.go │ ├── ui │ ├── create.tmpl │ ├── macros.tmpl │ ├── nav.tmpl │ ├── templates.tmpl │ └── upload.tmpl │ └── utils.go ├── deploy ├── build.sh ├── restart-tmemes.sh ├── run-tmemes.sh └── tmemes.service ├── docs └── api.md ├── go.mod ├── go.sum ├── memedraw ├── Oswald-SemiBold.ttf ├── draw.go └── utils.go ├── store ├── db.go ├── schema.sql └── store.go ├── types.go └── types_test.go /.github/workflows/go-presubmit.yml: -------------------------------------------------------------------------------- 1 | # A simple CI workflow to tide us over till we have something better. 2 | name: Go presubmit 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: [opened, reopened, synchronize] 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | name: Go presubmit 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | matrix: 21 | go-version: ['stable'] 22 | os: ['ubuntu-latest'] 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Install Go ${{ matrix.go-version }} 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ matrix.go-version }} 29 | cache: true 30 | - uses: creachadair/go-presubmit-action@v2 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023 Tailscale Inc & AUTHORS. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tmemes: putting the meme in TS 2 | 3 | This bit of fun was brought to you through the amazing power of Tailscale, and 4 | the collaborative efforts of 5 | 6 | - Maisem Ali: "I think we need memegen" 7 | - M. J. Fromberger: "why did I not think of that" 8 | - Jenny Zhang: "ok i’m finally in front of a computer, can I go write some css" 9 | - Salman Aljammaz: (quietly moves heaven and earth inside a ``) 10 | - Shayne Sweeney: "Would I be stepping on toes if I built a Slack bot?" 11 | 12 | together with a lovely and inspirational crew of supporters. There's lots more 13 | fun still to be had, so if you want to jump in, read on! There is also a 14 | wishlist of TODO items at the bottom. 15 | 16 | --- 17 | 18 | ## Synopsis 19 | 20 | `tmemes` is a web app built mainly in Go and running on [`tsnet`][tsnet]. This 21 | is a very terse description of how it all works. 22 | 23 | - The server is `tmemes`, a standalone Go binary using `tsnet`. Run 24 | 25 | ``` 26 | TS_AUTHKEY=$KEY go run ./tmemes 27 | ``` 28 | 29 | to start the server. Make sure your tailnet ACL allows access to this node, 30 | and you should be able to visit `http://tmemes` in the browser. 31 | 32 | - The server "database" is a directory of files. Use `--data-dir` to set the 33 | location; it defaults to `/tmp/tmemes`. 34 | 35 | - Terminology: 36 | 37 | - **Template**: A base image that can be decorated with text. 38 | - **Macro**: An image macro combining a template and a text overlay. 39 | - **Text overlay**: Lines of text with position and typographical info. 40 | 41 | Types are in `types.go`. 42 | 43 | - The data directory contains an `index.db` which is a SQLite database (schema 44 | in store/schema.sql), plus various other directories of image content: 45 | 46 | - `templates` are the template images. 47 | - `macros` are cached macros (re-generated on the fly as needed). 48 | - `static` are some static assets used by the macro generator (esp. fonts). 49 | 50 | The `store` package kinda provides a thin wrapper around these data. 51 | 52 | - UI elements are generated by Go HTML templates in `tmemes/ui`. These are 53 | statically embedded into the server and served by the handlers. 54 | 55 | - Static assets needed by the UI are stored in `tmemes/static`. These are 56 | served via `/static/` paths in the server mux. 57 | 58 | --- 59 | 60 | ## Links 61 | 62 | - [API documentation](./docs/api.md) 63 | - [Task wishlist](https://github.com/tailscale/tmemes/issues/4) (#4) 64 | 65 | [tsnet]: https://godoc.org/tailscale.com/tsnet 66 | -------------------------------------------------------------------------------- /cmd/tmemes/api.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "crypto/sha256" 9 | "encoding/json" 10 | "errors" 11 | "expvar" 12 | "fmt" 13 | "image" 14 | "image/gif" 15 | "image/jpeg" 16 | "image/png" 17 | "io" 18 | "log" 19 | "net/http" 20 | "os" 21 | "path/filepath" 22 | "strconv" 23 | "strings" 24 | "sync" 25 | "time" 26 | 27 | "github.com/creachadair/mds/compare" 28 | "github.com/tailscale/tmemes" 29 | "github.com/tailscale/tmemes/memedraw" 30 | "github.com/tailscale/tmemes/store" 31 | "golang.org/x/exp/slices" 32 | "tailscale.com/client/tailscale" 33 | "tailscale.com/client/tailscale/apitype" 34 | "tailscale.com/metrics" 35 | "tailscale.com/tailcfg" 36 | "tailscale.com/tsnet" 37 | "tailscale.com/tsweb" 38 | "tailscale.com/util/singleflight" 39 | ) 40 | 41 | type tmemeServer struct { 42 | db *store.DB 43 | srv *tsnet.Server 44 | lc *tailscale.LocalClient 45 | superUser map[string]bool // logins of admin users 46 | allowAnonymous bool 47 | 48 | macroGenerationSingleFlight singleflight.Group[string, string] 49 | imageFileEtags sync.Map // :: string(path) → string(quoted etag) 50 | 51 | mu sync.Mutex // guards userProfiles 52 | 53 | userProfiles map[tailcfg.UserID]tailcfg.UserProfile 54 | lastUpdatedUserProfiles time.Time 55 | } 56 | 57 | // initialize sets up the state of the server and checks the integrity of its 58 | // database to make it ready to serve. Any error it reports is considered 59 | // fatal. 60 | func (s *tmemeServer) initialize(ts *tsnet.Server) error { 61 | // Populate superusers. 62 | if *adminUsers != "" { 63 | s.superUser = make(map[string]bool) 64 | for _, u := range strings.Split(*adminUsers, ",") { 65 | s.superUser[u] = true 66 | } 67 | } 68 | 69 | // Preload Etag values. 70 | var numTags int 71 | for _, t := range s.db.Templates() { 72 | tpath, _ := s.db.TemplatePath(t.ID) 73 | tag, err := makeFileEtag(tpath) 74 | if err != nil { 75 | return err 76 | } 77 | s.imageFileEtags.Store(tpath, tag) 78 | numTags++ 79 | } 80 | for _, m := range s.db.Macros() { 81 | cachePath, _ := s.db.CachePath(m) 82 | tag, err := makeFileEtag(cachePath) 83 | if os.IsNotExist(err) { 84 | continue 85 | } else if err != nil { 86 | return err 87 | } 88 | s.imageFileEtags.Store(cachePath, tag) 89 | numTags++ 90 | } 91 | log.Printf("Preloaded %d image Etags", numTags) 92 | 93 | // Set up a metrics server. 94 | ln, err := ts.Listen("tcp", ":8383") 95 | if err != nil { 96 | return err 97 | } 98 | go func() { 99 | defer ln.Close() 100 | log.Print("Starting debug server on :8383") 101 | mux := http.NewServeMux() 102 | tsweb.Debugger(mux) 103 | http.Serve(ln, mux) 104 | }() 105 | 106 | return nil 107 | } 108 | 109 | var ( 110 | serveMetrics = &metrics.LabelMap{Label: "type"} 111 | macroMetrics = &metrics.LabelMap{Label: "type"} 112 | ) 113 | 114 | func init() { 115 | expvar.Publish("tmemes_serve_metrics", serveMetrics) 116 | expvar.Publish("tmemes_macro_metrics", macroMetrics) 117 | } 118 | 119 | var errNotFound = errors.New("not found") 120 | 121 | // userFromID returns the user profile for the given user ID. If the user 122 | // profile is not found, it will attempt to fetch the latest user profiles from 123 | // the tsnet server. 124 | func (s *tmemeServer) userFromID(ctx context.Context, id tailcfg.UserID) (*tailcfg.UserProfile, error) { 125 | s.mu.Lock() 126 | up, ok := s.userProfiles[id] 127 | lastUpdated := s.lastUpdatedUserProfiles 128 | s.mu.Unlock() 129 | if ok { 130 | return &up, nil 131 | } 132 | if time.Since(lastUpdated) < time.Minute { 133 | return nil, errNotFound 134 | } 135 | st, err := s.lc.Status(ctx) 136 | if err != nil { 137 | return nil, err 138 | } 139 | s.mu.Lock() 140 | defer s.mu.Unlock() 141 | s.userProfiles = st.User 142 | up, ok = s.userProfiles[id] 143 | if !ok { 144 | return nil, errNotFound 145 | } 146 | return &up, nil 147 | } 148 | 149 | // newMux constructs a router for the tmemes API. 150 | // 151 | // There are three groups of endpoints: 152 | // 153 | // - The /api/ endpoints serve JSON metadata for tools to consume. 154 | // - The /content/ endpoints serve image data. 155 | // - The rest of the endpoints serve UI components. 156 | func (s *tmemeServer) newMux() *http.ServeMux { 157 | apiMux := http.NewServeMux() 158 | apiMux.HandleFunc("/api/macro/", s.serveAPIMacro) // one macro by ID 159 | apiMux.HandleFunc("/api/macro", s.serveAPIMacro) // all macros 160 | apiMux.HandleFunc("/api/context/", s.serveAPIContext) // add/remove context 161 | apiMux.HandleFunc("/api/template/", s.serveAPITemplate) // one template by ID 162 | apiMux.HandleFunc("/api/template", s.serveAPITemplate) // all templates 163 | apiMux.HandleFunc("/api/vote/", s.serveAPIVote) // caller's vote by ID 164 | apiMux.HandleFunc("/api/vote", s.serveAPIVote) // all caller's votes 165 | 166 | contentMux := http.NewServeMux() 167 | contentMux.HandleFunc("/content/template/", s.serveContentTemplate) 168 | contentMux.HandleFunc("/content/macro/", s.serveContentMacro) 169 | 170 | uiMux := http.NewServeMux() 171 | uiMux.HandleFunc("/macros/", func(w http.ResponseWriter, r *http.Request) { 172 | http.Redirect(w, r, "/m/"+r.URL.Path[len("/macros/"):], http.StatusFound) 173 | }) 174 | uiMux.HandleFunc("/templates/", func(w http.ResponseWriter, r *http.Request) { 175 | http.Redirect(w, r, "/t/"+r.URL.Path[len("/templates/"):], http.StatusFound) 176 | }) 177 | uiMux.HandleFunc("/t/", s.serveUITemplates) // view one template by ID 178 | uiMux.HandleFunc("/t", s.serveUITemplates) // view all templates 179 | uiMux.HandleFunc("/create/", s.serveUICreate) // view create page for given template ID 180 | uiMux.HandleFunc("/m/", s.serveUIMacros) // view one macro by ID 181 | uiMux.HandleFunc("/m", s.serveUIMacros) // view all macros 182 | uiMux.HandleFunc("/", s.serveUIMacros) // alias for /macros/ 183 | uiMux.HandleFunc("/upload", s.serveUIUpload) // template upload view 184 | 185 | mux := http.NewServeMux() 186 | mux.Handle("/api/", apiMux) 187 | mux.Handle("/content/", contentMux) 188 | mux.Handle("/static/", http.FileServer(http.FS(staticFS))) 189 | mux.Handle("/", uiMux) 190 | 191 | return mux 192 | } 193 | 194 | // serveContentTemplate serves template image content. 195 | // 196 | // API: /content/template/:id[.ext] 197 | // 198 | // A file extension is optional, but if .ext is included, it must match the 199 | // stored value. 200 | func (s *tmemeServer) serveContentTemplate(w http.ResponseWriter, r *http.Request) { 201 | serveMetrics.Add("content-template", 1) 202 | const apiPath = "/content/template/" 203 | if r.Method != "GET" { 204 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 205 | return 206 | } 207 | 208 | // Require /id or /id.ext. 209 | id := strings.TrimPrefix(r.URL.Path, apiPath) 210 | if id == "" { 211 | http.Error(w, "missing id", http.StatusBadRequest) 212 | return 213 | } 214 | ext := filepath.Ext(id) 215 | idInt, err := strconv.Atoi(strings.TrimSuffix(id, ext)) 216 | if err != nil { 217 | http.Error(w, "invalid id", http.StatusBadRequest) 218 | return 219 | } 220 | 221 | tp, err := s.db.TemplatePath(idInt) 222 | if err != nil { 223 | http.Error(w, err.Error(), http.StatusNotFound) 224 | return 225 | } 226 | 227 | // Require that the requested extension match how the file is stored. 228 | if !strings.HasSuffix(tp, ext) { 229 | http.Error(w, "wrong file extension", http.StatusBadRequest) 230 | return 231 | } 232 | 233 | s.serveFileCached(w, r, tp, 365*24*time.Hour) 234 | } 235 | 236 | // serveContentMacro serves macro image content. If the requested macro is not 237 | // already in the cache, it is rendered and cached before returning. 238 | // 239 | // API: /content/macro/:id[.ext] 240 | // 241 | // A file extension is optional, but if .ext is included, it must match the 242 | // file extension stored with the macro's template. 243 | func (s *tmemeServer) serveContentMacro(w http.ResponseWriter, r *http.Request) { 244 | serveMetrics.Add("content-macro", 1) 245 | const apiPath = "/content/macro/" 246 | if r.Method != "GET" { 247 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 248 | return 249 | } 250 | 251 | // Require /id or /id.ext 252 | id := strings.TrimPrefix(r.URL.Path, apiPath) 253 | if id == "" { 254 | http.Error(w, "missing id", http.StatusBadRequest) 255 | return 256 | } 257 | ext := filepath.Ext(id) 258 | idInt, err := strconv.Atoi(strings.TrimSuffix(id, ext)) 259 | if err != nil { 260 | http.Error(w, "invalid id", http.StatusBadRequest) 261 | return 262 | } 263 | 264 | m, err := s.db.Macro(idInt) 265 | if err != nil { 266 | http.Error(w, err.Error(), http.StatusNotFound) 267 | return 268 | } 269 | cachePath, err := s.db.CachePath(m) 270 | if err != nil { 271 | http.Error(w, err.Error(), http.StatusInternalServerError) 272 | return 273 | } 274 | 275 | // Require that the requested extension (if there is one) match how the file 276 | // is stored. 277 | if ext != "" && !strings.HasSuffix(cachePath, ext) { 278 | http.Error(w, "wrong file extension", http.StatusBadRequest) 279 | return 280 | } 281 | 282 | if _, err := os.Stat(cachePath); err == nil { 283 | macroMetrics.Add("cache-hit", 1) 284 | s.serveFileCached(w, r, cachePath, 24*time.Hour) 285 | return 286 | } else { 287 | log.Printf("cache file %q not found, generating: %v", cachePath, err) 288 | } 289 | if _, err, reused := s.macroGenerationSingleFlight.Do(cachePath, func() (string, error) { 290 | macroMetrics.Add("cache-miss", 1) 291 | return cachePath, s.generateMacro(m, cachePath) 292 | }); err != nil { 293 | log.Printf("error generating macro %d: %v", m.ID, err) 294 | http.Error(w, err.Error(), http.StatusInternalServerError) 295 | return 296 | } else if reused { 297 | macroMetrics.Add("cache-reused", 1) 298 | } 299 | 300 | s.serveFileCached(w, r, cachePath, 24*time.Hour) 301 | } 302 | 303 | // serveFileCached is a wrapper for http.ServeFile that populates cache-control 304 | // and etag headers. 305 | func (s *tmemeServer) serveFileCached(w http.ResponseWriter, r *http.Request, path string, maxAge time.Duration) { 306 | w.Header().Set("Cache-Control", fmt.Sprintf( 307 | "public, max-age=%d, no-transform", maxAge/time.Second)) 308 | if tag, ok := s.imageFileEtags.Load(path); ok { 309 | w.Header().Set("Etag", tag.(string)) 310 | } 311 | http.ServeFile(w, r, path) 312 | } 313 | 314 | // generateMacroGIF renders the text specified by m onto the template GIF 315 | // stored in srcFile. On success it writes the generated macro to cachePath. 316 | // 317 | // If srcFile contains multiple frames, it renders the text onto each frame 318 | // according to the timing and position settings defined in its overlay. 319 | func (s *tmemeServer) generateMacroGIF(m *tmemes.Macro, cachePath string, srcFile *os.File) (retErr error) { 320 | macroMetrics.Add("generate-gif", 1) 321 | start := time.Now() 322 | log.Printf("generating GIF for macro %d", m.ID) 323 | defer func() { 324 | if retErr != nil { 325 | log.Printf("error generating GIF for macro %d: %v", m.ID, retErr) 326 | } else { 327 | log.Printf("generated GIF for macro %d in %v", m.ID, time.Since(start).Round(time.Millisecond)) 328 | } 329 | }() 330 | 331 | // Decode the source GIF 332 | srcGIF, err := gif.DecodeAll(srcFile) 333 | if err != nil { 334 | return err 335 | } 336 | 337 | if len(srcGIF.Image) == 0 { 338 | return errors.New("no frames in GIF") 339 | } 340 | 341 | memedraw.DrawGIF(srcGIF, m) 342 | 343 | // Save the modified GIF 344 | dstFile, err := os.Create(cachePath) 345 | if err != nil { 346 | return err 347 | } 348 | etagHash := sha256.New() 349 | dst := io.MultiWriter(etagHash, dstFile) 350 | defer func() { 351 | if retErr != nil { 352 | dstFile.Close() 353 | os.Remove(cachePath) 354 | } else { 355 | s.imageFileEtags.Store(cachePath, formatEtag(etagHash)) 356 | } 357 | }() 358 | 359 | err = gif.EncodeAll(dst, srcGIF) 360 | if err != nil { 361 | return err 362 | } 363 | return dstFile.Close() 364 | } 365 | 366 | // generateMacro renders the text specified by m onto its template image. On 367 | // success, it writes the generated macro to cachePath. 368 | // 369 | // Note this method will automatically dispatch to generateMacroGIF for 370 | // templates in GIF format. 371 | func (s *tmemeServer) generateMacro(m *tmemes.Macro, cachePath string) (retErr error) { 372 | tp, err := s.db.TemplatePath(m.TemplateID) 373 | if err != nil { 374 | return err 375 | } 376 | 377 | srcFile, err := os.Open(tp) 378 | if err != nil { 379 | return err 380 | } 381 | defer srcFile.Close() 382 | 383 | ext := filepath.Ext(tp) 384 | if ext == ".gif" { 385 | return s.generateMacroGIF(m, cachePath, srcFile) 386 | } 387 | macroMetrics.Add("generate", 1) 388 | 389 | srcImage, _, err := image.Decode(srcFile) 390 | if err != nil { 391 | return err 392 | } 393 | 394 | alpha := memedraw.Draw(srcImage, m) 395 | 396 | f, err := os.Create(cachePath) 397 | if err != nil { 398 | return err 399 | } 400 | etagHash := sha256.New() 401 | dst := io.MultiWriter(etagHash, f) 402 | defer func() { 403 | if retErr != nil { 404 | f.Close() 405 | os.Remove(cachePath) 406 | } else { 407 | s.imageFileEtags.Store(cachePath, formatEtag(etagHash)) 408 | } 409 | }() 410 | 411 | switch ext { 412 | case ".jpg", ".jpeg": 413 | macroMetrics.Add("generate-jpg", 1) 414 | if err := jpeg.Encode(dst, alpha, &jpeg.Options{Quality: 90}); err != nil { 415 | return err 416 | } 417 | case ".png": 418 | macroMetrics.Add("generate-png", 1) 419 | if err := png.Encode(dst, alpha); err != nil { 420 | return err 421 | } 422 | default: 423 | return fmt.Errorf("unknown extension: %v", ext) 424 | } 425 | 426 | return f.Close() 427 | } 428 | 429 | func (s *tmemeServer) serveAPIMacro(w http.ResponseWriter, r *http.Request) { 430 | serveMetrics.Add("api-macro", 1) 431 | switch r.Method { 432 | case "GET": 433 | s.serveAPIMacroGet(w, r) 434 | case "POST": 435 | s.serveAPIMacroPost(w, r) 436 | case "DELETE": 437 | s.serveAPIMacroDelete(w, r) 438 | default: 439 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 440 | } 441 | } 442 | 443 | // checkAccess checks that the caller is logged in and not a tagged node. If 444 | // so, it returns the whois data for the user. Otherwise, it writes an error 445 | // response to w and returns nil. 446 | func (s *tmemeServer) checkAccess(w http.ResponseWriter, r *http.Request, op string) *apitype.WhoIsResponse { 447 | whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) 448 | if err != nil { 449 | http.Error(w, err.Error(), http.StatusInternalServerError) 450 | return nil 451 | } 452 | if whois == nil { 453 | http.Error(w, "not logged in", http.StatusUnauthorized) 454 | return nil 455 | } 456 | if whois.Node.IsTagged() { 457 | http.Error(w, "tagged nodes cannot "+op, http.StatusForbidden) 458 | return nil 459 | } 460 | return whois 461 | } 462 | 463 | // serveAPIMacroPost implements the API for creating new image macros. 464 | // 465 | // API: POST /api/macro 466 | // 467 | // The payload must be of type application/json encoding a tmemes.Macro. On 468 | // success, the filled-in macro object is written back to the caller. 469 | func (s *tmemeServer) serveAPIMacroPost(w http.ResponseWriter, r *http.Request) { 470 | whois := s.checkAccess(w, r, "create macros") 471 | if whois == nil { 472 | return // error already sent 473 | } 474 | 475 | // Create a new macro. 476 | var m tmemes.Macro 477 | if err := json.NewDecoder(r.Body).Decode(&m); err != nil { 478 | http.Error(w, err.Error(), http.StatusBadRequest) 479 | return 480 | } else if err := m.ValidForCreate(); err != nil { 481 | http.Error(w, err.Error(), http.StatusBadRequest) 482 | return 483 | } 484 | 485 | // If the creator is negative, treat the macro as anonymous. 486 | if m.Creator < 0 { 487 | if !s.allowAnonymous { 488 | http.Error(w, "anonymous macros not allowed", http.StatusForbidden) 489 | return 490 | } 491 | m.Creator = -1 // normalize anonymous to -1 492 | } else { 493 | m.Creator = whois.UserProfile.ID 494 | } 495 | 496 | if err := s.db.AddMacro(&m); err != nil { 497 | http.Error(w, err.Error(), http.StatusBadRequest) 498 | return 499 | } 500 | w.Header().Set("Content-Type", "application/json") 501 | if err := json.NewEncoder(w).Encode(m); err != nil { 502 | http.Error(w, err.Error(), http.StatusInternalServerError) 503 | } 504 | } 505 | 506 | func (s *tmemeServer) serveAPIContext(w http.ResponseWriter, r *http.Request) { 507 | serveMetrics.Add("api-context", 1) 508 | switch r.Method { 509 | case "POST": 510 | s.serveAPIContextPost(w, r) 511 | default: 512 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 513 | } 514 | } 515 | 516 | // serveAPIContextPost implements the API for adding/removing context links. 517 | // 518 | // API: POST /api/context/:id 519 | // 520 | // The payload must be of type application/json encoding a tmemes.ContextRequest. 521 | func (s *tmemeServer) serveAPIContextPost(w http.ResponseWriter, r *http.Request) { 522 | whois := s.checkAccess(w, r, "edit macros") 523 | if whois == nil { 524 | return // error already sent 525 | } 526 | m, ok, err := getSingleFromIDInPath(r.URL.Path, "api/context", s.db.Macro) 527 | if err != nil { 528 | http.Error(w, err.Error(), http.StatusBadRequest) 529 | return 530 | } else if !ok { 531 | http.Error(w, "missing macro ID", http.StatusBadRequest) 532 | return 533 | } 534 | 535 | if whois.UserProfile.ID != m.Creator && !s.superUser[whois.UserProfile.LoginName] { 536 | http.Error(w, "permission denied", http.StatusUnauthorized) 537 | return 538 | } 539 | 540 | var req tmemes.ContextRequest 541 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 542 | http.Error(w, err.Error(), http.StatusBadRequest) 543 | return 544 | } else if req.Action != "clear" && req.Link.URL == "" { 545 | http.Error(w, "missing context URL", http.StatusBadRequest) 546 | return 547 | } 548 | 549 | saved := m.ContextLink // in case update fails 550 | var needsUpdate bool 551 | switch req.Action { 552 | case "add": 553 | if slices.Index(m.ContextLink, req.Link) < 0 { 554 | if len(m.ContextLink) >= tmemes.MaxContextLinks { 555 | http.Error(w, "maximum context links already present", http.StatusForbidden) 556 | return 557 | } 558 | m.ContextLink = append(m.ContextLink, req.Link) 559 | needsUpdate = true 560 | } 561 | case "remove": 562 | if i := slices.Index(m.ContextLink, req.Link); i >= 0 { 563 | m.ContextLink = removeItem(m.ContextLink, i) 564 | needsUpdate = true 565 | } 566 | case "clear": 567 | m.ContextLink = nil 568 | needsUpdate = len(saved) != 0 569 | default: 570 | http.Error(w, "invalid action: "+req.Action, http.StatusBadRequest) 571 | return 572 | } 573 | if needsUpdate { 574 | if err := s.db.UpdateMacro(m); err != nil { 575 | m.ContextLink = saved // restore original state 576 | http.Error(w, err.Error(), http.StatusInternalServerError) 577 | return 578 | } 579 | } 580 | w.Header().Set("Content-Type", "application/json") 581 | if err := json.NewEncoder(w).Encode(m); err != nil { 582 | http.Error(w, err.Error(), http.StatusInternalServerError) 583 | } 584 | } 585 | 586 | // creatorUserID parses the "creator" query parameter to identify a user ID for 587 | // which filtering should be done. 588 | // 589 | // If the query parameter is not present, it returns (0, nil). 590 | // If the query parameter is "anon" or "anonymous", it returns (-1, nil). 591 | // Otherwise, on success, it returns a positive user ID, but note that the 592 | // caller is responsible for checking whether that ID corresponds to a real 593 | // user on the tailnet. 594 | func creatorUserID(r *http.Request) (tailcfg.UserID, error) { 595 | c := r.URL.Query().Get("creator") 596 | if c == "" { 597 | return 0, nil 598 | } 599 | if c == "anon" || c == "anonymous" { 600 | return -1, nil 601 | } 602 | id, err := strconv.ParseUint(c, 10, 64) 603 | if err != nil { 604 | return 0, fmt.Errorf("bad creator: %v", err) 605 | } 606 | if id <= 0 { 607 | return 0, errors.New("invalid creator") 608 | } 609 | return tailcfg.UserID(id), nil 610 | } 611 | 612 | // serveAPIMacroGet returns metadata about image macros. 613 | // 614 | // API: /api/macro/:id -- one macro by ID 615 | // API: /api/macro -- all macros defined 616 | // 617 | // This API supports pagination (see parsePageOptions). 618 | // The result objects are JSON tmemes.Macro values. 619 | func (s *tmemeServer) serveAPIMacroGet(w http.ResponseWriter, r *http.Request) { 620 | m, ok, err := getSingleFromIDInPath(r.URL.Path, "api/macro", s.db.Macro) 621 | if err != nil { 622 | http.Error(w, err.Error(), http.StatusBadRequest) 623 | return 624 | } 625 | w.Header().Set("Content-Type", "application/json") 626 | if ok { 627 | if err := json.NewEncoder(w).Encode(m); err != nil { 628 | http.Error(w, err.Error(), http.StatusInternalServerError) 629 | } 630 | return 631 | } 632 | 633 | var all []*tmemes.Macro 634 | // If a creator parameter is set, filter to macros matching that user ID. 635 | // As a special case, "anon" or "anonymous" selects unattributed macros. 636 | uid, err := creatorUserID(r) 637 | if err != nil { 638 | http.Error(w, err.Error(), http.StatusBadRequest) 639 | return 640 | } 641 | if uid == 0 { 642 | all = s.db.Macros() 643 | } else { 644 | all = s.db.MacrosByCreator(uid) 645 | } 646 | total := len(all) 647 | 648 | // Check for sorting order. 649 | if err := sortMacros(r.FormValue("sort"), all); err != nil { 650 | http.Error(w, err.Error(), http.StatusBadRequest) 651 | return 652 | } 653 | 654 | // Handle pagination. 655 | page, count, err := parsePageOptions(r, 24) 656 | if err != nil { 657 | http.Error(w, err.Error(), http.StatusBadRequest) 658 | return 659 | } 660 | pageItems, isLast := slicePage(all, page, count) 661 | 662 | rsp := struct { 663 | M []*tmemes.Macro `json:"macros"` 664 | N int `json:"total"` 665 | L bool `json:"isLast,omitempty"` 666 | }{M: pageItems, N: total, L: isLast} 667 | if err := json.NewEncoder(w).Encode(rsp); err != nil { 668 | http.Error(w, err.Error(), http.StatusInternalServerError) 669 | } 670 | } 671 | 672 | // serveAPIMacroDelete implements deletion of image macros. Only the user who 673 | // created a macro or an admin can delete a macro. Note that because 674 | // unattributed macros do not store a user ID, this means only admins can 675 | // remove anonymous macros. 676 | // 677 | // API: DELETE /api/macro/:id 678 | // 679 | // On success, the deleted macro object is written back to the caller. 680 | func (s *tmemeServer) serveAPIMacroDelete(w http.ResponseWriter, r *http.Request) { 681 | whois := s.checkAccess(w, r, "delete macros") 682 | if whois == nil { 683 | return // error already sent 684 | } 685 | 686 | m, ok, err := getSingleFromIDInPath(r.URL.Path, "api/macro", s.db.Macro) 687 | if err != nil { 688 | http.Error(w, err.Error(), http.StatusBadRequest) 689 | return 690 | } else if !ok { 691 | http.Error(w, "missing macro ID", http.StatusBadRequest) 692 | return 693 | } 694 | 695 | // The creator of a macro can delete it, otherwise the caller must be a 696 | // superuser. 697 | if whois.UserProfile.ID != m.Creator && !s.superUser[whois.UserProfile.LoginName] { 698 | http.Error(w, "permission denied", http.StatusUnauthorized) 699 | return 700 | } 701 | if err := s.db.DeleteMacro(m.ID); err != nil { 702 | http.Error(w, err.Error(), http.StatusInternalServerError) 703 | return 704 | } 705 | if err := json.NewEncoder(w).Encode(m); err != nil { 706 | http.Error(w, err.Error(), http.StatusInternalServerError) 707 | } 708 | } 709 | 710 | // serveAPIVotePut implements voting on macros. Unlike images, votes cannot be 711 | // unattributed; each user may vote at most once for a macro. 712 | // 713 | // API: PUT /api/vote/:id/up -- upvote a macro by ID 714 | // API: PUT /api/vote/:id/down -- downvote a macro by ID 715 | func (s *tmemeServer) serveAPIVotePut(w http.ResponseWriter, r *http.Request) { 716 | whois := s.checkAccess(w, r, "vote") 717 | if whois == nil { 718 | return // error already sent 719 | } 720 | 721 | // Accept /api/vote/:id/{up,down} 722 | path, op := r.URL.Path, 0 723 | if v, ok := strings.CutSuffix(path, "/up"); ok { 724 | path, op = v, 1 725 | } else if v, ok := strings.CutSuffix(path, "/down"); ok { 726 | path, op = v, -1 727 | } else { 728 | http.Error(w, "missing vote type", http.StatusBadRequest) 729 | return 730 | } 731 | 732 | m, ok, err := getSingleFromIDInPath(path, "api/vote", s.db.Macro) 733 | if err != nil { 734 | http.Error(w, err.Error(), http.StatusBadRequest) 735 | return 736 | } else if !ok { 737 | http.Error(w, "missing macro ID", http.StatusBadRequest) 738 | return 739 | } 740 | m, err = s.db.SetVote(whois.UserProfile.ID, m.ID, op) 741 | if err != nil { 742 | http.Error(w, err.Error(), http.StatusInternalServerError) 743 | return 744 | } 745 | if err := json.NewEncoder(w).Encode(m); err != nil { 746 | http.Error(w, err.Error(), http.StatusInternalServerError) 747 | } 748 | } 749 | 750 | func (s *tmemeServer) serveAPITemplate(w http.ResponseWriter, r *http.Request) { 751 | serveMetrics.Add("api-template", 1) 752 | switch r.Method { 753 | case "GET": 754 | s.serveAPITemplateGet(w, r) 755 | case "POST": 756 | s.serveAPITemplatePost(w, r) 757 | case "DELETE": 758 | s.serveAPITemplateDelete(w, r) 759 | default: 760 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 761 | } 762 | } 763 | 764 | // serveAPITemplateGet returns metadata about template images. 765 | // 766 | // API: /api/template/:id -- one template by ID 767 | // API: /api/template -- all templates defined 768 | // 769 | // This API supports pagination (see parsePageOptions). 770 | // The result objects are JSON tmemes.Template values. 771 | func (s *tmemeServer) serveAPITemplateGet(w http.ResponseWriter, r *http.Request) { 772 | t, ok, err := getSingleFromIDInPath(r.URL.Path, "api/template", s.db.Template) 773 | if err != nil { 774 | http.Error(w, err.Error(), http.StatusBadRequest) 775 | return 776 | } 777 | w.Header().Set("Content-Type", "application/json") 778 | if ok { 779 | if err := json.NewEncoder(w).Encode(t); err != nil { 780 | http.Error(w, err.Error(), http.StatusInternalServerError) 781 | } 782 | return 783 | } 784 | 785 | var all []*tmemes.Template 786 | // If a creator parameter is set, filter to templates matching that user ID. 787 | // As a special case, "anon" or "anonymous" selects unattributed templates. 788 | uid, err := creatorUserID(r) 789 | if err != nil { 790 | http.Error(w, err.Error(), http.StatusBadRequest) 791 | return 792 | } 793 | if uid == 0 { 794 | all = s.db.Templates() 795 | } else { 796 | all = s.db.TemplatesByCreator(uid) 797 | } 798 | total := len(all) 799 | 800 | // Handle pagination. 801 | page, count, err := parsePageOptions(r, 24) 802 | if err != nil { 803 | http.Error(w, err.Error(), http.StatusBadRequest) 804 | return 805 | } 806 | pageItems, isLast := slicePage(all, page, count) 807 | 808 | rsp := struct { 809 | T []*tmemes.Template `json:"templates"` 810 | N int `json:"total"` 811 | L bool `json:"isLast,omitempty"` 812 | }{T: pageItems, N: total, L: isLast} 813 | if err := json.NewEncoder(w).Encode(rsp); err != nil { 814 | http.Error(w, err.Error(), http.StatusInternalServerError) 815 | } 816 | } 817 | 818 | // serveAPITemplatePost implements creating (uploading) new template images. 819 | // 820 | // API: POST /api/template 821 | // 822 | // The payload must be of type multipart/form-data, and supports the fields: 823 | // 824 | // - image: the image file to upload (required) 825 | // - name: a text description of the template (required) 826 | // - anon: if present and true, create an unattributed template 827 | func (s *tmemeServer) serveAPITemplatePost(w http.ResponseWriter, r *http.Request) { 828 | whois := s.checkAccess(w, r, "create templates") 829 | if whois == nil { 830 | return // error already sent 831 | } 832 | 833 | // Create a new image. 834 | t := &tmemes.Template{ 835 | Name: r.FormValue("name"), 836 | Creator: whois.UserProfile.ID, 837 | } 838 | if anon := r.FormValue("anon"); anon != "" { 839 | anonBool, err := strconv.ParseBool(anon) 840 | if err != nil { 841 | http.Error(w, err.Error(), http.StatusBadRequest) 842 | return 843 | } 844 | if anonBool { 845 | if !s.allowAnonymous { 846 | http.Error(w, "anonymous templates not allowed", http.StatusUnauthorized) 847 | return 848 | } 849 | t.Creator = -1 850 | } 851 | } 852 | 853 | img, header, err := r.FormFile("image") 854 | if err != nil { 855 | http.Error(w, err.Error(), http.StatusBadRequest) 856 | return 857 | } 858 | if header.Size > *maxImageSize<<20 { 859 | http.Error(w, "image too large", http.StatusBadRequest) 860 | return 861 | } 862 | ext := filepath.Ext(header.Filename) 863 | if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".gif" { 864 | http.Error(w, "invalid image format", http.StatusBadRequest) 865 | return 866 | } 867 | imageConfig, _, err := image.DecodeConfig(img) 868 | if err != nil { 869 | http.Error(w, err.Error(), http.StatusBadRequest) 870 | return 871 | } 872 | t.Width = imageConfig.Width 873 | t.Height = imageConfig.Height 874 | img.Seek(0, io.SeekStart) 875 | 876 | etagHash := sha256.New() 877 | if err := s.db.AddTemplate(t, ext, newHashPipe(img, etagHash)); err != nil { 878 | http.Error(w, err.Error(), http.StatusInternalServerError) 879 | return 880 | } 881 | s.imageFileEtags.Store(t.Path, formatEtag(etagHash)) 882 | redirect := fmt.Sprintf("/create/%v", t.ID) 883 | http.Redirect(w, r, redirect, http.StatusFound) 884 | } 885 | 886 | // serveAPITemplateDelete implements deletion of templates. Only the user who 887 | // created a template or an admin can delete a template. Note that because 888 | // unattributed templates do not store a user ID, this means only admins can 889 | // remove anonymous templates. 890 | // 891 | // API: DELETE /api/template/:id 892 | // 893 | // On success, the deleted template object is written back to the caller. 894 | func (s *tmemeServer) serveAPITemplateDelete(w http.ResponseWriter, r *http.Request) { 895 | whois := s.checkAccess(w, r, "delete templates") 896 | if whois == nil { 897 | return // error already sent 898 | } 899 | 900 | t, ok, err := getSingleFromIDInPath(r.URL.Path, "api/template", s.db.Template) 901 | if err != nil { 902 | http.Error(w, err.Error(), http.StatusBadRequest) 903 | return 904 | } else if !ok { 905 | http.Error(w, "missing template ID", http.StatusBadRequest) 906 | return 907 | } 908 | 909 | // The creator of a template can delete it, otherwise the caller must be a 910 | // superuser. 911 | if whois.UserProfile.ID != t.Creator && !s.superUser[whois.UserProfile.LoginName] { 912 | http.Error(w, "permission denied", http.StatusUnauthorized) 913 | return 914 | } 915 | if err := s.db.SetTemplateHidden(t.ID, true); err != nil { 916 | http.Error(w, err.Error(), http.StatusInternalServerError) 917 | return 918 | } 919 | if err := json.NewEncoder(w).Encode(t); err != nil { 920 | http.Error(w, err.Error(), http.StatusInternalServerError) 921 | } 922 | } 923 | 924 | func (s *tmemeServer) serveAPIVote(w http.ResponseWriter, r *http.Request) { 925 | serveMetrics.Add("api-vote", 1) 926 | switch r.Method { 927 | case "GET": 928 | s.serveAPIVoteGet(w, r) 929 | case "DELETE": 930 | s.serveAPIVoteDelete(w, r) 931 | case "PUT": 932 | s.serveAPIVotePut(w, r) 933 | default: 934 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 935 | } 936 | } 937 | 938 | // serveAPIVoteGet reports vote data for the calling user. 939 | // 940 | // API: /api/vote -- report all votes for the caller 941 | // API: /api/vote/:id -- report the user's vote on a macro ID 942 | // 943 | // Vote values are -1 (downvote), 0 (unvoted), and 1 (upvote). 944 | func (s *tmemeServer) serveAPIVoteGet(w http.ResponseWriter, r *http.Request) { 945 | whois := s.checkAccess(w, r, "get votes") 946 | if whois == nil { 947 | return // error already sent 948 | } 949 | 950 | m, ok, err := getSingleFromIDInPath(r.URL.Path, "api/vote", s.db.Macro) 951 | if err != nil { 952 | http.Error(w, err.Error(), http.StatusBadRequest) 953 | return 954 | } 955 | 956 | type macroVote struct { 957 | M int `json:"macroID"` 958 | V int `json:"vote"` 959 | } 960 | 961 | w.Header().Set("Content-Type", "application/json") 962 | if ok { 963 | // Report the user's vote on a single macro. 964 | vote, err := s.db.UserMacroVote(whois.UserProfile.ID, m.ID) 965 | if err != nil { 966 | http.Error(w, err.Error(), http.StatusInternalServerError) 967 | return 968 | } 969 | if err := json.NewEncoder(w).Encode(macroVote{ 970 | M: m.ID, 971 | V: vote, 972 | }); err != nil { 973 | http.Error(w, err.Error(), http.StatusInternalServerError) 974 | } 975 | return 976 | } 977 | 978 | // Report all the user's non-zero votes. 979 | uv, err := s.db.UserVotes(whois.UserProfile.ID) 980 | if err != nil { 981 | http.Error(w, err.Error(), http.StatusInternalServerError) 982 | return 983 | } 984 | votes := make([]macroVote, 0, len(uv)) 985 | for mid, vote := range uv { 986 | votes = append(votes, macroVote{mid, vote}) 987 | } 988 | slices.SortFunc(votes, compare.FromLessFunc(func(a, b macroVote) bool { 989 | return a.M < b.M 990 | })) 991 | 992 | all := struct { 993 | U tailcfg.UserID `json:"userID"` 994 | V []macroVote `json:"votes"` 995 | }{ 996 | U: whois.UserProfile.ID, 997 | V: votes, 998 | } 999 | if err := json.NewEncoder(w).Encode(all); err != nil { 1000 | http.Error(w, err.Error(), http.StatusInternalServerError) 1001 | } 1002 | } 1003 | 1004 | // serveAPIVoteDelete implements removal of a user's vote from a macro. 1005 | // 1006 | // API: DELETE /api/vote/:id 1007 | // 1008 | // This succeeds even if the user had not voted on the specified macro, 1009 | // provided the user is valid and the macro exists. 1010 | func (s *tmemeServer) serveAPIVoteDelete(w http.ResponseWriter, r *http.Request) { 1011 | whois := s.checkAccess(w, r, "delete votes") 1012 | if whois == nil { 1013 | return // error already sent 1014 | } 1015 | 1016 | m, ok, err := getSingleFromIDInPath(r.URL.Path, "api/vote", s.db.Macro) 1017 | if err != nil { 1018 | http.Error(w, err.Error(), http.StatusBadRequest) 1019 | return 1020 | } else if !ok { 1021 | http.Error(w, "missing macro ID", http.StatusBadRequest) 1022 | return 1023 | } 1024 | 1025 | if _, err := s.db.SetVote(whois.UserProfile.ID, m.ID, 0); err != nil { 1026 | http.Error(w, err.Error(), http.StatusInternalServerError) 1027 | } else if err := json.NewEncoder(w).Encode(m); err != nil { 1028 | http.Error(w, err.Error(), http.StatusInternalServerError) 1029 | } 1030 | } 1031 | -------------------------------------------------------------------------------- /cmd/tmemes/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Program tmemes is an image macro server that runs as a node on a tailnet. 5 | // It exposes a UI and API service to create and share base images overlaid 6 | // with user-defined text. 7 | package main 8 | 9 | import ( 10 | "context" 11 | "flag" 12 | "fmt" 13 | "log" 14 | "net/http" 15 | "os" 16 | "os/signal" 17 | "path/filepath" 18 | "syscall" 19 | "time" 20 | 21 | "github.com/tailscale/tmemes/store" 22 | "tailscale.com/tsnet" 23 | "tailscale.com/types/logger" 24 | 25 | _ "modernc.org/sqlite" 26 | ) 27 | 28 | // Flag definitions 29 | var ( 30 | doVerbose = flag.Bool("v", false, "Enable verbose debug logging") 31 | 32 | // Users with administrative ("super-user") powers. By default, only the 33 | // user who created an image can edit or delete it. Marking a user as an 34 | // admin gives them permission to edit or delete any image. 35 | adminUsers = flag.String("admin", "", 36 | "Users with admin rights (comma-separated logins: user@example.com)") 37 | 38 | // If this flag is set true, users are allowed to post unattributed 39 | // ("anonymous") templates and macros. Unattributed images still require 40 | // that the user be authorized by the tailnet, but the server will not 41 | // record their user ID in its database. 42 | allowAnonymous = flag.Bool("allow-anonymous", true, "allow anonymous uploads") 43 | 44 | // The hostname to advertise on the tailnet. 45 | hostName = flag.String("hostname", "tmemes", 46 | "The tailscale hostname to use for the server") 47 | 48 | // This flag controls the maximum image file size the server will allow to 49 | // be uploaded as a template. 50 | maxImageSize = flag.Int64("max-image-size", 4, 51 | "Maximum image size in MiB") 52 | 53 | // The data directory where the server will store its images, caches, and 54 | // the database of macro definitions. 55 | storeDir = flag.String("store", "/tmp/tmemes", "Storage directory (required)") 56 | 57 | // Image macros are generated on the fly and cached. The server periodically 58 | // cleans up cached macros that have not been accessed for some period of 59 | // time, once the cache exceeds a size threshold. 60 | maxAccessAge = flag.Duration("cache-max-access-age", 24*time.Hour, 61 | "How long after last access a cached macro is eligible for cleanup") 62 | minPruneMiB = flag.Int64("cache-min-prune-mib", 512, 63 | "Minimum size of macro cache in MiB to trigger a cleanup") 64 | cacheSeed = flag.String("cache-seed", "", 65 | "Hash seed used to generate cache keys") 66 | ) 67 | 68 | func init() { 69 | flag.Usage = func() { 70 | fmt.Fprintf(os.Stderr, `Usage: [TS_AUTHKEY=k] %[1]s 71 | 72 | Run an image macro service as a node on a tailnet. The service listens for 73 | HTTP requests (not HTTPS) on port 80. 74 | 75 | The first time you start %[1]s, you must authenticate its node on the tailnet 76 | you wnat it to join. To do this, generate an auth key [1] and pass it in via 77 | the TS_AUTHKEY environment variable: 78 | 79 | TS_AUTHKEY=tskey-auth-k______CNTRL-aBC0d1efG2h34iJkLM5nO6pqr7stUV8w9 %[1]s 80 | 81 | We recommend you use a tagged auth key so that the node will not expire. Once 82 | the node is authorized, you can just run the program itself. The server runs 83 | until terminated by SIGINT or SIGTERM. 84 | 85 | [1]: https://tailscale.com/kb/1085/auth-keys/ 86 | 87 | Options: 88 | `, filepath.Base(os.Args[0])) 89 | flag.PrintDefaults() 90 | } 91 | } 92 | 93 | func main() { 94 | flag.Parse() 95 | if *storeDir == "" { 96 | log.Fatal("You must provide a non-empty --store directory") 97 | } else if *maxImageSize <= 0 { 98 | log.Fatal("The -max-image-size must be positive") 99 | } 100 | 101 | db, err := store.New(*storeDir, &store.Options{ 102 | MaxAccessAge: *maxAccessAge, 103 | MinPruneBytes: *minPruneMiB << 20, 104 | }) 105 | if err != nil { 106 | log.Fatalf("Opening store: %v", err) 107 | } else if *cacheSeed != "" { 108 | err := db.SetCacheSeed(*cacheSeed) 109 | if err != nil { 110 | log.Fatalf("Setting cache seed: %v", err) 111 | } 112 | } 113 | defer db.Close() 114 | 115 | logf := logger.Discard 116 | if *doVerbose { 117 | logf = log.Printf 118 | } 119 | s := &tsnet.Server{ 120 | Hostname: *hostName, 121 | Dir: filepath.Join(*storeDir, "tsnet"), 122 | Logf: logf, 123 | } 124 | 125 | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 126 | defer cancel() 127 | go func() { 128 | <-ctx.Done() 129 | log.Print("Signal received, stopping server...") 130 | s.Close() 131 | }() 132 | 133 | ln, err := s.Listen("tcp", ":80") 134 | if err != nil { 135 | panic(err) 136 | } 137 | defer ln.Close() 138 | 139 | lc, err := s.LocalClient() 140 | if err != nil { 141 | panic(err) 142 | } 143 | 144 | ms := &tmemeServer{ 145 | db: db, 146 | srv: s, 147 | lc: lc, 148 | allowAnonymous: *allowAnonymous, 149 | } 150 | if err := ms.initialize(s); err != nil { 151 | panic(err) 152 | } 153 | 154 | log.Print("it's alive!") 155 | http.Serve(ln, ms.newMux()) 156 | } 157 | -------------------------------------------------------------------------------- /cmd/tmemes/static/font/Oswald-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tmemes/b16a51825662834723406490cf513ef6ad0e373d/cmd/tmemes/static/font/Oswald-SemiBold.ttf -------------------------------------------------------------------------------- /cmd/tmemes/static/meme.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tmemes/b16a51825662834723406490cf513ef6ad0e373d/cmd/tmemes/static/meme.wasm -------------------------------------------------------------------------------- /cmd/tmemes/static/script.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | // globals required by draw() 3 | /** @type {CanvasRenderingContext2D} */ let ctx; 4 | /** @type {HTMLCanvasElement} */ let canvas; 5 | /** @type {HTMLImageElement} */ let fallback; 6 | 7 | function readTextValues() { 8 | const top = document.getElementById("top").value; 9 | const bottom = document.getElementById("bottom").value; 10 | let anon = false; 11 | const anonEl = document.getElementById("anon"); 12 | if (anonEl) { 13 | anon = document.getElementById("anon").checked; 14 | } 15 | overlays = []; 16 | if (top !== "") { 17 | overlays.push({ 18 | text: top, 19 | field: { 20 | x: 0.5, 21 | y: 0.15, 22 | width: 1, 23 | }, 24 | color: "white", 25 | strokeColor: "black", 26 | }); 27 | } 28 | if (bottom !== "") { 29 | overlays.push({ 30 | text: bottom, 31 | field: { 32 | x: 0.5, 33 | y: 0.85, 34 | width: 1, 35 | }, 36 | color: "white", 37 | strokeColor: "black", 38 | }); 39 | } 40 | return { overlays, anon }; 41 | } 42 | 43 | function draw(e) { 44 | ctx.clearRect(0, 0, canvas.width, canvas.height); 45 | let x = readTextValues(); 46 | for (let overlay of x.overlays) { 47 | const field = overlay.field; 48 | const text = overlay.text; 49 | const x = field.x * fallback.naturalWidth; 50 | const y = field.y * fallback.naturalHeight; 51 | const width = field.width * fallback.naturalWidth; 52 | 53 | ctx.textBaseline = "middle"; 54 | 55 | // Simulate outline by repeatedly filling the text in black (even though 56 | // Canvas2DRenderingContext.strokeText exists, it has glitches for large 57 | // stroke values -- this replicates what the server-side does) 58 | const n = 6; // visible outline size 59 | ctx.fillStyle = overlay.strokeColor; 60 | for (let dy = -n; dy <= n; dy++) { 61 | for (let dx = -n; dx <= n; dx++) { 62 | if (dx * dx + dy * dy >= n * n) { 63 | // give it rounded corners 64 | continue; 65 | } 66 | ctx.fillText(text, x + dx, y + dy, width); 67 | } 68 | } 69 | 70 | ctx.fillStyle = overlay.color; 71 | ctx.fillText(text, x, y, width); 72 | } 73 | } 74 | 75 | function submitMacro(id) { 76 | values = readTextValues(); 77 | fetch(`/create/${id}`, { 78 | method: "POST", 79 | headers: { 80 | Accept: "application/json", 81 | "Content-Type": "application/json", 82 | }, 83 | body: JSON.stringify(values), 84 | }) 85 | .then(function (response) { 86 | response.json().then((data) => { 87 | window.location.href = "/m/" + data.createdId; 88 | }); 89 | }) 90 | .catch(function (err) { 91 | console.log(`error encountered creating macro: ${err}`); 92 | }); 93 | } 94 | 95 | function deleteMacro(id) { 96 | if ( 97 | confirm( 98 | `Are you sure you want delete macro ID #${id}? This cannot be undone` 99 | ) 100 | ) { 101 | fetch(`/api/macro/${id}`, { 102 | method: "DELETE", 103 | headers: { 104 | Accept: "application/json", 105 | "Content-Type": "application/json", 106 | }, 107 | }) 108 | .then(function () { 109 | window.location.href = "/"; 110 | }) 111 | .catch(function (err) { 112 | console.log(`error encountered deleting macro: ${err}`); 113 | }); 114 | } 115 | } 116 | 117 | function deleteTemplate(id) { 118 | if ( 119 | confirm( 120 | `Are you sure you want delete template ID #${id}? This cannot be undone` 121 | ) 122 | ) { 123 | fetch(`/api/template/${id}`, { 124 | method: "DELETE", 125 | headers: { 126 | Accept: "application/json", 127 | "Content-Type": "application/json", 128 | }, 129 | }) 130 | .then(function () { 131 | window.location.href = "/templates"; 132 | }) 133 | .catch(function (err) { 134 | alert(`error encountered deleting template: ${err}`); 135 | }); 136 | } 137 | } 138 | 139 | function setupCreatePage() { 140 | // setup submit button 141 | const submitBtn = document.getElementById("submit"); 142 | const pathParts = window.location.pathname.split("/"); 143 | const id = pathParts[pathParts.length - 1]; 144 | submitBtn.addEventListener("click", () => { 145 | const warn = 146 | "Are you sure you want to submit this? Your coworkers will see it!"; 147 | if (confirm(warn)) { 148 | submitMacro(id); 149 | } 150 | }); 151 | // TODO more graceful fallback for gifs. 152 | canvas = document.getElementById("preview"); 153 | fallback = document.getElementById("preview-fallback"); 154 | canvas.width = fallback.naturalWidth; 155 | canvas.height = fallback.naturalHeight; 156 | 157 | // To match *tmemeServer.fontForImage. It's not exactly the same, but close enough for now. 158 | const typeHeightFraction = 0.15; 159 | let fontSize = fallback.naturalHeight * 0.75 * typeHeightFraction; 160 | 161 | ctx = canvas.getContext("2d"); 162 | ctx.lineWidth = 1; 163 | ctx.textAlign = "center"; 164 | ctx.font = `${fontSize}px Oswald SemiBold`; 165 | 166 | document.getElementById("top").addEventListener("input", draw); 167 | document.getElementById("bottom").addEventListener("input", draw); 168 | draw(); 169 | } 170 | 171 | function setupListPages() { 172 | // setup delete buttons 173 | const deleteMacros = document.querySelectorAll("button.delete.macro"); 174 | const deleteTemplates = document.querySelectorAll("button.delete.template"); 175 | const upvoteMacros = document.querySelectorAll("button.upvote.macro"); 176 | const downvoteMacros = document.querySelectorAll("button.downvote.macro"); 177 | 178 | for (let i = 0; i < deleteMacros.length; i++) { 179 | const el = deleteMacros[i]; 180 | el.addEventListener("click", () => { 181 | id = el.getAttribute("delete-id"); 182 | deleteMacro(id); 183 | }); 184 | } 185 | 186 | for (let i = 0; i < deleteTemplates.length; i++) { 187 | const el = deleteTemplates[i]; 188 | el.addEventListener("click", () => { 189 | id = el.getAttribute("delete-id"); 190 | deleteTemplate(id); 191 | }); 192 | } 193 | 194 | for (let i = 0; i < upvoteMacros.length; i++) { 195 | const upEl = upvoteMacros[i]; 196 | const downEl = downvoteMacros[i]; 197 | upEl.addEventListener("click", () => { 198 | id = upEl.getAttribute("upvote-id"); 199 | if (upEl.classList.contains("upvoted")) { 200 | unvoteMacro(id, upEl, downEl); 201 | return; 202 | } 203 | upvoteMacro(id, upEl, downEl); 204 | }); 205 | } 206 | 207 | for (let i = 0; i < downvoteMacros.length; i++) { 208 | const upEl = upvoteMacros[i]; 209 | const downEl = downvoteMacros[i]; 210 | downEl.addEventListener("click", () => { 211 | id = downEl.getAttribute("downvote-id"); 212 | if (downEl.classList.contains("downvoted")) { 213 | unvoteMacro(id, upEl, downEl); 214 | return; 215 | } 216 | downvoteMacro(id, upEl, downEl); 217 | }); 218 | } 219 | } 220 | 221 | function updateVotes(upvoteElement, downvoteElement, data) { 222 | upvoteElement.innerHTML = data.upvotes ? data.upvotes : 0; 223 | downvoteElement.innerHTML = data.downvotes ? data.downvotes : 0; 224 | } 225 | 226 | function unvoteMacro(id, upvoteElement, downvoteElement) { 227 | fetch(`/api/vote/${id}`, { 228 | method: "DELETE", 229 | }) 230 | .then(function (response) { 231 | response.json().then((data) => { 232 | upvoteElement.classList.remove("upvoted"); 233 | downvoteElement.classList.remove("downvoted"); 234 | updateVotes(upvoteElement, downvoteElement, data); 235 | }); 236 | }) 237 | .catch(function (err) { 238 | console.log(`error encountered deleting macro: ${err}`); 239 | }); 240 | } 241 | 242 | function downvoteMacro(id, upvoteElement, downvoteElement) { 243 | fetch(`/api/vote/${id}/down`, { 244 | method: "PUT", 245 | }) 246 | .then(function (response) { 247 | response.json().then((data) => { 248 | upvoteElement.classList.remove("upvoted"); 249 | downvoteElement.classList.add("downvoted"); 250 | updateVotes(upvoteElement, downvoteElement, data); 251 | }); 252 | }) 253 | .catch(function (err) { 254 | console.log(`error encountered downvoting macro: ${err}`); 255 | }); 256 | } 257 | 258 | function upvoteMacro(id, upvoteElement, downvoteElement) { 259 | fetch(`/api/vote/${id}/up`, { 260 | method: "PUT", 261 | }) 262 | .then(function (response) { 263 | response.json().then((data) => { 264 | upvoteElement.classList.add("upvoted"); 265 | downvoteElement.classList.remove("downvoted"); 266 | updateVotes(upvoteElement, downvoteElement, data); 267 | }); 268 | }) 269 | .catch(function (err) { 270 | console.log(`error encountered upvoting macro: ${err}`); 271 | }); 272 | } 273 | 274 | function setup() { 275 | const page = document.body.getAttribute("id"); 276 | switch (page) { 277 | case "templates": 278 | case "macros": 279 | setupListPages(); 280 | break; 281 | case "create": 282 | setupCreatePage(); 283 | break; 284 | } 285 | } 286 | setup(); 287 | })(); 288 | -------------------------------------------------------------------------------- /cmd/tmemes/static/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --grey: #dadada; 3 | --light-grey: #efefef; 4 | --dark-grey: #aaa; 5 | --text-body: var(--grey); 6 | --text-muted: #A5AFC4; 7 | --bg-body: #15141A; 8 | --bg-cards: #142138; 9 | --success: green; 10 | --warn: #c97514; 11 | --error: #9e0808; 12 | } 13 | 14 | @media (prefers-color-scheme: light) { 15 | :root { 16 | --grey: #595555; 17 | --light-grey: #bbb8b8; 18 | --dark-grey: #6f6f6f; 19 | --text-body: var(--grey); 20 | --text-muted: #66799F; 21 | --bg-body: #fff; 22 | --bg-cards: #dee9ff; 23 | --success: green; 24 | --warn: #c97514; 25 | --error: #9e0808; 26 | } 27 | } 28 | 29 | @font-face { 30 | font-family: 'Oswald SemiBold'; 31 | src: url('/static/font/Oswald-SemiBold.ttf') format('truetype'); 32 | } 33 | 34 | * { 35 | box-sizing: border-box; 36 | } 37 | 38 | body { 39 | font-family: sans-serif; 40 | margin: 0; 41 | padding: 0; 42 | font-size: 18px; 43 | background: var(--bg-body); 44 | color: var(--text-body); 45 | min-height: 100vh; 46 | } 47 | 48 | .container { 49 | width: 100%; 50 | max-width: 1800px; 51 | margin: 0 auto; 52 | } 53 | 54 | .button { 55 | display: inline-block; 56 | margin: 0.25rem; 57 | background: var(--bg-cards); 58 | color: var(--text-body); 59 | border: 0; 60 | padding: 0.6rem 1.2rem; 61 | cursor: pointer; 62 | transition: 0.2s all; 63 | font-size: 1rem; 64 | } 65 | 66 | .button:hover { 67 | background: var(--text-body); 68 | color: var(--bg-cards); 69 | } 70 | 71 | h1 { 72 | margin-inline: 1rem; 73 | } 74 | 75 | /************************************************** 76 | NAVIGATION 77 | **************************************************/ 78 | nav { 79 | display: flex; 80 | align-items: flex-start; 81 | flex-wrap: wrap; 82 | } 83 | 84 | nav svg { 85 | display: block; 86 | align-self: center; 87 | margin: 0.25rem 1rem 0 1.5rem; 88 | color: initial; 89 | } 90 | 91 | nav a { 92 | display: block; 93 | padding: 1rem 1.5rem; 94 | text-decoration: none; 95 | color: var(--bg-cards); 96 | } 97 | 98 | nav a:hover, 99 | nav a.active { 100 | color: var(--text-body); 101 | background: var(--bg-body); 102 | } 103 | 104 | .nav-wrapper { 105 | background: var(--dark-grey); 106 | } 107 | 108 | .pages { 109 | display: flex; 110 | justify-content: center; 111 | } 112 | 113 | .pages a { 114 | margin: 0.5em; 115 | } 116 | 117 | /************************************************** 118 | IMAGE CARDS 119 | **************************************************/ 120 | 121 | .meme-list { 122 | max-width: 100%; 123 | text-align: center; 124 | display: grid; 125 | grid-gap: 10px; 126 | align-content: center; 127 | grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); 128 | padding: 0 40px; 129 | } 130 | 131 | .meme-list .meme { 132 | margin: 0.5rem; 133 | } 134 | 135 | .meme a { 136 | flex: 2; 137 | display: flex; 138 | justify-content: center; 139 | flex-direction: column; 140 | background: #000; 141 | } 142 | 143 | .meme .context a { 144 | color: var(--text-body); 145 | background: var(--bg-cards); 146 | } 147 | 148 | .meme { 149 | position: relative; 150 | background: var(--bg-cards); 151 | border-radius: 0.5rem; 152 | display: flex; 153 | flex-direction: column; 154 | max-width: 800px; 155 | margin: 0 auto; 156 | } 157 | 158 | .meme .desc { 159 | position: relative; 160 | width: max-content; 161 | background: var(--bg-cards); 162 | } 163 | 164 | .meme img { 165 | width: 100%; 166 | height: auto; 167 | } 168 | 169 | .meta { 170 | padding: 0.75rem 1rem; 171 | color: var(--text-muted); 172 | text-align: left; 173 | font-size: 0.9rem; 174 | font-weight: bold; 175 | height: 4em; 176 | } 177 | 178 | .actions button { 179 | border: 1.5px solid var(--text-muted); 180 | font-weight: bold; 181 | color: var(--text-muted); 182 | background: transparent; 183 | text-align: center; 184 | line-height: 1.8; 185 | border-radius: 3px; 186 | cursor: pointer; 187 | justify-content: flex-start; 188 | height: 30px; 189 | } 190 | 191 | .actions { 192 | display: flex; 193 | } 194 | 195 | .actions .delete { 196 | margin-left: auto; 197 | padding: 0 0.5rem; 198 | } 199 | 200 | .actions .upvote, 201 | .actions .downvote { 202 | width: 50px; 203 | margin-right: 0.6rem; 204 | } 205 | 206 | .actions .upvote::before, 207 | .actions .downvote::before { 208 | display: inline-block; 209 | margin-right: 0.5rem; 210 | } 211 | 212 | .actions .upvote::before { 213 | content: "+"; 214 | } 215 | 216 | .actions .downvote::before { 217 | content: "–"; 218 | } 219 | 220 | .actions .upvote:hover, 221 | .actions .upvoted { 222 | border-color: var(--success); 223 | color: var(--success); 224 | } 225 | 226 | .actions .downvote:hover, 227 | .actions .downvoted { 228 | border-color: var(--warn); 229 | color: var(--warn); 230 | } 231 | 232 | .actions .delete:hover { 233 | border-color: var(--error); 234 | color: var(--error); 235 | } 236 | 237 | /************************************************** 238 | UPLOAD 239 | **************************************************/ 240 | 241 | div.form-input { 242 | padding: 1rem; 243 | } 244 | 245 | div#upload { 246 | border: 4px solid transparent; 247 | } 248 | 249 | .highlight #upload { 250 | border: 4px dashed var(--light-grey); 251 | } 252 | 253 | #image-preview { 254 | width: 100px; 255 | height: 100px; 256 | border-style: solid; 257 | } 258 | 259 | /************************************************** 260 | CREATE PAGE 261 | **************************************************/ 262 | 263 | .create .image-container { 264 | max-width: 600px; 265 | position: relative; 266 | } 267 | 268 | .image-container img { 269 | width: 100%; 270 | display: block; 271 | } 272 | 273 | .image-container canvas { 274 | width: 100%; 275 | position: absolute; 276 | top: 0; 277 | left: 0; 278 | } 279 | 280 | .create { 281 | display: flex; 282 | gap: 2rem; 283 | } 284 | 285 | .text-entry { 286 | display: grid; 287 | grid-template-columns: 150px 1fr; 288 | row-gap: 0.5rem; 289 | column-gap: 1rem; 290 | padding-top: 1rem; 291 | } 292 | 293 | .text-entry label { 294 | font-size: 0.95rem; 295 | } 296 | 297 | .text-entry .input[type="text"] { 298 | padding: 0.5rem 0.8rem; 299 | } 300 | 301 | .create-data .button { 302 | margin: 1rem 0 0; 303 | } 304 | 305 | 306 | /************************************************** 307 | TEMPLATES PAGE 308 | **************************************************/ 309 | 310 | .template-link { 311 | text-decoration: none; 312 | color: var(--text-body); 313 | position: relative; 314 | } 315 | 316 | .template-link img { 317 | transition: 0.3s all; 318 | } 319 | 320 | .template-link:hover img { 321 | opacity: 0.2; 322 | transition: 0.3s all; 323 | } 324 | 325 | .template-link:hover::before { 326 | position: absolute; 327 | top: 40%; 328 | content: "Use this template"; 329 | color: var(--text-body); 330 | font-size: 1.5rem; 331 | text-align: center; 332 | padding: 2rem; 333 | display: block; 334 | width: 100%; 335 | box-sizing: border-box; 336 | } 337 | -------------------------------------------------------------------------------- /cmd/tmemes/static/wasm_exec.js: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | "use strict"; 6 | 7 | (() => { 8 | const enosys = () => { 9 | const err = new Error("not implemented"); 10 | err.code = "ENOSYS"; 11 | return err; 12 | }; 13 | 14 | if (!globalThis.fs) { 15 | let outputBuf = ""; 16 | globalThis.fs = { 17 | constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused 18 | writeSync(fd, buf) { 19 | outputBuf += decoder.decode(buf); 20 | const nl = outputBuf.lastIndexOf("\n"); 21 | if (nl != -1) { 22 | console.log(outputBuf.substring(0, nl)); 23 | outputBuf = outputBuf.substring(nl + 1); 24 | } 25 | return buf.length; 26 | }, 27 | write(fd, buf, offset, length, position, callback) { 28 | if (offset !== 0 || length !== buf.length || position !== null) { 29 | callback(enosys()); 30 | return; 31 | } 32 | const n = this.writeSync(fd, buf); 33 | callback(null, n); 34 | }, 35 | chmod(path, mode, callback) { callback(enosys()); }, 36 | chown(path, uid, gid, callback) { callback(enosys()); }, 37 | close(fd, callback) { callback(enosys()); }, 38 | fchmod(fd, mode, callback) { callback(enosys()); }, 39 | fchown(fd, uid, gid, callback) { callback(enosys()); }, 40 | fstat(fd, callback) { callback(enosys()); }, 41 | fsync(fd, callback) { callback(null); }, 42 | ftruncate(fd, length, callback) { callback(enosys()); }, 43 | lchown(path, uid, gid, callback) { callback(enosys()); }, 44 | link(path, link, callback) { callback(enosys()); }, 45 | lstat(path, callback) { callback(enosys()); }, 46 | mkdir(path, perm, callback) { callback(enosys()); }, 47 | open(path, flags, mode, callback) { callback(enosys()); }, 48 | read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, 49 | readdir(path, callback) { callback(enosys()); }, 50 | readlink(path, callback) { callback(enosys()); }, 51 | rename(from, to, callback) { callback(enosys()); }, 52 | rmdir(path, callback) { callback(enosys()); }, 53 | stat(path, callback) { callback(enosys()); }, 54 | symlink(path, link, callback) { callback(enosys()); }, 55 | truncate(path, length, callback) { callback(enosys()); }, 56 | unlink(path, callback) { callback(enosys()); }, 57 | utimes(path, atime, mtime, callback) { callback(enosys()); }, 58 | }; 59 | } 60 | 61 | if (!globalThis.process) { 62 | globalThis.process = { 63 | getuid() { return -1; }, 64 | getgid() { return -1; }, 65 | geteuid() { return -1; }, 66 | getegid() { return -1; }, 67 | getgroups() { throw enosys(); }, 68 | pid: -1, 69 | ppid: -1, 70 | umask() { throw enosys(); }, 71 | cwd() { throw enosys(); }, 72 | chdir() { throw enosys(); }, 73 | } 74 | } 75 | 76 | if (!globalThis.crypto) { 77 | throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); 78 | } 79 | 80 | if (!globalThis.performance) { 81 | throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); 82 | } 83 | 84 | if (!globalThis.TextEncoder) { 85 | throw new Error("globalThis.TextEncoder is not available, polyfill required"); 86 | } 87 | 88 | if (!globalThis.TextDecoder) { 89 | throw new Error("globalThis.TextDecoder is not available, polyfill required"); 90 | } 91 | 92 | const encoder = new TextEncoder("utf-8"); 93 | const decoder = new TextDecoder("utf-8"); 94 | 95 | globalThis.Go = class { 96 | constructor() { 97 | this.argv = ["js"]; 98 | this.env = {}; 99 | this.exit = (code) => { 100 | if (code !== 0) { 101 | console.warn("exit code:", code); 102 | } 103 | }; 104 | this._exitPromise = new Promise((resolve) => { 105 | this._resolveExitPromise = resolve; 106 | }); 107 | this._pendingEvent = null; 108 | this._scheduledTimeouts = new Map(); 109 | this._nextCallbackTimeoutID = 1; 110 | 111 | const setInt64 = (addr, v) => { 112 | this.mem.setUint32(addr + 0, v, true); 113 | this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); 114 | } 115 | 116 | const getInt64 = (addr) => { 117 | const low = this.mem.getUint32(addr + 0, true); 118 | const high = this.mem.getInt32(addr + 4, true); 119 | return low + high * 4294967296; 120 | } 121 | 122 | const loadValue = (addr) => { 123 | const f = this.mem.getFloat64(addr, true); 124 | if (f === 0) { 125 | return undefined; 126 | } 127 | if (!isNaN(f)) { 128 | return f; 129 | } 130 | 131 | const id = this.mem.getUint32(addr, true); 132 | return this._values[id]; 133 | } 134 | 135 | const storeValue = (addr, v) => { 136 | const nanHead = 0x7FF80000; 137 | 138 | if (typeof v === "number" && v !== 0) { 139 | if (isNaN(v)) { 140 | this.mem.setUint32(addr + 4, nanHead, true); 141 | this.mem.setUint32(addr, 0, true); 142 | return; 143 | } 144 | this.mem.setFloat64(addr, v, true); 145 | return; 146 | } 147 | 148 | if (v === undefined) { 149 | this.mem.setFloat64(addr, 0, true); 150 | return; 151 | } 152 | 153 | let id = this._ids.get(v); 154 | if (id === undefined) { 155 | id = this._idPool.pop(); 156 | if (id === undefined) { 157 | id = this._values.length; 158 | } 159 | this._values[id] = v; 160 | this._goRefCounts[id] = 0; 161 | this._ids.set(v, id); 162 | } 163 | this._goRefCounts[id]++; 164 | let typeFlag = 0; 165 | switch (typeof v) { 166 | case "object": 167 | if (v !== null) { 168 | typeFlag = 1; 169 | } 170 | break; 171 | case "string": 172 | typeFlag = 2; 173 | break; 174 | case "symbol": 175 | typeFlag = 3; 176 | break; 177 | case "function": 178 | typeFlag = 4; 179 | break; 180 | } 181 | this.mem.setUint32(addr + 4, nanHead | typeFlag, true); 182 | this.mem.setUint32(addr, id, true); 183 | } 184 | 185 | const loadSlice = (addr) => { 186 | const array = getInt64(addr + 0); 187 | const len = getInt64(addr + 8); 188 | return new Uint8Array(this._inst.exports.mem.buffer, array, len); 189 | } 190 | 191 | const loadSliceOfValues = (addr) => { 192 | const array = getInt64(addr + 0); 193 | const len = getInt64(addr + 8); 194 | const a = new Array(len); 195 | for (let i = 0; i < len; i++) { 196 | a[i] = loadValue(array + i * 8); 197 | } 198 | return a; 199 | } 200 | 201 | const loadString = (addr) => { 202 | const saddr = getInt64(addr + 0); 203 | const len = getInt64(addr + 8); 204 | return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); 205 | } 206 | 207 | const timeOrigin = Date.now() - performance.now(); 208 | this.importObject = { 209 | go: { 210 | // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) 211 | // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported 212 | // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). 213 | // This changes the SP, thus we have to update the SP used by the imported function. 214 | 215 | // func wasmExit(code int32) 216 | "runtime.wasmExit": (sp) => { 217 | sp >>>= 0; 218 | const code = this.mem.getInt32(sp + 8, true); 219 | this.exited = true; 220 | delete this._inst; 221 | delete this._values; 222 | delete this._goRefCounts; 223 | delete this._ids; 224 | delete this._idPool; 225 | this.exit(code); 226 | }, 227 | 228 | // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) 229 | "runtime.wasmWrite": (sp) => { 230 | sp >>>= 0; 231 | const fd = getInt64(sp + 8); 232 | const p = getInt64(sp + 16); 233 | const n = this.mem.getInt32(sp + 24, true); 234 | fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); 235 | }, 236 | 237 | // func resetMemoryDataView() 238 | "runtime.resetMemoryDataView": (sp) => { 239 | sp >>>= 0; 240 | this.mem = new DataView(this._inst.exports.mem.buffer); 241 | }, 242 | 243 | // func nanotime1() int64 244 | "runtime.nanotime1": (sp) => { 245 | sp >>>= 0; 246 | setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); 247 | }, 248 | 249 | // func walltime() (sec int64, nsec int32) 250 | "runtime.walltime": (sp) => { 251 | sp >>>= 0; 252 | const msec = (new Date).getTime(); 253 | setInt64(sp + 8, msec / 1000); 254 | this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); 255 | }, 256 | 257 | // func scheduleTimeoutEvent(delay int64) int32 258 | "runtime.scheduleTimeoutEvent": (sp) => { 259 | sp >>>= 0; 260 | const id = this._nextCallbackTimeoutID; 261 | this._nextCallbackTimeoutID++; 262 | this._scheduledTimeouts.set(id, setTimeout( 263 | () => { 264 | this._resume(); 265 | while (this._scheduledTimeouts.has(id)) { 266 | // for some reason Go failed to register the timeout event, log and try again 267 | // (temporary workaround for https://github.com/golang/go/issues/28975) 268 | console.warn("scheduleTimeoutEvent: missed timeout event"); 269 | this._resume(); 270 | } 271 | }, 272 | getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early 273 | )); 274 | this.mem.setInt32(sp + 16, id, true); 275 | }, 276 | 277 | // func clearTimeoutEvent(id int32) 278 | "runtime.clearTimeoutEvent": (sp) => { 279 | sp >>>= 0; 280 | const id = this.mem.getInt32(sp + 8, true); 281 | clearTimeout(this._scheduledTimeouts.get(id)); 282 | this._scheduledTimeouts.delete(id); 283 | }, 284 | 285 | // func getRandomData(r []byte) 286 | "runtime.getRandomData": (sp) => { 287 | sp >>>= 0; 288 | crypto.getRandomValues(loadSlice(sp + 8)); 289 | }, 290 | 291 | // func finalizeRef(v ref) 292 | "syscall/js.finalizeRef": (sp) => { 293 | sp >>>= 0; 294 | const id = this.mem.getUint32(sp + 8, true); 295 | this._goRefCounts[id]--; 296 | if (this._goRefCounts[id] === 0) { 297 | const v = this._values[id]; 298 | this._values[id] = null; 299 | this._ids.delete(v); 300 | this._idPool.push(id); 301 | } 302 | }, 303 | 304 | // func stringVal(value string) ref 305 | "syscall/js.stringVal": (sp) => { 306 | sp >>>= 0; 307 | storeValue(sp + 24, loadString(sp + 8)); 308 | }, 309 | 310 | // func valueGet(v ref, p string) ref 311 | "syscall/js.valueGet": (sp) => { 312 | sp >>>= 0; 313 | const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); 314 | sp = this._inst.exports.getsp() >>> 0; // see comment above 315 | storeValue(sp + 32, result); 316 | }, 317 | 318 | // func valueSet(v ref, p string, x ref) 319 | "syscall/js.valueSet": (sp) => { 320 | sp >>>= 0; 321 | Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); 322 | }, 323 | 324 | // func valueDelete(v ref, p string) 325 | "syscall/js.valueDelete": (sp) => { 326 | sp >>>= 0; 327 | Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); 328 | }, 329 | 330 | // func valueIndex(v ref, i int) ref 331 | "syscall/js.valueIndex": (sp) => { 332 | sp >>>= 0; 333 | storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); 334 | }, 335 | 336 | // valueSetIndex(v ref, i int, x ref) 337 | "syscall/js.valueSetIndex": (sp) => { 338 | sp >>>= 0; 339 | Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); 340 | }, 341 | 342 | // func valueCall(v ref, m string, args []ref) (ref, bool) 343 | "syscall/js.valueCall": (sp) => { 344 | sp >>>= 0; 345 | try { 346 | const v = loadValue(sp + 8); 347 | const m = Reflect.get(v, loadString(sp + 16)); 348 | const args = loadSliceOfValues(sp + 32); 349 | const result = Reflect.apply(m, v, args); 350 | sp = this._inst.exports.getsp() >>> 0; // see comment above 351 | storeValue(sp + 56, result); 352 | this.mem.setUint8(sp + 64, 1); 353 | } catch (err) { 354 | sp = this._inst.exports.getsp() >>> 0; // see comment above 355 | storeValue(sp + 56, err); 356 | this.mem.setUint8(sp + 64, 0); 357 | } 358 | }, 359 | 360 | // func valueInvoke(v ref, args []ref) (ref, bool) 361 | "syscall/js.valueInvoke": (sp) => { 362 | sp >>>= 0; 363 | try { 364 | const v = loadValue(sp + 8); 365 | const args = loadSliceOfValues(sp + 16); 366 | const result = Reflect.apply(v, undefined, args); 367 | sp = this._inst.exports.getsp() >>> 0; // see comment above 368 | storeValue(sp + 40, result); 369 | this.mem.setUint8(sp + 48, 1); 370 | } catch (err) { 371 | sp = this._inst.exports.getsp() >>> 0; // see comment above 372 | storeValue(sp + 40, err); 373 | this.mem.setUint8(sp + 48, 0); 374 | } 375 | }, 376 | 377 | // func valueNew(v ref, args []ref) (ref, bool) 378 | "syscall/js.valueNew": (sp) => { 379 | sp >>>= 0; 380 | try { 381 | const v = loadValue(sp + 8); 382 | const args = loadSliceOfValues(sp + 16); 383 | const result = Reflect.construct(v, args); 384 | sp = this._inst.exports.getsp() >>> 0; // see comment above 385 | storeValue(sp + 40, result); 386 | this.mem.setUint8(sp + 48, 1); 387 | } catch (err) { 388 | sp = this._inst.exports.getsp() >>> 0; // see comment above 389 | storeValue(sp + 40, err); 390 | this.mem.setUint8(sp + 48, 0); 391 | } 392 | }, 393 | 394 | // func valueLength(v ref) int 395 | "syscall/js.valueLength": (sp) => { 396 | sp >>>= 0; 397 | setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); 398 | }, 399 | 400 | // valuePrepareString(v ref) (ref, int) 401 | "syscall/js.valuePrepareString": (sp) => { 402 | sp >>>= 0; 403 | const str = encoder.encode(String(loadValue(sp + 8))); 404 | storeValue(sp + 16, str); 405 | setInt64(sp + 24, str.length); 406 | }, 407 | 408 | // valueLoadString(v ref, b []byte) 409 | "syscall/js.valueLoadString": (sp) => { 410 | sp >>>= 0; 411 | const str = loadValue(sp + 8); 412 | loadSlice(sp + 16).set(str); 413 | }, 414 | 415 | // func valueInstanceOf(v ref, t ref) bool 416 | "syscall/js.valueInstanceOf": (sp) => { 417 | sp >>>= 0; 418 | this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); 419 | }, 420 | 421 | // func copyBytesToGo(dst []byte, src ref) (int, bool) 422 | "syscall/js.copyBytesToGo": (sp) => { 423 | sp >>>= 0; 424 | const dst = loadSlice(sp + 8); 425 | const src = loadValue(sp + 32); 426 | if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { 427 | this.mem.setUint8(sp + 48, 0); 428 | return; 429 | } 430 | const toCopy = src.subarray(0, dst.length); 431 | dst.set(toCopy); 432 | setInt64(sp + 40, toCopy.length); 433 | this.mem.setUint8(sp + 48, 1); 434 | }, 435 | 436 | // func copyBytesToJS(dst ref, src []byte) (int, bool) 437 | "syscall/js.copyBytesToJS": (sp) => { 438 | sp >>>= 0; 439 | const dst = loadValue(sp + 8); 440 | const src = loadSlice(sp + 16); 441 | if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { 442 | this.mem.setUint8(sp + 48, 0); 443 | return; 444 | } 445 | const toCopy = src.subarray(0, dst.length); 446 | dst.set(toCopy); 447 | setInt64(sp + 40, toCopy.length); 448 | this.mem.setUint8(sp + 48, 1); 449 | }, 450 | 451 | "debug": (value) => { 452 | console.log(value); 453 | }, 454 | } 455 | }; 456 | } 457 | 458 | async run(instance) { 459 | if (!(instance instanceof WebAssembly.Instance)) { 460 | throw new Error("Go.run: WebAssembly.Instance expected"); 461 | } 462 | this._inst = instance; 463 | this.mem = new DataView(this._inst.exports.mem.buffer); 464 | this._values = [ // JS values that Go currently has references to, indexed by reference id 465 | NaN, 466 | 0, 467 | null, 468 | true, 469 | false, 470 | globalThis, 471 | this, 472 | ]; 473 | this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id 474 | this._ids = new Map([ // mapping from JS values to reference ids 475 | [0, 1], 476 | [null, 2], 477 | [true, 3], 478 | [false, 4], 479 | [globalThis, 5], 480 | [this, 6], 481 | ]); 482 | this._idPool = []; // unused ids that have been garbage collected 483 | this.exited = false; // whether the Go program has exited 484 | 485 | // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. 486 | let offset = 4096; 487 | 488 | const strPtr = (str) => { 489 | const ptr = offset; 490 | const bytes = encoder.encode(str + "\0"); 491 | new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); 492 | offset += bytes.length; 493 | if (offset % 8 !== 0) { 494 | offset += 8 - (offset % 8); 495 | } 496 | return ptr; 497 | }; 498 | 499 | const argc = this.argv.length; 500 | 501 | const argvPtrs = []; 502 | this.argv.forEach((arg) => { 503 | argvPtrs.push(strPtr(arg)); 504 | }); 505 | argvPtrs.push(0); 506 | 507 | const keys = Object.keys(this.env).sort(); 508 | keys.forEach((key) => { 509 | argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); 510 | }); 511 | argvPtrs.push(0); 512 | 513 | const argv = offset; 514 | argvPtrs.forEach((ptr) => { 515 | this.mem.setUint32(offset, ptr, true); 516 | this.mem.setUint32(offset + 4, 0, true); 517 | offset += 8; 518 | }); 519 | 520 | // The linker guarantees global data starts from at least wasmMinDataAddr. 521 | // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. 522 | const wasmMinDataAddr = 4096 + 8192; 523 | if (offset >= wasmMinDataAddr) { 524 | throw new Error("total length of command line and environment variables exceeds limit"); 525 | } 526 | 527 | this._inst.exports.run(argc, argv); 528 | if (this.exited) { 529 | this._resolveExitPromise(); 530 | } 531 | await this._exitPromise; 532 | } 533 | 534 | _resume() { 535 | if (this.exited) { 536 | throw new Error("Go program has already exited"); 537 | } 538 | this._inst.exports.resume(); 539 | if (this.exited) { 540 | this._resolveExitPromise(); 541 | } 542 | } 543 | 544 | _makeFuncWrapper(id) { 545 | const go = this; 546 | return function () { 547 | const event = { id: id, this: this, args: arguments }; 548 | go._pendingEvent = event; 549 | go._resume(); 550 | return event.result; 551 | }; 552 | } 553 | } 554 | })(); 555 | -------------------------------------------------------------------------------- /cmd/tmemes/ui.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "embed" 10 | "encoding/json" 11 | "fmt" 12 | "html/template" 13 | "log" 14 | "net/http" 15 | "path/filepath" 16 | "strconv" 17 | "strings" 18 | "time" 19 | 20 | "github.com/creachadair/mds/compare" 21 | "github.com/tailscale/tmemes" 22 | "golang.org/x/exp/slices" 23 | "tailscale.com/tailcfg" 24 | "tailscale.com/words" 25 | ) 26 | 27 | //go:embed ui/* 28 | var uiFS embed.FS 29 | 30 | //go:embed static 31 | var staticFS embed.FS 32 | 33 | var ui = template.Must(template.New("ui").Funcs(template.FuncMap{ 34 | "timestamp": func(ts time.Time) string { 35 | return ts.Local().Format(time.Stamp) 36 | }, 37 | "add1": func(z int) int { return z + 1 }, 38 | "sub1": func(z int) int { 39 | if z > 1 { 40 | return z - 1 41 | } 42 | return z 43 | }, 44 | }).ParseFS(uiFS, "ui/*.tmpl")) 45 | 46 | // uiData is the value passed to HTML templates. 47 | type uiData struct { 48 | Macros []*uiMacro 49 | Templates []*uiTemplate 50 | 51 | Page int // current page number (1-based; 0 means unpaged) 52 | HasNextPage bool // whether there is a next page 53 | HasPrevPage bool // whether there is a previous page 54 | 55 | CallerID tailcfg.UserID 56 | AllowAnon bool 57 | CallerIsAdmin bool 58 | } 59 | 60 | type uiMacro struct { 61 | *tmemes.Macro 62 | Template *uiTemplate 63 | ImageURL string 64 | CreatorName string 65 | CreatorID tailcfg.UserID 66 | ContextLink []tmemes.ContextLink 67 | Upvoted bool 68 | Downvoted bool 69 | } 70 | 71 | type uiTemplate struct { 72 | *tmemes.Template 73 | ImageURL string 74 | Extension string 75 | CreatorName string 76 | CreatorID tailcfg.UserID 77 | AllowAnon bool 78 | } 79 | 80 | func (s *tmemeServer) newUITemplate(ctx context.Context, t *tmemes.Template) *uiTemplate { 81 | ext := filepath.Ext(t.Path) 82 | return &uiTemplate{ 83 | Template: t, 84 | ImageURL: fmt.Sprintf("/content/template/%d%s", t.ID, ext), 85 | Extension: ext, 86 | CreatorName: s.userDisplayName(ctx, t.Creator, t.CreatedAt), 87 | CreatorID: t.Creator, 88 | AllowAnon: s.allowAnonymous, 89 | } 90 | } 91 | 92 | func (s *tmemeServer) newUIData(ctx context.Context, templates []*tmemes.Template, macros []*tmemes.Macro, caller tailcfg.UserID) *uiData { 93 | data := &uiData{ 94 | AllowAnon: s.allowAnonymous, 95 | CallerID: caller, 96 | CallerIsAdmin: s.userIsAdmin(ctx, caller), 97 | } 98 | 99 | tid := make(map[int]*uiTemplate) 100 | for _, t := range templates { 101 | ut := s.newUITemplate(ctx, t) 102 | data.Templates = append(data.Templates, ut) 103 | tid[t.ID] = ut 104 | } 105 | uv, err := s.db.UserVotes(caller) 106 | if err != nil { 107 | log.Printf("error getting user votes: %v", err) 108 | } 109 | 110 | for _, m := range macros { 111 | mt := tid[m.TemplateID] 112 | if mt == nil { 113 | t, err := s.db.AnyTemplate(m.TemplateID) 114 | if err != nil { 115 | panic(err) // this should not be possible 116 | } 117 | mt = s.newUITemplate(ctx, t) 118 | } 119 | vote := uv[m.ID] 120 | um := &uiMacro{ 121 | Macro: m, 122 | Template: mt, 123 | ImageURL: fmt.Sprintf("/content/macro/%d%s", m.ID, mt.Extension), 124 | ContextLink: m.ContextLink, 125 | CreatorName: s.userDisplayName(ctx, m.Creator, m.CreatedAt), 126 | CreatorID: m.Creator, 127 | } 128 | if vote > 0 { 129 | um.Upvoted = true 130 | } else if vote < 0 { 131 | um.Downvoted = true 132 | } 133 | data.Macros = append(data.Macros, um) 134 | } 135 | 136 | return data 137 | } 138 | 139 | var pick = [2][]string{words.Tails(), words.Scales()} 140 | 141 | func tailyScalyName(ts time.Time) string { 142 | var names []string 143 | v := int(ts.UnixMicro()) 144 | for i := 0; i < 3; i++ { 145 | j := int(v & 1) 146 | v >>= 1 147 | n := len(pick[j]) 148 | k := v % n 149 | v /= n 150 | w := pick[j][k] 151 | names = append(names, strings.ToUpper(w[:1])+w[1:]) 152 | } 153 | return strings.Join(names, " ") 154 | } 155 | 156 | func (s *tmemeServer) userDisplayName(ctx context.Context, id tailcfg.UserID, ts time.Time) string { 157 | p, err := s.userFromID(ctx, id) 158 | if err != nil { 159 | return tailyScalyName(ts) 160 | } else if p.DisplayName != "" { 161 | return p.DisplayName 162 | } 163 | return p.LoginName 164 | } 165 | 166 | func (s *tmemeServer) userIsAdmin(ctx context.Context, id tailcfg.UserID) bool { 167 | p, err := s.userFromID(ctx, id) 168 | if err != nil { 169 | return false // fail closed 170 | } 171 | return s.superUser[p.LoginName] 172 | } 173 | 174 | func getSingleFromIDInPath[T any](path, key string, f func(int) (T, error)) (T, bool, error) { 175 | var zero T 176 | idStr, ok := strings.CutPrefix(path, "/"+key+"/") 177 | if !ok || idStr == "" { 178 | return zero, false, nil 179 | } 180 | id, err := strconv.Atoi(idStr) 181 | if err != nil { 182 | return zero, false, fmt.Errorf("invalid %s ID: %w", key, err) 183 | } 184 | v, err := f(id) 185 | if err != nil { 186 | return v, false, err 187 | } 188 | return v, true, nil 189 | } 190 | 191 | func (s *tmemeServer) serveUICreate(w http.ResponseWriter, r *http.Request) { 192 | serveMetrics.Add("ui-create", 1) 193 | id := strings.TrimPrefix(r.URL.Path, "/create/") 194 | if id == "" { 195 | http.Error(w, "missing id", http.StatusBadRequest) 196 | return 197 | } 198 | idInt, err := strconv.Atoi(id) 199 | if err != nil { 200 | http.Error(w, "invalid id", http.StatusBadRequest) 201 | return 202 | } 203 | t, err := s.db.Template(idInt) 204 | if err != nil { 205 | http.Error(w, err.Error(), http.StatusNotFound) 206 | return 207 | } 208 | 209 | switch r.Method { 210 | case "GET": 211 | s.serveUICreateGet(w, r, t) 212 | case "POST": 213 | s.serveUICreatePost(w, r, t) 214 | default: 215 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 216 | } 217 | } 218 | 219 | func (s *tmemeServer) serveUICreateGet(w http.ResponseWriter, r *http.Request, t *tmemes.Template) { 220 | template := s.newUITemplate(r.Context(), t) 221 | 222 | var buf bytes.Buffer 223 | if err := ui.ExecuteTemplate(&buf, "create.tmpl", template); err != nil { 224 | http.Error(w, err.Error(), http.StatusInternalServerError) 225 | return 226 | } 227 | buf.WriteTo(w) 228 | } 229 | 230 | type webTemplateData struct { 231 | Overlays []tmemes.TextLine `json:"overlays"` 232 | Anon bool `json:"anon"` 233 | } 234 | 235 | func (s *tmemeServer) serveUICreatePost(w http.ResponseWriter, r *http.Request, t *tmemes.Template) { 236 | // TODO: need to refactor out whois protection 237 | whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) 238 | if err != nil { 239 | http.Error(w, err.Error(), http.StatusInternalServerError) 240 | return 241 | } 242 | if whois == nil { 243 | http.Error(w, "not logged in", http.StatusUnauthorized) 244 | return 245 | } 246 | if whois.Node.IsTagged() { 247 | http.Error(w, "tagged nodes cannot create macros", http.StatusForbidden) 248 | return 249 | } 250 | 251 | // actual processing starts here 252 | var webData webTemplateData 253 | if err := json.NewDecoder(r.Body).Decode(&webData); err != nil { 254 | http.Error(w, err.Error(), http.StatusBadRequest) 255 | return 256 | } 257 | 258 | if len(webData.Overlays) == 0 { 259 | http.Error(w, "must specify at least one overlay", http.StatusBadRequest) 260 | return 261 | } 262 | for _, o := range webData.Overlays { 263 | if o.Text == "" { 264 | http.Error(w, "overlay text cannot be empty", http.StatusBadRequest) 265 | return 266 | } 267 | } 268 | 269 | m := tmemes.Macro{ 270 | TemplateID: t.ID, 271 | TextOverlay: webData.Overlays, 272 | } 273 | 274 | if webData.Anon { 275 | if !s.allowAnonymous { 276 | http.Error(w, "anonymous macros not allowed", http.StatusForbidden) 277 | return 278 | } 279 | m.Creator = -1 280 | } else { 281 | m.Creator = whois.UserProfile.ID 282 | } 283 | 284 | if err := s.db.AddMacro(&m); err != nil { 285 | http.Error(w, err.Error(), http.StatusBadRequest) 286 | return 287 | } 288 | 289 | created := struct { 290 | CreatedID int `json:"createdId"` 291 | }{ 292 | CreatedID: m.ID, 293 | } 294 | w.Header().Set("Content-Type", "application/json") 295 | if err := json.NewEncoder(w).Encode(created); err != nil { 296 | http.Error(w, err.Error(), http.StatusInternalServerError) 297 | } 298 | } 299 | 300 | func (s *tmemeServer) serveUITemplates(w http.ResponseWriter, r *http.Request) { 301 | serveMetrics.Add("ui-templates", 1) 302 | if r.Method != "GET" { 303 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 304 | return 305 | } 306 | var templates []*tmemes.Template 307 | if t, ok, err := getSingleFromIDInPath(r.URL.Path, "t", s.db.Template); err != nil { 308 | http.Error(w, err.Error(), http.StatusBadRequest) 309 | return 310 | } else if !ok { 311 | creator, err := creatorUserID(r) 312 | if err != nil { 313 | http.Error(w, err.Error(), http.StatusBadRequest) 314 | return 315 | } 316 | if creator != 0 { 317 | templates = s.db.TemplatesByCreator(creator) 318 | } else { 319 | templates = s.db.Templates() 320 | } 321 | } else { 322 | templates = append(templates, t) 323 | } 324 | slices.SortFunc(templates, compare.FromLessFunc(func(a, b *tmemes.Template) bool { 325 | return a.CreatedAt.After(b.CreatedAt) 326 | })) 327 | 328 | page, count, err := parsePageOptions(r, 24) 329 | if err != nil { 330 | http.Error(w, err.Error(), http.StatusBadRequest) 331 | return 332 | } 333 | if page < 0 { 334 | page = 1 335 | } 336 | pageItems, isLast := slicePage(templates, page, count) 337 | 338 | caller := s.getCallerID(r) 339 | data := s.newUIData(r.Context(), pageItems, nil, caller) 340 | data.Page = page 341 | data.HasNextPage = !isLast 342 | data.HasPrevPage = page > 1 343 | 344 | var buf bytes.Buffer 345 | if err := ui.ExecuteTemplate(&buf, "templates.tmpl", data); err != nil { 346 | http.Error(w, err.Error(), http.StatusInternalServerError) 347 | return 348 | } 349 | buf.WriteTo(w) 350 | } 351 | 352 | func (s *tmemeServer) getCallerID(r *http.Request) tailcfg.UserID { 353 | caller := tailcfg.UserID(-1) 354 | whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) 355 | if err == nil { 356 | caller = whois.UserProfile.ID 357 | } 358 | return caller 359 | } 360 | 361 | func (s *tmemeServer) serveUIMacros(w http.ResponseWriter, r *http.Request) { 362 | serveMetrics.Add("ui-macros", 1) 363 | if r.Method != "GET" { 364 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 365 | return 366 | } 367 | 368 | var macros []*tmemes.Macro 369 | if m, ok, err := getSingleFromIDInPath(r.URL.Path, "m", s.db.Macro); err != nil { 370 | http.Error(w, err.Error(), http.StatusBadRequest) 371 | return 372 | } else if !ok { 373 | creator, err := creatorUserID(r) 374 | if err != nil { 375 | http.Error(w, err.Error(), http.StatusBadRequest) 376 | return 377 | } 378 | if creator != 0 { 379 | macros = s.db.MacrosByCreator(creator) 380 | } else { 381 | macros = s.db.Macros() 382 | } 383 | } else { 384 | macros = append(macros, m) 385 | } 386 | defaultSort := "score" 387 | if v := r.URL.Query().Get("sort"); v != "" { 388 | defaultSort = v 389 | } 390 | if err := sortMacros(defaultSort, macros); err != nil { 391 | http.Error(w, err.Error(), http.StatusBadRequest) 392 | return 393 | } 394 | 395 | page, count, err := parsePageOptions(r, 24) 396 | if err != nil { 397 | http.Error(w, err.Error(), http.StatusBadRequest) 398 | return 399 | } 400 | if page < 0 { 401 | page = 1 402 | } 403 | pageItems, isLast := slicePage(macros, page, count) 404 | 405 | data := s.newUIData(r.Context(), s.db.Templates(), pageItems, s.getCallerID(r)) 406 | data.Page = page 407 | data.HasNextPage = !isLast 408 | data.HasPrevPage = page > 1 409 | 410 | var buf bytes.Buffer 411 | if err := ui.ExecuteTemplate(&buf, "macros.tmpl", data); err != nil { 412 | http.Error(w, err.Error(), http.StatusInternalServerError) 413 | return 414 | } 415 | buf.WriteTo(w) 416 | } 417 | 418 | func (s *tmemeServer) serveUIUpload(w http.ResponseWriter, r *http.Request) { 419 | serveMetrics.Add("ui-upload", 1) 420 | if r.Method != "GET" { 421 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 422 | return 423 | } 424 | var buf bytes.Buffer 425 | uiD := s.newUIData(r.Context(), nil, nil, s.getCallerID(r)) 426 | if err := ui.ExecuteTemplate(&buf, "upload.tmpl", uiD); err != nil { 427 | http.Error(w, err.Error(), http.StatusInternalServerError) 428 | return 429 | } 430 | buf.WriteTo(w) 431 | } 432 | -------------------------------------------------------------------------------- /cmd/tmemes/ui/create.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | tmemes: putting the meme in TS 4 | 5 | 6 | 7 | {{template "nav.tmpl" ""}} 8 |
9 |

Create your own version of {{.Name}}

10 |
11 |
12 | 13 | 14 |
15 |
16 |
17 | 18 | 19 | {{ if .AllowAnon }} 20 | 21 | {{ end }} 22 |
23 | 24 |
25 |
26 |
27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /cmd/tmemes/ui/macros.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | tmemes: putting the meme in TS 4 | 5 | 6 | 7 | {{template "nav.tmpl" "macro"}} 8 |
9 | {{ $caller := .CallerID }}{{ $isAdmin := .CallerIsAdmin }} 10 |

Macros

11 | {{if or .HasPrevPage .HasNextPage}}
12 | {{if .HasPrevPage}}← previous page{{end}} 13 | {{if .HasNextPage}}next page →{{end}} 14 |
{{end}} 15 |
16 | {{range .Macros}} 17 |
18 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | {{if or (eq $caller .CreatorID) $isAdmin}} 28 | 29 | {{end}} 30 |
{{if .ContextLink}} 31 |
Context: {{range .ContextLink}} 32 | {{or .Text .URL}}{{end}} 33 |
{{end}} 34 |
35 | {{end}} 36 |
37 |
38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /cmd/tmemes/ui/nav.tmpl: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /cmd/tmemes/ui/templates.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | tmemes: putting the meme in TS 4 | 5 | 6 | 7 | {{template "nav.tmpl" "templates"}} 8 |
9 | {{ $caller := .CallerID }}{{ $isAdmin := .CallerIsAdmin }} 10 |

Templates

11 | {{if or .HasPrevPage .HasNextPage}}
12 | {{if .HasPrevPage}}← previous page{{end}} 13 | {{if .HasNextPage}}next page →{{end}} 14 |
{{end}} 15 |
16 | {{range .Templates}} 17 | 30 | {{- end}} 31 |
32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /cmd/tmemes/ui/upload.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | tmemes: putting the meme in TS 4 | 5 | 6 | 7 | {{template "nav.tmpl" "upload"}} 8 |
9 |

Upload a Template

10 |
11 |
12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 |
20 | {{ if .AllowAnon }} 21 |
22 | 23 | 24 |
25 | {{ end }} 26 |
27 | 28 |
29 |
30 |
31 | 32 | 61 | 62 | -------------------------------------------------------------------------------- /cmd/tmemes/utils.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package main 5 | 6 | import ( 7 | "crypto/sha256" 8 | "errors" 9 | "fmt" 10 | "hash" 11 | "io" 12 | "net/http" 13 | "os" 14 | "strconv" 15 | "time" 16 | 17 | "github.com/creachadair/mds/compare" 18 | "github.com/creachadair/mds/slice" 19 | "github.com/tailscale/tmemes" 20 | "golang.org/x/exp/slices" 21 | ) 22 | 23 | // sortMacros sorts a slice of macros in-place by the specified sorting key. 24 | // The only possible error is if the sort key is not understood. 25 | func sortMacros(key string, ms []*tmemes.Macro) error { 26 | // Check for sorting order. 27 | switch key { 28 | case "", "default", "id": 29 | // nothing to do, this is the order we get from the database 30 | case "recent": 31 | sortMacrosByRecency(ms) 32 | case "popular": 33 | sortMacrosByPopularity(ms) 34 | case "top-popular": 35 | top := slice.Partition(ms, func(m *tmemes.Macro) bool { 36 | return time.Since(m.CreatedAt) < 1*time.Hour 37 | }) 38 | rest := ms[len(top):] 39 | sortMacrosByRecency(top) 40 | sortMacrosByPopularity(rest) 41 | case "score": 42 | sortMacrosByScore(ms) 43 | default: 44 | return fmt.Errorf("invalid sort order %q", key) 45 | } 46 | return nil 47 | } 48 | 49 | func sortMacrosByRecency(ms []*tmemes.Macro) { 50 | slices.SortFunc(ms, compare.FromLessFunc(func(a, b *tmemes.Macro) bool { 51 | return a.CreatedAt.After(b.CreatedAt) 52 | })) 53 | } 54 | 55 | func sortMacrosByPopularity(ms []*tmemes.Macro) { 56 | // TODO: what should the definition of this be? 57 | slices.SortFunc(ms, compare.FromLessFunc(func(a, b *tmemes.Macro) bool { 58 | da := a.Upvotes - a.Downvotes 59 | db := b.Upvotes - b.Downvotes 60 | if da == db { 61 | return a.CreatedAt.After(b.CreatedAt) 62 | } 63 | return da > db 64 | })) 65 | } 66 | 67 | // sortMacrosByScore sorts macros by a heuristic blended "score" that takes 68 | // into account both recency and popularity. The score favours macros that were 69 | // created very recently, but this bias degrades so that after a while 70 | // popularity dominates. 71 | func sortMacrosByScore(ms []*tmemes.Macro) { 72 | if len(ms) == 0 { 73 | return 74 | } 75 | 76 | // Values are pre-ordered by ID and IDs are assigned sequentially, so the 77 | // oldest remaining is first. 78 | now := time.Now() 79 | oldest := now.Sub(ms[0].CreatedAt) 80 | recencyWeight := float64(oldest / time.Second) 81 | score := func(m *tmemes.Macro) float64 { 82 | // Recency bias is an exponentially decaying increment. 83 | rb := recencyWeight / max(1.0, float64(now.Sub(m.CreatedAt)/time.Second)) 84 | delta := float64(m.Upvotes - m.Downvotes) 85 | return delta + rb 86 | } 87 | slices.SortFunc(ms, compare.FromLessFunc(func(a, b *tmemes.Macro) bool { 88 | return score(a) > score(b) 89 | })) 90 | } 91 | 92 | // parsePageOptions parses "page" and "count" query parameters from r if they 93 | // are present. If they are present, they give the page > 0 and count > 0 that 94 | // the endpoint should return. Otherwise, page < 0. If the count parameter is 95 | // not specified or is 0, defaultCount is returned. It is an error if these 96 | // parameters are present but invalid. 97 | func parsePageOptions(r *http.Request, defaultCount int) (page, count int, _ error) { 98 | pageStr := r.FormValue("page") 99 | if pageStr == "" { 100 | return -1, defaultCount, nil // pagination not requested (ignore count) 101 | } 102 | page, err := strconv.Atoi(pageStr) 103 | if err != nil { 104 | return -1, 0, fmt.Errorf("invalid page: %w", err) 105 | } else if page <= 0 { 106 | return -1, 0, errors.New("page must be positive") 107 | } 108 | 109 | countStr := r.FormValue("count") 110 | if countStr == "" { 111 | return page, defaultCount, nil 112 | } 113 | count, err = strconv.Atoi(countStr) 114 | if err != nil { 115 | return -1, 0, fmt.Errorf("invalid count: %w", err) 116 | } else if count < 0 { 117 | return -1, 0, errors.New("count must be non-negative") 118 | } 119 | 120 | if count == 0 { 121 | return page, defaultCount, nil 122 | } 123 | return page, count, nil 124 | } 125 | 126 | // slicePage returns the subslice of vs corresponding to the page and count 127 | // parameters (as returned by parsePageOptions), or nil if the page and count 128 | // are past the end of vs. In addition, it reports whether this is the last 129 | // page of results given these settings. 130 | func slicePage[T any, S ~[]T](vs S, page, count int) (S, bool) { 131 | if page < 0 { 132 | return vs, true // take the whole input, no pagination 133 | } 134 | start := (page - 1) * count 135 | end := start + count 136 | if start >= len(vs) { 137 | return nil, true // the page starts after the end of vs 138 | } 139 | if end >= len(vs) { 140 | end = len(vs) 141 | return vs[start:end], true 142 | } 143 | return vs[start:end], false 144 | } 145 | 146 | func formatEtag(h hash.Hash) string { return fmt.Sprintf(`"%x"`, h.Sum(nil)) } 147 | 148 | // newHashPipe returns a reader that delegates to r, and as a side-effect 149 | // writes everything successfully read from r as writes to h. 150 | func newHashPipe(r io.Reader, h hash.Hash) io.Reader { return hashPipe{r: r, h: h} } 151 | 152 | type hashPipe struct { 153 | r io.Reader 154 | h hash.Hash 155 | } 156 | 157 | func (h hashPipe) Read(data []byte) (int, error) { 158 | nr, err := h.r.Read(data) 159 | h.h.Write(data[:nr]) 160 | return nr, err 161 | } 162 | 163 | // makeFileEtag returns a quoted Etag hash ("") for the specified file 164 | // path. 165 | func makeFileEtag(path string) (string, error) { 166 | f, err := os.Open(path) 167 | if err != nil { 168 | return "", err 169 | } 170 | defer f.Close() 171 | etagHash := sha256.New() 172 | if _, err := io.Copy(etagHash, f); err != nil { 173 | return "", err 174 | } 175 | return formatEtag(etagHash), nil 176 | } 177 | 178 | // removeItem returns a copy of slice with index i removed. The original slice 179 | // is not modified. 180 | func removeItem[T any, S ~[]T](slice S, i int) S { 181 | cp := make(S, len(slice)-1) 182 | j := copy(cp, slice[:i]) 183 | copy(cp[j:], slice[i+1:]) 184 | return cp 185 | } 186 | -------------------------------------------------------------------------------- /deploy/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | set -x 4 | 5 | cd "$HOME" 6 | if [[ ! -d repo ]] ; then 7 | git clone git@github.com:tailscale/tmemes repo 8 | else 9 | git -C repo pull --rebase 10 | fi 11 | 12 | # Build and link a new binary. 13 | bin="tmemes-$(date +%Y%m%d%H%M%S).bin" 14 | ( cd repo && go build -o "../${bin}" ./tmemes ) 15 | ln -s -f "$bin" ./tmemes 16 | -------------------------------------------------------------------------------- /deploy/restart-tmemes.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | lbin="$(ls -1 tmemes-*.bin | tail -1)" 5 | svc=tmemes.service 6 | 7 | sudo setcap CAP_NET_BIND_SERVICE=+eip $lbin 8 | sudo cp -f "$svc" /etc/systemd/system/"$svc" 9 | 10 | ln -sf $lbin tmemes 11 | 12 | sudo systemctl daemon-reload 13 | sudo systemctl enable "$svc" 14 | sudo systemctl restart "$svc" 15 | 16 | # Clean up old binary versions. 17 | keep=5 18 | ls -1 | grep -E 'tmemes-[0-9]{14}.bin' | \ 19 | jq -sRr 'rtrimstr("\n") | split("\n")[:-'"$keep"'][]' | \ 20 | while read -r old ; do 21 | printf " * cleanup: %s\n" "$old" 1>&2 22 | rm -f -- "$old" 23 | done 24 | -------------------------------------------------------------------------------- /deploy/run-tmemes.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | data="$HOME/data" 5 | ( 6 | cd "$(dirname "$data")" 7 | tar -c --exclude '*/macros/*' data | \ 8 | zstd -9v > data-backup-"$(date +%Y%m%d%H%M%S)".tar.zst 9 | ) 10 | 11 | mkdir -p "$data" 12 | "$HOME/tmemes" \ 13 | -admin=maisem@tailscale.com,fromberger@tailscale.com \ 14 | -allow-anonymous=false \ 15 | -cache-max-access-age=96h \ 16 | -cache-min-prune-mib=8192 \ 17 | -max-image-size=16 \ 18 | -store="$data" 19 | -------------------------------------------------------------------------------- /deploy/tmemes.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | StartLimitIntervalSec=0 3 | StartLimitBurst=0 4 | 5 | [Service] 6 | ExecStart=/home/ubuntu/run-tmemes.sh 7 | WorkingDirectory=/home/ubuntu 8 | User=ubuntu 9 | Group=ubuntu 10 | Restart=on-failure 11 | 12 | [Install] 13 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # tmemes API 2 | 3 | The `tmemes` server exposes an API over HTTP. There are three buckets of 4 | methods: 5 | 6 | - `/` top-level methods serve the UI and navigation (`ui.go`). 7 | - `/api/` methods are for programmatic use and return JSON blobs (`api.go`). 8 | - `/content/` methods grant access to image data (`api.go`). 9 | 10 | Access to the API requires the caller be a user of the tailnet hosting the 11 | server node, or a tailnet into which the server node has been shared. 12 | Access is via plain HTTP (not HTTPS). 13 | No authentication tokens are required. 14 | 15 | # Methods 16 | 17 | ## User Interface 18 | 19 | - `GET /` serve a UI page for all known macros (equivalent to `/m`) 20 | 21 | - `GET /t` serve a UI page for all known templates. Supports [pagination](#pagination). 22 | 23 | - `GET /t/:id` serve a UI page for one template by ID. 24 | 25 | - `GET /m` serve a UI page for all known templates. Supports [pagination](#pagination). 26 | 27 | - `GET /m/:id` serve a UI page for one macro by ID. 28 | 29 | - `GET /create/:id` serve a UI page to create a macro from the template with 30 | the given ID. 31 | 32 | - `GET /upload` serve a UI page to upload a new template image. 33 | 34 | Other top-level endpoints exist to serve styles, scripts, etc. See `newMux()` 35 | in [tmemes/api.go](../tmemes/api.go). 36 | 37 | 38 | ## Programmatic (`/api`) 39 | 40 | - `(GET|DELETE) /api/macro/:id` get or delete one macro by ID. Only a server 41 | admin, or the user who created a macro, can delete it. Anonymous macros can 42 | only be deleted by server admins. 43 | 44 | - `POST /api/macro` create a new macro. The `POST` body must be a JSON 45 | `tmemes.Macro` object (`types.go`). 46 | 47 | - `GET /api/macro` get all macros `{"macros":[...], "total":}`. 48 | This call supports [pagination](#pagination) and [filtering](#filtering). 49 | Paging past the end returns `"macros":null`. 50 | 51 | - `POST /api/context/:id` add, clear, or remove context links on the specified 52 | macro by ID. The request body must be a JSON `tmemes.ContextRequest`, and 53 | unless the action is `"clear"`, (at least) a link URL is required. 54 | 55 | - `(GET|POST|DELETE) /api/template/:id` get, set, delete one template by ID. 56 | The `POST` body must be `multipart/form-data` (TODO: document keys). 57 | 58 | - `GET /api/template` get all templates `{"templates":[...], "total":}`. 59 | This call supports [pagination](#pagination) and [filtering](#filtering). 60 | Paging past the end returns `"templates":null`. 61 | 62 | - `GET /api/vote` to fetch the vote from the calling user on all macros for 63 | which the user has cast a nonzero vote. 64 | 65 | - `GET /api/vote/:id` to fetch the vote from the calling user on the specified 66 | macro. It reports a vote of `0` if the user did not vote on that macro. 67 | 68 | - `DELETE /api/vote/:id` to delete the vote from the calling user on the 69 | specified macro ID. This is a no-op without error if the user did not vote on 70 | that macro. 71 | 72 | - `PUT /api/vote/:id/up` and `PUT /api/vote/:id/down` to set an upvote or 73 | downvote for a single macro by ID, for the calling user. 74 | 75 | 76 | ## Content (`/content`) 77 | 78 | - `GET /content/template/:id` fetch image content for the specified template. 79 | An optional trailing `.ext` (e.g., `.jpg`) is allowed, but it must match the 80 | stored format. 81 | 82 | - `GET /content/macro/:id` fetch image content for the specified macro. An 83 | optional trailing `.ext` is allowed, but it must match the stored template. 84 | Macros are cached and re-generated on-the-fly for this method. 85 | 86 | 87 | ## Pagination 88 | 89 | For APIs that support pagination, the query parameters `page=N` and `count=M` 90 | specify a subset of the available results, returning the Nth page (N > 0) of up 91 | to M values. If `count` is omitted a default is chosen. Regardless whether the 92 | result is paged, the total is the aggregate total for the whole collection. 93 | 94 | ## Sorting 95 | 96 | The query parameter `sort` changes the sort ordering of macro results. Note 97 | that sorting affects pagination, so you can't change the sort order while 98 | paging and expect the results to make sense. 99 | 100 | Sort orders currently defined: 101 | 102 | - `default`, `id`, or omitted: sort by ID ascending. This roughly corresponds 103 | to order of creation, but that's not guaranteed to remain true. 104 | 105 | - `recent` sorts in reverse order of creation time (newest first). 106 | 107 | - `popular` sorts in decreasing order of (upvotes - downvotes), breaking ties 108 | by recency (newest first). 109 | 110 | - `top-popular` sorts entries from the last 1 hour in reverse order of creation 111 | time (as `recent`); entries older than that are sorted by popularity. 112 | 113 | - `score` sorts entries by a blended score that is based on popularity but 114 | which gives extra weight to recent entries. 115 | 116 | ## Filtering 117 | 118 | Where relevant, the query parameter `creator=ID` filters for results created by 119 | the specified user ID. As a special case, `anon` or `anonymous`can be passed to 120 | filter for unattributed templates. 121 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale/tmemes 2 | 3 | go 1.23.1 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/creachadair/mds v0.21.4 9 | github.com/creachadair/taskgroup v0.13.1 10 | github.com/fogleman/gg v1.3.0 11 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 12 | github.com/google/go-cmp v0.6.0 13 | github.com/tailscale/squibble v0.0.0-20240909231413-32a80b9743f7 14 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f 15 | golang.org/x/image v0.19.0 16 | golang.org/x/sys v0.27.0 17 | modernc.org/sqlite v1.30.1 18 | tailscale.com v1.75.0-pre.0.20241118201719-da70a84a4bab 19 | ) 20 | 21 | require ( 22 | filippo.io/edwards25519 v1.1.0 // indirect 23 | github.com/akutz/memconn v0.1.0 // indirect 24 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 25 | github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect 26 | github.com/aws/aws-sdk-go-v2/config v1.26.5 // indirect 27 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 // indirect 28 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect 29 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect 30 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect 31 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect 34 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect 38 | github.com/aws/smithy-go v1.19.0 // indirect 39 | github.com/beorn7/perks v1.0.1 // indirect 40 | github.com/bits-and-blooms/bitset v1.13.0 // indirect 41 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 42 | github.com/coder/websocket v1.8.12 // indirect 43 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect 44 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect 45 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect 46 | github.com/dustin/go-humanize v1.0.1 // indirect 47 | github.com/fxamacker/cbor/v2 v2.6.0 // indirect 48 | github.com/gaissmai/bart v0.11.1 // indirect 49 | github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 // indirect 50 | github.com/go-ole/go-ole v1.3.0 // indirect 51 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 52 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 53 | github.com/google/btree v1.1.2 // indirect 54 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect 55 | github.com/google/uuid v1.6.0 // indirect 56 | github.com/gorilla/csrf v1.7.2 // indirect 57 | github.com/gorilla/securecookie v1.1.2 // indirect 58 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 59 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 60 | github.com/illarion/gonotify/v2 v2.0.3 // indirect 61 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect 62 | github.com/jmespath/go-jmespath v0.4.0 // indirect 63 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect 64 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect 65 | github.com/klauspost/compress v1.17.11 // indirect 66 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect 67 | github.com/mattn/go-isatty v0.0.20 // indirect 68 | github.com/mdlayher/genetlink v1.3.2 // indirect 69 | github.com/mdlayher/netlink v1.7.2 // indirect 70 | github.com/mdlayher/sdnotify v1.0.0 // indirect 71 | github.com/mdlayher/socket v0.5.0 // indirect 72 | github.com/miekg/dns v1.1.58 // indirect 73 | github.com/mitchellh/go-ps v1.0.0 // indirect 74 | github.com/ncruces/go-strftime v0.1.9 // indirect 75 | github.com/pierrec/lz4/v4 v4.1.21 // indirect 76 | github.com/prometheus-community/pro-bing v0.4.0 // indirect 77 | github.com/prometheus/client_golang v1.19.1 // indirect 78 | github.com/prometheus/client_model v0.5.0 // indirect 79 | github.com/prometheus/common v0.48.0 // indirect 80 | github.com/prometheus/procfs v0.12.0 // indirect 81 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 82 | github.com/safchain/ethtool v0.3.0 // indirect 83 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect 84 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 85 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect 86 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 87 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect 88 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect 89 | github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 // indirect 90 | github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // indirect 91 | github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 // indirect 92 | github.com/tcnksm/go-httpstat v0.2.0 // indirect 93 | github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect 94 | github.com/vishvananda/netns v0.0.4 // indirect 95 | github.com/x448/float16 v0.8.4 // indirect 96 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect 97 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 98 | golang.org/x/crypto v0.29.0 // indirect 99 | golang.org/x/mod v0.22.0 // indirect 100 | golang.org/x/net v0.31.0 // indirect 101 | golang.org/x/sync v0.9.0 // indirect 102 | golang.org/x/term v0.26.0 // indirect 103 | golang.org/x/text v0.20.0 // indirect 104 | golang.org/x/time v0.5.0 // indirect 105 | golang.org/x/tools v0.27.0 // indirect 106 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 107 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 108 | google.golang.org/protobuf v1.33.0 // indirect 109 | gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 // indirect 110 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect 111 | modernc.org/libc v1.52.1 // indirect 112 | modernc.org/mathutil v1.6.0 // indirect 113 | modernc.org/memory v1.8.0 // indirect 114 | modernc.org/strutil v1.2.0 // indirect 115 | modernc.org/token v1.1.0 // indirect 116 | ) 117 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= 4 | filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= 5 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= 6 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 7 | github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 8 | github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 9 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 10 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 11 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 12 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 13 | github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= 14 | github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= 15 | github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw= 16 | github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU= 17 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= 18 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= 19 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= 20 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= 21 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= 22 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= 23 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= 24 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= 25 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= 26 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= 27 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= 28 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= 29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= 30 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= 31 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= 32 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= 33 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= 34 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= 35 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= 36 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= 37 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= 38 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= 39 | github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= 40 | github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= 41 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 42 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 43 | github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= 44 | github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 45 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 46 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 47 | github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= 48 | github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= 49 | github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= 50 | github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 51 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= 52 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 53 | github.com/creachadair/mds v0.21.4 h1:osKuLbjkV7YswBnhuTJh1lCDkqZMQnNfFVn0j8wLpz8= 54 | github.com/creachadair/mds v0.21.4/go.mod h1:1ltMWZd9yXhaHEoZwBialMaviWVUpRPvMwVP7saFAzM= 55 | github.com/creachadair/taskgroup v0.13.1 h1:OMDSdQV+OCr7uyS322cBlC2X3YPGec6wWAhAaNoSQwg= 56 | github.com/creachadair/taskgroup v0.13.1/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= 57 | github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= 58 | github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 59 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 60 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 61 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 62 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= 63 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= 64 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= 65 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= 66 | github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= 67 | github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= 68 | github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= 69 | github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= 70 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 71 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 72 | github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= 73 | github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= 74 | github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= 75 | github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= 76 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 77 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 78 | github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= 79 | github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 80 | github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc= 81 | github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg= 82 | github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= 83 | github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= 84 | github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg= 85 | github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA= 86 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 87 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 88 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= 89 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= 90 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 91 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 92 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 93 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 94 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 95 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 96 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 97 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 98 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 99 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 100 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= 101 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= 102 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 103 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 104 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 105 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 106 | github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= 107 | github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 108 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 109 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 110 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 111 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 112 | github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= 113 | github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 114 | github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A= 115 | github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= 116 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= 117 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= 118 | github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= 119 | github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= 120 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 121 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 122 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 123 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 124 | github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= 125 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= 126 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= 127 | github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= 128 | github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= 129 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 130 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 131 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= 132 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= 133 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 134 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 135 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 136 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 137 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 138 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 139 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 140 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 141 | github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 142 | github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 143 | github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= 144 | github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= 145 | github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= 146 | github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= 147 | github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 148 | github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 149 | github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= 150 | github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= 151 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 152 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 153 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 154 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 155 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 156 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 157 | github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 158 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 159 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 160 | github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= 161 | github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= 162 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 163 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 164 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 165 | github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= 166 | github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= 167 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 168 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 169 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 170 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 171 | github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= 172 | github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= 173 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 174 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 175 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 176 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 177 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 178 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 179 | github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= 180 | github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= 181 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 182 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 183 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 184 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= 185 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= 186 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 187 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 188 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 h1:rXZGgEa+k2vJM8xT0PoSKfVXwFGPQ3z3CJfmnHJkZZw= 189 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= 190 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= 191 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= 192 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= 193 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 194 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= 195 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 196 | github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w= 197 | github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU= 198 | github.com/tailscale/squibble v0.0.0-20240909231413-32a80b9743f7 h1:nfklwaP8uNz2IbUygSKOQ1aDzzRRRLaIbPpnQWUUMGc= 199 | github.com/tailscale/squibble v0.0.0-20240909231413-32a80b9743f7/go.mod h1:YH/J7n7jNZOq10nTxxPANv2ha/Eg47/6J5b7NnOYAhQ= 200 | github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g= 201 | github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= 202 | github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= 203 | github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= 204 | github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ= 205 | github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= 206 | github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= 207 | github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= 208 | github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= 209 | github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= 210 | github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= 211 | github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= 212 | github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= 213 | github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= 214 | github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw= 215 | github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= 216 | github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= 217 | github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= 218 | github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 219 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 220 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 221 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= 222 | go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 223 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 224 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 225 | golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= 226 | golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= 227 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= 228 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= 229 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= 230 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 231 | golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= 232 | golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys= 233 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 234 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 235 | golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= 236 | golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= 237 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 238 | golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= 239 | golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 240 | golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 241 | golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 242 | golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 243 | golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 244 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 245 | golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 246 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 247 | golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= 248 | golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 249 | golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= 250 | golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= 251 | golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= 252 | golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= 253 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 254 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 255 | golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= 256 | golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= 257 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 258 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 259 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 260 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 261 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 262 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 263 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 264 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 265 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 266 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 267 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 268 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 269 | gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8= 270 | gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= 271 | honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= 272 | honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= 273 | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 274 | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 275 | modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= 276 | modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 277 | modernc.org/ccgo/v4 v4.17.10 h1:6wrtRozgrhCxieCeJh85QsxkX/2FFrT9hdaWPlbn4Zo= 278 | modernc.org/ccgo/v4 v4.17.10/go.mod h1:0NBHgsqTTpm9cA5z2ccErvGZmtntSM9qD2kFAs6pjXM= 279 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 280 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 281 | modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= 282 | modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 283 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= 284 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= 285 | modernc.org/libc v1.52.1 h1:uau0VoiT5hnR+SpoWekCKbLqm7v6dhRL3hI+NQhgN3M= 286 | modernc.org/libc v1.52.1/go.mod h1:HR4nVzFDSDizP620zcMCgjb1/8xk2lg5p/8yjfGv1IQ= 287 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 288 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 289 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 290 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 291 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 292 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 293 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 294 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 295 | modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk= 296 | modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU= 297 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 298 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 299 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 300 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 301 | software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= 302 | software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 303 | tailscale.com v1.75.0-pre.0.20241118201719-da70a84a4bab h1:40oZJQ/zRcO/F8JDzuzkfF14RKOxnOGwPmxaMKdeEsg= 304 | tailscale.com v1.75.0-pre.0.20241118201719-da70a84a4bab/go.mod h1:OQHJodEdJx4e8lKkSpYmK2H/B1D/VN0EU7g9imiGj2k= 305 | -------------------------------------------------------------------------------- /memedraw/Oswald-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tmemes/b16a51825662834723406490cf513ef6ad0e373d/memedraw/Oswald-SemiBold.ttf -------------------------------------------------------------------------------- /memedraw/draw.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Package memedraw draws text on a tempate. 5 | package memedraw 6 | 7 | import ( 8 | "fmt" 9 | "image" 10 | "image/draw" 11 | "image/gif" 12 | "log" 13 | "math" 14 | "runtime" 15 | "strings" 16 | "time" 17 | 18 | "github.com/creachadair/taskgroup" 19 | "github.com/fogleman/gg" 20 | "github.com/golang/freetype/truetype" 21 | "github.com/tailscale/tmemes" 22 | "golang.org/x/image/font" 23 | 24 | _ "embed" 25 | ) 26 | 27 | // Preloaded font definition. 28 | var ( 29 | //go:embed Oswald-SemiBold.ttf 30 | oswaldSemiBoldBytes []byte 31 | 32 | oswaldSemiBold *truetype.Font 33 | ) 34 | 35 | func init() { 36 | var err error 37 | oswaldSemiBold, err = truetype.Parse(oswaldSemiBoldBytes) 38 | if err != nil { 39 | panic(fmt.Sprintf("Parsing font: %v", err)) 40 | } 41 | } 42 | 43 | // fontForSize constructs a new font.Face for the specified point size. 44 | func fontForSize(points int) font.Face { 45 | return truetype.NewFace(oswaldSemiBold, &truetype.Options{ 46 | Size: float64(points), 47 | }) 48 | } 49 | 50 | // fontSizeForImage computes a recommend font size in points for the given image. 51 | func fontSizeForImage(img image.Image) int { 52 | const typeHeightFraction = 0.15 53 | points := int(math.Round((float64(img.Bounds().Dy()) * 0.75) * typeHeightFraction)) 54 | return points 55 | } 56 | 57 | func oneForZero(v float64) float64 { 58 | if v == 0 { 59 | return 1 60 | } 61 | return v 62 | } 63 | 64 | // overlayTextOnImage paints the specified text line on a single image frame. 65 | func overlayTextOnImage(dc *gg.Context, tl frame, bounds image.Rectangle) { 66 | text := strings.TrimSpace(tl.Text) 67 | if text == "" { 68 | return 69 | } 70 | 71 | fontSize := fontSizeForImage(bounds) 72 | font := fontForSize(fontSize) 73 | dc.SetFontFace(font) 74 | 75 | width := oneForZero(tl.Field[0].Width) * float64(bounds.Dx()) 76 | lineSpacing := 1.25 77 | x := tl.area().X * float64(bounds.Dx()) 78 | y := tl.area().Y * float64(bounds.Dy()) 79 | ax := 0.5 80 | ay := 1.0 81 | fontHeight := dc.FontHeight() 82 | // Replicate part of the DrawStringWrapped logic so that we can draw the 83 | // text multiple times to create an outline effect. 84 | lines := dc.WordWrap(text, width) 85 | 86 | for len(lines) > 2 && fontSize > 6 { 87 | fontSize-- 88 | font = fontForSize(fontSize) 89 | dc.SetFontFace(font) 90 | lines = dc.WordWrap(text, width) 91 | } 92 | 93 | // sync h formula with MeasureMultilineString 94 | h := float64(len(lines)) * fontHeight * lineSpacing 95 | h -= (lineSpacing - 1) * fontHeight 96 | y -= 0.5 * h 97 | 98 | for _, line := range lines { 99 | c := tl.StrokeColor 100 | dc.SetRGB(c.R(), c.G(), c.B()) 101 | 102 | n := 6 // visible outline size 103 | for dy := -n; dy <= n; dy++ { 104 | for dx := -n; dx <= n; dx++ { 105 | if dx*dx+dy*dy >= n*n { 106 | // give it rounded corners 107 | continue 108 | } 109 | dc.DrawStringAnchored(line, x+float64(dx), y+float64(dy), ax, ay) 110 | } 111 | } 112 | 113 | c = tl.Color 114 | dc.SetRGB(c.R(), c.G(), c.B()) 115 | 116 | dc.DrawStringAnchored(line, x, y, ax, ay) 117 | y += fontHeight * lineSpacing 118 | } 119 | } 120 | 121 | func Draw(srcImage image.Image, m *tmemes.Macro) image.Image { 122 | dc := gg.NewContext(srcImage.Bounds().Dx(), srcImage.Bounds().Dy()) 123 | bounds := srcImage.Bounds() 124 | for _, tl := range m.TextOverlay { 125 | overlayTextOnImage(dc, newFrames(1, tl).frame(0), bounds) 126 | } 127 | 128 | alpha := image.NewNRGBA(bounds) 129 | draw.Draw(alpha, bounds, srcImage, bounds.Min, draw.Src) 130 | draw.Draw(alpha, bounds, dc.Image(), bounds.Min, draw.Over) 131 | return alpha 132 | } 133 | 134 | func DrawGIF(img *gif.GIF, m *tmemes.Macro) *gif.GIF { 135 | lineFrames := make([]frames, len(m.TextOverlay)) 136 | for i, tl := range m.TextOverlay { 137 | lineFrames[i] = newFrames(len(img.Image), tl) 138 | } 139 | 140 | bounds := image.Rect(0, 0, img.Config.Width, img.Config.Height) 141 | rStart := time.Now() 142 | 143 | backdrops := make([]*image.Paletted, len(img.Image)) 144 | backdropReady := make([]chan struct{}, len(img.Image)) 145 | for i := range img.Image { 146 | backdropReady[i] = make(chan struct{}) 147 | } 148 | 149 | // Draw first frame's backdrop. 150 | backdrops[0] = image.NewPaletted(bounds, img.Image[0].Palette) 151 | draw.Draw(backdrops[0], bounds, image.NewUniform(img.Image[0].Palette[img.BackgroundIndex]), image.Point{}, draw.Src) 152 | close(backdropReady[0]) 153 | 154 | g, run := taskgroup.New(nil).Limit(runtime.NumCPU()) 155 | for i := 0; i < len(img.Image); i++ { 156 | i, frame := i, img.Image[i] 157 | run.Run(func() { 158 | pal := frame.Palette 159 | fb := frame.Bounds() 160 | 161 | // Block until the required background for this frame is already painted. 162 | <-backdropReady[i] 163 | 164 | dst := image.NewPaletted(bounds, pal) 165 | 166 | // Draw the backdrop. 167 | copy(dst.Pix, backdrops[i].Pix) 168 | 169 | // Draw the frame. 170 | draw.Draw(dst, fb, frame, fb.Min, draw.Over) 171 | 172 | // Sort out next frame's backdrop, unless we're on the final frame. 173 | if i != len(img.Image)-1 { 174 | switch img.Disposal[i] { 175 | case gif.DisposalBackground: 176 | // Restore background colour. 177 | backdrops[i+1] = backdrops[0] 178 | case gif.DisposalPrevious: 179 | // Keep the backdrops the same, i.e. discard whatever this frame drew. 180 | backdrops[i+1] = backdrops[i] 181 | case gif.DisposalNone: 182 | // Do not dispose of the frame, i.e. copy this frame to be next frame's backdrop. 183 | backdrops[i+1] = image.NewPaletted(bounds, pal) 184 | copy(backdrops[i+1].Pix, dst.Pix) 185 | default: 186 | backdrops[i+1] = backdrops[0] 187 | } 188 | close(backdropReady[i+1]) 189 | } 190 | 191 | // Draw the text overlay. 192 | dc := gg.NewContext(bounds.Dx(), bounds.Dy()) 193 | for _, f := range lineFrames { 194 | if f.visibleAt(i) { 195 | overlayTextOnImage(dc, f.frame(i), bounds) 196 | } 197 | } 198 | text := dc.Image() 199 | draw.Draw(dst, dst.Bounds(), text, text.Bounds().Min, draw.Over) 200 | img.Image[i] = dst 201 | }) 202 | } 203 | g.Wait() 204 | 205 | log.Printf("Rendering complete: %v", time.Since(rStart).Round(time.Millisecond)) 206 | return img 207 | } 208 | -------------------------------------------------------------------------------- /memedraw/utils.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package memedraw 5 | 6 | import ( 7 | "math" 8 | 9 | "github.com/tailscale/tmemes" 10 | ) 11 | 12 | // newFrames constructs a frame tracker for a text line given an animation with 13 | // the specified frameCount. 14 | func newFrames(frameCount int, line tmemes.TextLine) frames { 15 | na := len(line.Field) 16 | fpa := math.Ceil(float64(frameCount) / float64(na)) 17 | start, end := 0, frameCount 18 | if line.Start > 0 { 19 | start = int(math.Ceil(line.Start * float64(frameCount))) 20 | } 21 | if line.End > line.Start { 22 | end = int(math.Ceil(line.End * float64(frameCount))) 23 | } 24 | return frames{ 25 | line: line, 26 | framesPerArea: int(fpa), 27 | start: start, 28 | end: end, 29 | } 30 | } 31 | 32 | // A frames value wraps a TextLine with the ability to figure out which of 33 | // possibly-multiple positions should be rendered at a given frame index. 34 | type frames struct { 35 | line tmemes.TextLine 36 | framesPerArea int 37 | start, end int 38 | } 39 | 40 | // visibleAt reports whether the text is visible at index i ≥ 0. 41 | func (f frames) visibleAt(i int) bool { 42 | return f.start <= i && i <= f.end 43 | } 44 | 45 | // frame returns the frame information for index i ≥ 0. 46 | func (f frames) frame(i int) frame { 47 | if len(f.line.Field) == 1 { 48 | return frame{f.line, 0, 0, 1} 49 | } 50 | pos := (i / f.framesPerArea) % len(f.line.Field) 51 | return frame{f.line, pos, i, f.framesPerArea} 52 | } 53 | 54 | // A frame wraps a single-frame view of a movable text line. Call the Area 55 | // method to get the current position for the line. 56 | type frame struct { 57 | tmemes.TextLine 58 | pos, i, fpa int 59 | } 60 | 61 | func (f frame) area() tmemes.Area { 62 | cur := f.Field[f.pos] 63 | if !cur.Tween { 64 | return cur 65 | } 66 | if rem := f.i % f.fpa; rem != 0 { 67 | // Find the next area in sequence (not just the next frame). 68 | npos := ((f.i + f.fpa) / f.fpa) % len(f.Field) 69 | next := f.Field[npos] 70 | 71 | // Compute a linear interpolation and update the apparent position. 72 | // We have a copy, so it's safe to update in-place. 73 | dx := (next.X - cur.X) / float64(f.fpa) 74 | dy := (next.Y - cur.Y) / float64(f.fpa) 75 | cur.X += float64(rem) * dx 76 | cur.Y += float64(rem) * dy 77 | 78 | } 79 | return cur 80 | } 81 | -------------------------------------------------------------------------------- /store/db.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package store 5 | 6 | import ( 7 | "context" 8 | "database/sql" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "log" 13 | "os" 14 | "path/filepath" 15 | "time" 16 | 17 | _ "embed" 18 | 19 | "github.com/tailscale/squibble" 20 | "github.com/tailscale/tmemes" 21 | "golang.org/x/sys/unix" 22 | ) 23 | 24 | //go:embed schema.sql 25 | var schemaText string 26 | 27 | var schema = &squibble.Schema{ 28 | Current: schemaText, 29 | } 30 | 31 | func openDatabase(url string) (*sql.DB, error) { 32 | db, err := sql.Open("sqlite", url) 33 | if err != nil { 34 | return nil, err 35 | } else if err := db.Ping(); err != nil { 36 | db.Close() 37 | return nil, err 38 | } 39 | if err := schema.Apply(context.Background(), db); err != nil { 40 | db.Close() 41 | return nil, err 42 | } 43 | return db, nil 44 | } 45 | 46 | func (db *DB) loadSQLiteIndex() error { 47 | db.mu.Lock() 48 | defer db.mu.Unlock() 49 | 50 | merr := db.loadMacrosLocked() 51 | terr := db.loadTemplatesLocked() 52 | derr := db.loadMetadataLocked() 53 | 54 | return errors.Join(merr, terr, derr) 55 | } 56 | 57 | func (db *DB) loadMacrosLocked() error { 58 | db.macros = make(map[int]*tmemes.Macro) 59 | db.nextMacroID = 0 60 | mr, err := db.sqldb.Query(`SELECT id, raw FROM Macros`) 61 | if err != nil { 62 | return fmt.Errorf("loading macros: %w", err) 63 | } 64 | defer mr.Close() 65 | for mr.Next() { 66 | var id int 67 | var macroJSON []byte 68 | var macro tmemes.Macro 69 | 70 | if err := mr.Scan(&id, ¯oJSON); err != nil { 71 | return fmt.Errorf("scanning macro: %w", err) 72 | } 73 | if id > db.nextMacroID { 74 | db.nextMacroID = id 75 | } 76 | if err := json.Unmarshal(macroJSON, ¯o); err != nil { 77 | return fmt.Errorf("decode macro id %d: %w", id, err) 78 | } 79 | db.macros[id] = ¯o 80 | } 81 | db.nextMacroID++ 82 | return mr.Err() 83 | } 84 | 85 | func (db *DB) loadTemplatesLocked() error { 86 | db.templates = make(map[int]*tmemes.Template) 87 | db.nextTemplateID = 0 88 | mr, err := db.sqldb.Query(`SELECT id, raw FROM Templates`) 89 | if err != nil { 90 | return fmt.Errorf("loading templates: %w", err) 91 | } 92 | defer mr.Close() 93 | for mr.Next() { 94 | var id int 95 | var tmplJSON []byte 96 | var tmpl tmemes.Template 97 | 98 | if err := mr.Scan(&id, &tmplJSON); err != nil { 99 | return fmt.Errorf("scanning template: %w", err) 100 | } 101 | if id > db.nextTemplateID { 102 | db.nextTemplateID = id 103 | } 104 | if err := json.Unmarshal(tmplJSON, &tmpl); err != nil { 105 | return fmt.Errorf("decode template id %d: %w", id, err) 106 | } 107 | db.templates[id] = &tmpl 108 | } 109 | db.nextTemplateID++ 110 | return mr.Err() 111 | } 112 | 113 | func (db *DB) loadMetadataLocked() error { 114 | row := db.sqldb.QueryRow(`SELECT value FROM Meta WHERE key = ?`, "cacheSeed") 115 | if err := row.Scan(&db.cacheSeed); err != nil && !errors.Is(err, sql.ErrNoRows) { 116 | return err 117 | } 118 | return nil 119 | } 120 | 121 | func (db *DB) updateTemplateLocked(t *tmemes.Template) error { 122 | bits, err := json.Marshal(t) 123 | if err != nil { 124 | return err 125 | } 126 | _, err = db.sqldb.Exec(`INSERT OR REPLACE INTO Templates (id, raw) VALUES (?, ?)`, 127 | t.ID, bits) 128 | return err 129 | } 130 | 131 | func (db *DB) updateMacroLocked(m *tmemes.Macro) error { 132 | cp := *m 133 | cp.Upvotes = 0 134 | cp.Downvotes = 0 135 | bits, err := json.Marshal(cp) 136 | if err != nil { 137 | return err 138 | } 139 | _, err = db.sqldb.Exec(`INSERT OR REPLACE INTO Macros (id, raw) VALUES (?, ?)`, 140 | m.ID, bits) 141 | return err 142 | } 143 | 144 | func (db *DB) fillMacroVotesLocked(m *tmemes.Macro) error { 145 | var up, down int 146 | row := db.sqldb.QueryRow(`SELECT up, down FROM VoteTotals WHERE macro_id = ?`, m.ID) 147 | if err := row.Scan(&up, &down); err != nil && !errors.Is(err, sql.ErrNoRows) { 148 | return err 149 | } 150 | m.Upvotes = up 151 | m.Downvotes = down 152 | return nil 153 | } 154 | 155 | func (db *DB) fillAllMacroVotesLocked() error { 156 | tx, err := db.sqldb.Begin() 157 | if err != nil { 158 | return err 159 | } 160 | defer tx.Rollback() 161 | rows, err := tx.Query(`SELECT macro_id, up, down FROM VoteTotals`) 162 | if err != nil { 163 | return err 164 | } 165 | for rows.Next() { 166 | var macroID, up, down int 167 | if err := rows.Scan(¯oID, &up, &down); err != nil { 168 | return err 169 | } 170 | if m, ok := db.macros[macroID]; ok { 171 | m.Upvotes = up 172 | m.Downvotes = down 173 | } 174 | } 175 | return rows.Err() 176 | } 177 | 178 | func (db *DB) cleanMacroCache(ctx context.Context) { 179 | const pollInterval = time.Minute // how often to scan the cache 180 | log.Printf("Starting macro cache cleaner (poll=%v, max-age=%v, min-prune=%d bytes)", 181 | pollInterval, db.maxAccessAge, db.minPruneBytes) 182 | 183 | t := time.NewTicker(pollInterval) 184 | defer t.Stop() 185 | 186 | cacheDir := filepath.Join(db.dir, "macros") 187 | for { 188 | select { 189 | case <-ctx.Done(): 190 | log.Printf("Macro cache cleaner exiting (%v)", ctx.Err()) 191 | return 192 | case <-t.C: 193 | } 194 | 195 | // Phase 1: List all the files in the macro cache. 196 | es, err := os.ReadDir(cacheDir) 197 | if err != nil { 198 | log.Printf("WARNING: reading cache directory: %v (continuing)", err) 199 | continue 200 | } 201 | 202 | // Phase 2: Select candidate paths for removal based on access time. 203 | var totalSize int64 204 | var cand []string 205 | for _, e := range es { 206 | if !e.Type().IsRegular() { 207 | continue // ignore directories, other nonsense 208 | } 209 | 210 | path := filepath.Join(cacheDir, e.Name()) 211 | atime, err := getAccessTime(path) 212 | if err != nil { 213 | continue // skip 214 | } 215 | 216 | age := time.Since(atime) 217 | if age > db.maxAccessAge { 218 | cand = append(cand, path) 219 | } 220 | 221 | fi, _ := e.Info() 222 | totalSize += fi.Size() 223 | } 224 | 225 | // If we don't have eny candidates, or have not stored enough data to be 226 | // worried about, go back to sleep. 227 | if totalSize <= db.minPruneBytes || len(cand) == 0 { 228 | continue 229 | } 230 | 231 | // Phase 3: Grab the lock and clean up candidates. By holding the lock, 232 | // we ensure we are not racing with a last-minute /content request; if we 233 | // win the race, the unlucky call will regenerate the file. If we lose, 234 | // the caller is done with it by the time we unlink. 235 | func() { 236 | db.mu.Lock() 237 | defer db.mu.Unlock() 238 | for _, path := range cand { 239 | if os.Remove(path) == nil { 240 | log.Printf("[macro cache] removed %q", path) 241 | } 242 | 243 | // N.B. We ignore errors herd, it's not the end of the world if we 244 | // aren't able to remove everything. 245 | } 246 | }() 247 | } 248 | } 249 | 250 | func getAccessTime(path string) (time.Time, error) { 251 | var sbuf unix.Stat_t 252 | if err := unix.Stat(path, &sbuf); err != nil { 253 | return time.Time{}, err 254 | } 255 | return time.Unix(sbuf.Atim.Sec, sbuf.Atim.Nsec).UTC(), nil 256 | } 257 | -------------------------------------------------------------------------------- /store/schema.sql: -------------------------------------------------------------------------------- 1 | -- Database schema for tmemes. 2 | CREATE TABLE IF NOT EXISTS Templates ( 3 | id INTEGER PRIMARY KEY, 4 | raw BLOB, -- JSON tmemes.Template 5 | 6 | -- Generated columns. 7 | creator INTEGER AS (json_extract(raw, '$.creator')) STORED, 8 | created_at TIMESTAMP AS (json_extract(raw, '$.createdAt')) STORED, 9 | hidden BOOLEAN AS (coalesce(json_extract(raw, '$.hidden'), 0)) STORED 10 | ); 11 | 12 | CREATE TRIGGER IF NOT EXISTS TemplateDel 13 | AFTER DELETE ON Templates FOR EACH ROW 14 | BEGIN 15 | DELETE FROM Macros WHERE template_id = OLD.id; 16 | END; 17 | 18 | CREATE TABLE IF NOT EXISTS Macros ( 19 | id INTEGER PRIMARY KEY, 20 | raw BLOB, -- JSON tmemes.Macro 21 | 22 | -- Generated columns. 23 | creator INTEGER NULL AS (json_extract(raw, '$.creator')) STORED, 24 | created_at TIMESTAMP AS (json_extract(raw, '$.createdAt')) STORED, 25 | template_id INTEGER AS (json_extract(raw, '$.templateID')) STORED 26 | ); 27 | 28 | CREATE TRIGGER IF NOT EXISTS MacroDel 29 | AFTER DELETE ON Macros FOR EACH ROW 30 | BEGIN 31 | DELETE FROM Votes WHERE macro_id = OLD.id; 32 | END; 33 | 34 | CREATE TABLE IF NOT EXISTS Votes ( 35 | user_id INTEGER NOT NULL, 36 | macro_id INTEGER NOT NULL, 37 | vote INTEGER NOT NULL, 38 | last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 39 | 40 | CHECK (vote = -1 OR vote = 1), 41 | FOREIGN KEY (macro_id) REFERENCES Macros(id), 42 | UNIQUE (user_id, macro_id) 43 | ); 44 | 45 | CREATE VIEW IF NOT EXISTS VoteTotals AS 46 | WITH upvotes AS ( 47 | SELECT macro_id, sum(vote) up FROM Votes 48 | WHERE vote = 1 GROUP BY macro_id 49 | ), downvotes AS ( 50 | SELECT macro_id, -sum(vote) down FROM Votes 51 | WHERE vote = -1 GROUP BY macro_id 52 | ) 53 | SELECT iif(upvotes.macro_id, upvotes.macro_id, downvotes.macro_id) macro_id, 54 | iif(up, up, 0) up, 55 | iif(down, down, 0) down 56 | FROM upvotes FULL OUTER JOIN downvotes 57 | ON (upvotes.macro_id = downvotes.macro_id) 58 | ; 59 | 60 | CREATE TABLE IF NOT EXISTS Meta ( 61 | key TEXT UNIQUE NOT NULL, 62 | value BLOB 63 | ); 64 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Package store implements a data store for memes. 5 | // 6 | // # Structure 7 | // 8 | // A DB manages a directory in the filesystem. At the top level of the 9 | // directory is a SQLite database (index.db) that keeps track of metadata about 10 | // templates, macros, and votes. There are also subdirectories to store the 11 | // image data, "templates" and "macros". 12 | // 13 | // The "macros" subdirectory is a cache, and the DB maintains a background 14 | // polling thread that cleans up files that have not been accessed for a while. 15 | // It is safe to manually delete files inside the macros directory; the server 16 | // will re-create them on demand. Templates images are persistent, and should 17 | // not be modified or deleted. 18 | package store 19 | 20 | import ( 21 | "context" 22 | "database/sql" 23 | "errors" 24 | "fmt" 25 | "io" 26 | "log" 27 | "os" 28 | "path/filepath" 29 | "sort" 30 | "strings" 31 | "sync" 32 | "time" 33 | 34 | "github.com/tailscale/tmemes" 35 | "golang.org/x/exp/maps" 36 | "tailscale.com/tailcfg" 37 | ) 38 | 39 | var subdirs = []string{"templates", "macros"} 40 | 41 | // A DB is a meme database. It consists of a directory containing files and 42 | // subdirectories holding images and metadata. A DB is safe for concurrent use 43 | // by multiple goroutines. 44 | type DB struct { 45 | dir string 46 | stop context.CancelFunc 47 | tasks sync.WaitGroup 48 | minPruneBytes int64 49 | maxAccessAge time.Duration 50 | 51 | mu sync.Mutex 52 | sqldb *sql.DB 53 | cacheSeed []byte 54 | macros map[int]*tmemes.Macro 55 | nextMacroID int 56 | templates map[int]*tmemes.Template 57 | nextTemplateID int 58 | } 59 | 60 | // Options are optional settings for a DB. A nil *Options is ready for use 61 | // with default values. 62 | type Options struct { 63 | // Do not prune the macro cache until it is at least this big. 64 | // Default: 50MB. 65 | MinPruneBytes int64 66 | 67 | // When pruning the cache, discard entries that have not been accessed in at 68 | // least this long. Default: 30m. 69 | MaxAccessAge time.Duration 70 | } 71 | 72 | func (o *Options) minPruneBytes() int64 { 73 | if o == nil || o.MinPruneBytes <= 0 { 74 | return 50 << 20 75 | } 76 | return o.MinPruneBytes 77 | } 78 | 79 | func (o *Options) maxAccessAge() time.Duration { 80 | if o == nil || o.MaxAccessAge <= 0 { 81 | return 30 * time.Minute 82 | } 83 | return o.MaxAccessAge 84 | } 85 | 86 | // New creates or opens a data store. A store is a directory that is created 87 | // if necessary. The DB assumes ownership of the directory contents. A nil 88 | // *Options provides default settings (see [Options]). 89 | // 90 | // The caller should Close the DB when it is no longer in use, to ensure the 91 | // cache maintenance routine is stopped and cleaned up. 92 | func New(dirPath string, opts *Options) (*DB, error) { 93 | if err := os.MkdirAll(dirPath, 0700); err != nil { 94 | return nil, fmt.Errorf("store.New: %w", err) 95 | } 96 | 97 | // Create the standard subdirectories for image data. 98 | for _, sub := range subdirs { 99 | path := filepath.Join(dirPath, sub) 100 | if err := os.MkdirAll(path, 0700); err != nil { 101 | return nil, err 102 | } 103 | } 104 | 105 | dbPath := filepath.Join(dirPath, "index.db") 106 | sqldb, err := openDatabase(dbPath) 107 | if err != nil { 108 | return nil, fmt.Errorf("open sqlite: %w", err) 109 | } 110 | 111 | ctx, cancel := context.WithCancel(context.Background()) 112 | db := &DB{ 113 | dir: dirPath, 114 | minPruneBytes: opts.minPruneBytes(), 115 | maxAccessAge: opts.maxAccessAge(), 116 | stop: cancel, 117 | sqldb: sqldb, 118 | } 119 | if err := db.loadSQLiteIndex(); err != nil { 120 | db.Close() 121 | return nil, err 122 | } 123 | db.tasks.Add(1) 124 | go func() { 125 | defer db.tasks.Done() 126 | db.cleanMacroCache(ctx) 127 | }() 128 | return db, err 129 | } 130 | 131 | // Close stops background tasks and closes the index database. 132 | func (db *DB) Close() error { 133 | db.stop() 134 | db.tasks.Wait() 135 | db.mu.Lock() 136 | defer db.mu.Unlock() 137 | if db.sqldb != nil { 138 | err := db.sqldb.Close() 139 | db.sqldb = nil 140 | return err 141 | } 142 | return nil 143 | } 144 | 145 | // SetCacheSeed sets the base string used when generating cache keys for 146 | // generated macros. If not set, the value persisted in the index is used. 147 | // Changing the cache seed invalidates cached entries. 148 | func (db *DB) SetCacheSeed(s string) error { 149 | db.mu.Lock() 150 | defer db.mu.Unlock() 151 | 152 | if s == string(db.cacheSeed) { 153 | return nil 154 | } 155 | _, err := db.sqldb.Exec(`INSERT OR REPLACE INTO Meta (key, value) VALUES (?,?)`, 156 | "cacheSeed", []byte(s)) 157 | if err == nil { 158 | db.cacheSeed = []byte(s) 159 | } 160 | return err 161 | } 162 | 163 | // Templates returns all the non-hidden templates in the store. 164 | // Templates are ordered non-decreasing by ID. 165 | func (db *DB) Templates() []*tmemes.Template { 166 | db.mu.Lock() 167 | all := make([]*tmemes.Template, 0, len(db.templates)) 168 | for _, t := range db.templates { 169 | if !t.Hidden { 170 | all = append(all, t) 171 | } 172 | } 173 | db.mu.Unlock() 174 | sort.Slice(all, func(i, j int) bool { 175 | return all[i].ID < all[j].ID 176 | }) 177 | return all 178 | } 179 | 180 | // TemplatesByCreator returns all the non-hidden templates in the store created 181 | // by the specified user. The results are ordered non-decreasing by ID. 182 | func (db *DB) TemplatesByCreator(creator tailcfg.UserID) []*tmemes.Template { 183 | db.mu.Lock() 184 | defer db.mu.Unlock() 185 | var all []*tmemes.Template 186 | for _, t := range db.templates { 187 | if !t.Hidden && t.Creator == creator { 188 | all = append(all, t) 189 | } 190 | } 191 | sort.Slice(all, func(i, j int) bool { 192 | return all[i].ID < all[j].ID 193 | }) 194 | return all 195 | } 196 | 197 | // Template returns the template data for the specified ID. 198 | // Hidden templates are treated as not found. 199 | func (db *DB) Template(id int) (*tmemes.Template, error) { 200 | db.mu.Lock() 201 | t, ok := db.templates[id] 202 | db.mu.Unlock() 203 | if !ok || t.Hidden { 204 | return nil, fmt.Errorf("template %d not found", id) 205 | } 206 | return t, nil 207 | } 208 | 209 | // AnyTemplate returns the template data for the specified ID. 210 | // Hidden templates are included. 211 | func (db *DB) AnyTemplate(id int) (*tmemes.Template, error) { 212 | db.mu.Lock() 213 | t, ok := db.templates[id] 214 | db.mu.Unlock() 215 | if !ok { 216 | return nil, fmt.Errorf("template %d not found", id) 217 | } 218 | return t, nil 219 | } 220 | 221 | // SetTemplateHidden sets (or clears) the "hidden" flag of a template. Hidden 222 | // templates are not available for use in creating macros. 223 | func (db *DB) SetTemplateHidden(id int, hidden bool) error { 224 | db.mu.Lock() 225 | defer db.mu.Unlock() 226 | t, ok := db.templates[id] 227 | if !ok { 228 | return fmt.Errorf("template %d not found", id) 229 | } 230 | if t.Hidden != hidden { 231 | t.Hidden = hidden 232 | return db.updateTemplateLocked(t) 233 | } 234 | return nil 235 | } 236 | 237 | var sep = strings.NewReplacer(" ", "-", "_", "-") 238 | 239 | func canonicalTemplateName(name string) string { 240 | base := strings.Join(strings.Fields(strings.TrimSpace(name)), "-") 241 | return sep.Replace(strings.ToLower(base)) 242 | } 243 | 244 | // TemplateByName returns the template data matching the given name. 245 | // Comparison is done without regard to case, leading and trailing whitespace 246 | // are removed, and interior whitespace, "-", and "_" are normalized to "-". 247 | // HIdden templates are excluded. 248 | func (db *DB) TemplateByName(name string) (*tmemes.Template, error) { 249 | cn := canonicalTemplateName(name) 250 | if cn == "" { 251 | return nil, errors.New("empty template name") 252 | } 253 | db.mu.Lock() 254 | defer db.mu.Unlock() 255 | for _, t := range db.templates { 256 | if !t.Hidden && t.Name == cn { 257 | return t, nil 258 | } 259 | } 260 | return nil, fmt.Errorf("template %q not found", cn) 261 | } 262 | 263 | // TemplatePath returns the path of the file containing a template image. 264 | // Hidden templates are included. 265 | func (db *DB) TemplatePath(id int) (string, error) { 266 | // N.B. We include hidden templates in this query, since the image may still 267 | // be used by macros created before the template was hidden. 268 | db.mu.Lock() 269 | t, ok := db.templates[id] 270 | db.mu.Unlock() 271 | if !ok { 272 | return "", fmt.Errorf("template %d not found", id) 273 | } 274 | return filepath.Join(db.dir, t.Path), nil 275 | } 276 | 277 | // Macro returns the macro data for the specified ID. 278 | func (db *DB) Macro(id int) (*tmemes.Macro, error) { 279 | db.mu.Lock() 280 | m, ok := db.macros[id] 281 | db.mu.Unlock() 282 | if !ok { 283 | return nil, fmt.Errorf("macro %d not found", id) 284 | } 285 | return m, nil 286 | } 287 | 288 | // MacrosByCreator returns all the macros created by the specified user. 289 | func (db *DB) MacrosByCreator(creator tailcfg.UserID) []*tmemes.Macro { 290 | db.mu.Lock() 291 | defer db.mu.Unlock() 292 | if err := db.fillAllMacroVotesLocked(); err != nil { 293 | log.Printf("WARNING: filling macro votes: %v (continuing)", err) 294 | } 295 | var all []*tmemes.Macro 296 | for _, m := range db.macros { 297 | if m.Creator == creator { 298 | all = append(all, m) 299 | } 300 | } 301 | sort.Slice(all, func(i, j int) bool { 302 | return all[i].ID < all[j].ID 303 | }) 304 | return all 305 | } 306 | 307 | // Macros returns all the macros in the store. 308 | func (db *DB) Macros() []*tmemes.Macro { 309 | db.mu.Lock() 310 | defer db.mu.Unlock() 311 | if err := db.fillAllMacroVotesLocked(); err != nil { 312 | log.Printf("WARNING: filling macro votes: %v (continuing)", err) 313 | } 314 | all := maps.Values(db.macros) 315 | sort.Slice(all, func(i, j int) bool { 316 | return all[i].ID < all[j].ID 317 | }) 318 | return all 319 | } 320 | 321 | // CachePath returns a cache file path for the specified macro. The path is 322 | // returned even if the file is not cached. 323 | func (db *DB) CachePath(m *tmemes.Macro) (string, error) { 324 | t, err := db.AnyTemplate(m.TemplateID) 325 | if err != nil { 326 | return "", err 327 | } 328 | return db.cachePath(m, t), nil 329 | } 330 | 331 | func (db *DB) cachePath(m *tmemes.Macro, t *tmemes.Template) string { 332 | key := string(db.cacheSeed) 333 | if key == "" { 334 | key = "0000" 335 | } 336 | name := fmt.Sprintf("%s-%d%s", key, m.ID, filepath.Ext(t.Path)) 337 | return filepath.Join(db.dir, "macros", name) 338 | } 339 | 340 | // AddMacro adds m to the database. It reports an error if m.ID != 0, or 341 | // updates m.ID on success. 342 | func (db *DB) AddMacro(m *tmemes.Macro) error { 343 | if m.ID != 0 { 344 | return errors.New("macro ID must be zero") 345 | } else if m.TemplateID == 0 { 346 | return errors.New("macro must have a template ID") 347 | } else if m.TextOverlay == nil { 348 | return errors.New("macro must have an overlay") 349 | } 350 | if m.CreatedAt.IsZero() { 351 | m.CreatedAt = time.Now().UTC() 352 | } 353 | db.mu.Lock() 354 | defer db.mu.Unlock() 355 | if _, ok := db.templates[m.TemplateID]; !ok { 356 | return fmt.Errorf("template %d not found", m.TemplateID) 357 | } 358 | m.ID = db.nextMacroID 359 | m.CreatedAt = time.Now().UTC() 360 | db.nextMacroID++ 361 | db.macros[m.ID] = m 362 | return db.updateMacroLocked(m) 363 | } 364 | 365 | // DeleteMacro deletes the specified macro ID from the database. 366 | func (db *DB) DeleteMacro(id int) error { 367 | db.mu.Lock() 368 | defer db.mu.Unlock() 369 | m, ok := db.macros[id] 370 | if !ok { 371 | return fmt.Errorf("macro %d not found", id) 372 | } 373 | if t, ok := db.templates[m.TemplateID]; ok { 374 | os.Remove(db.cachePath(m, t)) 375 | } 376 | delete(db.macros, id) 377 | _, err := db.sqldb.Exec(`DELETE FROM Macros WHERE id = ?`, id) 378 | return err 379 | } 380 | 381 | // UpdateMacro updates macro m. It reports an error if m is not already in the 382 | // store; otherwise it updates the stored data to the current state of m. 383 | func (db *DB) UpdateMacro(m *tmemes.Macro) error { 384 | db.mu.Lock() 385 | defer db.mu.Unlock() 386 | if _, ok := db.macros[m.ID]; !ok { 387 | return fmt.Errorf("macro %d not found", m.ID) 388 | } 389 | return db.updateMacroLocked(m) 390 | } 391 | 392 | // AddTemplate adds t to the database. The ID must be 0 and the Path must be 393 | // empty, these are populated by a successful add. The other fields of t 394 | // should be initialized by the caller. 395 | // 396 | // If set, fileExt is used as the filename extension for the image file. The 397 | // contents of the template image are fully read from r. 398 | func (db *DB) AddTemplate(t *tmemes.Template, fileExt string, data io.Reader) error { 399 | if t.ID != 0 { 400 | return errors.New("template ID must be zero") 401 | } 402 | if fileExt == "" { 403 | fileExt = "png" 404 | } else { 405 | fileExt = strings.TrimPrefix(fileExt, ".") 406 | } 407 | t.Name = canonicalTemplateName(t.Name) 408 | if t.Name == "" { 409 | return errors.New("empty template name") 410 | } else if _, err := db.TemplateByName(t.Name); err == nil { 411 | return fmt.Errorf("duplicate template name %q", t.Name) 412 | } 413 | if t.CreatedAt.IsZero() { 414 | t.CreatedAt = time.Now().UTC() 415 | } 416 | 417 | db.mu.Lock() 418 | defer db.mu.Unlock() 419 | id := db.nextTemplateID 420 | relPath := filepath.Join("templates", fmt.Sprintf("%d.%s", id, fileExt)) 421 | path := filepath.Join(db.dir, relPath) 422 | f, err := os.Create(path) 423 | if err != nil { 424 | return err 425 | } 426 | if _, err := io.Copy(f, data); err != nil { 427 | f.Close() 428 | return err 429 | } 430 | if err := f.Close(); err != nil { 431 | return err 432 | } 433 | t.ID = id 434 | t.Path = relPath // N.B. not path, the data may move 435 | db.nextTemplateID++ 436 | db.templates[t.ID] = t 437 | return db.updateTemplateLocked(t) 438 | } 439 | 440 | // GetVote returns the given user's vote on a single macro. 441 | // If vote < 0, the user downvoted this macro. 442 | // If vote == 0, the user did not vote on this macro. 443 | // If vote > 0, the user upvoted this macro. 444 | func (db *DB) GetVote(userID tailcfg.UserID, macroID int) (vote int, err error) { 445 | tx, err := db.sqldb.Begin() 446 | if err != nil { 447 | return 0, err 448 | } 449 | defer tx.Rollback() 450 | if err := tx.QueryRow(`SELECT vote FROM Votes WHERE user_id = ? AND macro_id = ?`, 451 | userID, macroID).Scan(&vote); err != nil { 452 | if err == sql.ErrNoRows { 453 | return 0, nil 454 | } 455 | return 0, err 456 | } 457 | return vote, nil 458 | } 459 | 460 | // SetVote records a user vote on the specified macro. 461 | // If vote < 0, a downvote is recorded; if vote > 0 an upvote is recorded. 462 | // If vote == 0 the user's vote is removed. 463 | // Each user can vote at most once for a given macro. 464 | func (db *DB) SetVote(userID tailcfg.UserID, macroID, vote int) (*tmemes.Macro, error) { 465 | db.mu.Lock() 466 | defer db.mu.Unlock() 467 | m, ok := db.macros[macroID] 468 | if !ok { 469 | return nil, fmt.Errorf("macro %d not found", macroID) 470 | } 471 | tx, err := db.sqldb.Begin() 472 | if err != nil { 473 | return nil, err 474 | } 475 | defer tx.Rollback() 476 | if vote == 0 { 477 | _, err := tx.Exec(`DELETE FROM Votes WHERE user_id = ? AND macro_id = ?`, 478 | userID, macroID) 479 | if err != nil { 480 | return nil, err 481 | } 482 | if err := tx.Commit(); err != nil { 483 | return nil, err 484 | } 485 | return m, db.fillMacroVotesLocked(m) 486 | } 487 | 488 | // Pin votes to the allowed values, +1 for up, -1 for down. 489 | flag := 1 490 | if vote < 0 { 491 | flag = -1 492 | } 493 | _, err = tx.Exec(`INSERT OR REPLACE INTO Votes (user_id, macro_id, vote) VALUES (?, ?, ?)`, 494 | userID, macroID, flag) 495 | if err != nil { 496 | return nil, err 497 | } else if err := tx.Commit(); err != nil { 498 | return nil, err 499 | } 500 | if err := db.fillMacroVotesLocked(m); err != nil { 501 | return nil, err 502 | } 503 | return m, nil 504 | } 505 | 506 | // UserMacroVote reports the vote status of the given user for a single macro. 507 | // The result is -1 for a downvote, 1 for an upvote, 0 for no vote. 508 | func (db *DB) UserMacroVote(userID tailcfg.UserID, macroID int) (int, error) { 509 | db.mu.Lock() 510 | defer db.mu.Unlock() 511 | if _, ok := db.macros[macroID]; !ok { 512 | return 0, fmt.Errorf("macro %d not found", macroID) 513 | } 514 | var vote int 515 | if err := db.sqldb.QueryRow(`SELECT vote FROM Votes WHERE user_id = ? AND macro_id = ?`, 516 | userID, macroID).Scan(&vote); err != nil && !errors.Is(err, sql.ErrNoRows) { 517 | return 0, err 518 | } 519 | return vote, nil 520 | } 521 | 522 | // UserVotes all the votes for the given user, as a map from macroID to vote. 523 | // The votes are -1 for a downvote, 1 for an upvote. Macros on which the user 524 | // has not voted are not included. 525 | func (db *DB) UserVotes(userID tailcfg.UserID) (map[int]int, error) { 526 | db.mu.Lock() 527 | defer db.mu.Unlock() 528 | tx, err := db.sqldb.Begin() 529 | if err != nil { 530 | return nil, err 531 | } 532 | defer tx.Rollback() 533 | rows, err := tx.Query(`SELECT macro_id, vote FROM Votes WHERE user_id = ?`, userID) 534 | if err != nil { 535 | return nil, err 536 | } 537 | out := make(map[int]int) 538 | for rows.Next() { 539 | var macroID, vote int 540 | if err := rows.Scan(¯oID, &vote); err != nil { 541 | return nil, err 542 | } 543 | out[macroID] = vote 544 | } 545 | return out, rows.Err() 546 | } 547 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Package tmemes defines a meme generator, putting the meme in TS. 5 | // 6 | // This package defines shared data types used throughout the service. 7 | package tmemes 8 | 9 | import ( 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "net/url" 14 | "strings" 15 | "time" 16 | 17 | "tailscale.com/tailcfg" 18 | ) 19 | 20 | // A Template defines a base template for an image macro. 21 | type Template struct { 22 | ID int `json:"id"` // assigned by the server 23 | Path string `json:"path"` // path of image file 24 | Width int `json:"width"` // image width 25 | Height int `json:"height"` // image height 26 | Name string `json:"name"` // descriptive label 27 | Creator tailcfg.UserID `json:"creator"` 28 | CreatedAt time.Time `json:"createdAt"` 29 | Areas []Area `json:"areas,omitempty"` // optional predefined areas 30 | Hidden bool `json:"hidden,omitempty"` 31 | 32 | // If a template is hidden, macros based on it are still usable, but the 33 | // service won't list it as available and won't let you create new macros 34 | // from it. This way we can "delete" a template without screwing up the 35 | // previous macros that used it. 36 | // 37 | // To truly obliterate a template, delete the macros that reference it. 38 | } 39 | 40 | // A Macro combines a Template with some text. Macros can be cached by their 41 | // ID, or re-rendered on-demand. 42 | type Macro struct { 43 | ID int `json:"id"` 44 | TemplateID int `json:"templateID"` 45 | Creator tailcfg.UserID `json:"creator,omitempty"` // -1 for anon 46 | CreatedAt time.Time `json:"createdAt"` 47 | TextOverlay []TextLine `json:"textOverlay"` 48 | ContextLink []ContextLink `json:"contextLink,omitempty"` 49 | 50 | Upvotes int `json:"upvotes,omitempty"` 51 | Downvotes int `json:"downvotes,omitempty"` 52 | } 53 | 54 | // MaxContextLinks is the maximum number of context links permitted on a macro. 55 | const MaxContextLinks = 3 56 | 57 | // ValidForCreate reports whether m is valid for the creation of a new macro. 58 | func (m *Macro) ValidForCreate() error { 59 | switch { 60 | case m.ID != 0: 61 | return errors.New("macro ID must be zero") 62 | case m.TemplateID <= 0: 63 | return errors.New("macro must have a template ID") 64 | case len(m.TextOverlay) == 0: 65 | return errors.New("macro must have an overlay") 66 | case m.Upvotes != 0 || m.Downvotes != 0: 67 | return errors.New("macro must not contain votes") 68 | case m.Creator > 0: 69 | return errors.New("invalid macro creator") 70 | case len(m.ContextLink) > MaxContextLinks: 71 | return errors.New("too many context links") 72 | } 73 | 74 | // Check and sanitize context links: Remove leading and trailing whitespace, 75 | // verify that the link is a syntactically valid "http" or "https" URL, and 76 | // then render it properly escaped. 77 | for i, cl := range m.ContextLink { 78 | u, err := url.Parse(strings.TrimSpace(cl.URL)) 79 | if err != nil { 80 | return fmt.Errorf("invalid context URL: %w", err) 81 | } else if u.Scheme != "http" && u.Scheme != "https" { 82 | return fmt.Errorf("invalid context link scheme %q", u.Scheme) 83 | } 84 | m.ContextLink[i].URL = u.String() 85 | } 86 | for _, tl := range m.TextOverlay { 87 | if err := tl.ValidForCreate(); err != nil { 88 | return err 89 | } 90 | } 91 | return nil 92 | } 93 | 94 | // Areas is a wrapper for a slice of Area values that optionally decodes from 95 | // JSON as either a single Area object or an array of Area values. 96 | // A length-1 Areas encodes as a plain object. 97 | type Areas []Area 98 | 99 | func (a Areas) MarshalJSON() ([]byte, error) { 100 | if len(a) == 1 { 101 | return json.Marshal(a[0]) 102 | } 103 | msgs := make([]json.RawMessage, len(a)) 104 | for i, v := range a { 105 | data, err := json.Marshal(v) 106 | if err != nil { 107 | return nil, fmt.Errorf("area %d: %w", i, err) 108 | } 109 | msgs[i] = data 110 | } 111 | return json.Marshal(msgs) 112 | } 113 | 114 | func (a *Areas) UnmarshalJSON(data []byte) error { 115 | if len(data) == 0 { 116 | return errors.New("empty input") 117 | } 118 | switch data[0] { 119 | case '[': 120 | return json.Unmarshal(data, (*[]Area)(a)) 121 | case '{': 122 | var single Area 123 | if err := json.Unmarshal(data, &single); err != nil { 124 | return err 125 | } 126 | *a = Areas{single} 127 | return nil 128 | default: 129 | return errors.New("invalid input") 130 | } 131 | } 132 | 133 | // An Area defines a region of an image where text is placed. Each area has an 134 | // anchor point, relative to the top-left of the image, and a target width and 135 | // height as fractions of the image size. Text drawn within an area should be 136 | // scaled so that the resulting box does not exceed those dimensions. 137 | type Area struct { 138 | X float64 `json:"x"` // x offset of anchor as a fraction 0..1 of width 139 | Y float64 `json:"y"` // y offset of anchor as a fraciton 0..1 of height 140 | Width float64 `json:"width,omitempty"` // width of text box as a fraction of image width 141 | 142 | // If true, adjust the effective coordinates for each frame by interpolating 143 | // the distance between the given X, Y and the X, Y of the next area in 144 | // sequence, when rendering multiple frames. 145 | // 146 | // This is ignored when rendering on a single-frame template. 147 | Tween bool `json:"tween,omitempty"` 148 | 149 | // N.B. If width == 0 or height == 0, the full dimension can be used. 150 | } 151 | 152 | // ValidForCreate reports whether a is valid for creation of a new macro. 153 | func (a Area) ValidForCreate() error { 154 | if a.X < 0 || a.X > 1 { 155 | return fmt.Errorf("x out of range %g", a.X) 156 | } 157 | if a.Y < 0 || a.Y > 1 { 158 | return fmt.Errorf("y out of range %g", a.Y) 159 | } 160 | if a.Width < 0 || a.Width > 1 { 161 | return fmt.Errorf("width out of range %g", a.Width) 162 | } 163 | return nil 164 | } 165 | 166 | // A TextLine is a single line of text with an optional alignment. 167 | type TextLine struct { 168 | Text string `json:"text"` 169 | Color Color `json:"color"` 170 | StrokeColor Color `json:"strokeColor"` 171 | 172 | // The location(s) where the text should be drawn, it must be non-empty. 173 | // For a single-frame image, only the first entry is used. 174 | // 175 | // For a multiple-frame image, the locations are applied cyclically to the 176 | // frames of the image. Each area occupies an equal fraction of the frames, 177 | // for example if there are 8 frames and 2 areas, each area is mapped to 4 178 | // frames (Field[0] to frames 0, 1, 2, 3; Field[1] to frames 4, 5, 6, 7). 179 | Field Areas `json:"field"` 180 | 181 | // The first point in a multi-frame image where this text should be visible, 182 | // as a fraction (0..1) of the total frames of the image. For example, in an 183 | // image with 16 frames, 0.25 represents 4 frames. 184 | // 185 | // if > 0, do not show the text line before this frame fraction. 186 | // If = 0, show the text beginning at the first frame. 187 | Start float64 `json:"start,omitempty"` // 0..1 188 | 189 | // The last point in a multi-frame image where this text should be visible, 190 | // as a fraction (0..1) of the total frames of the image. For example, in an 191 | // image with 10 frames, 0.5 represents 5 frames. 192 | // 193 | // If > Start, hide the text after this frame fraction. 194 | // Otherwise, do not hide the text after the start index. 195 | End float64 `json:"end,omitempty"` // 0..1 196 | 197 | // TODO: size, typeface, linebreaks in long runs 198 | } 199 | 200 | // ValidForCreate reports whether t is valid for creation of a macro. 201 | func (t TextLine) ValidForCreate() error { 202 | switch { 203 | case t.Text == "": 204 | return errors.New("text is empty") 205 | case len(t.Field) == 0: 206 | return errors.New("no fields specified") 207 | case t.Start < 0 || t.Start > 1: 208 | return fmt.Errorf("start out of range %g", t.Start) 209 | case t.End < 0 || t.End > 1: 210 | return fmt.Errorf("end out of range %g", t.End) 211 | } 212 | for _, f := range t.Field { 213 | if err := f.ValidForCreate(); err != nil { 214 | return err 215 | } 216 | } 217 | return nil 218 | } 219 | 220 | // ContextLink is a link to explain the context of a macro. 221 | type ContextLink struct { 222 | URL string `json:"url"` // required 223 | Text string `json:"text,omitempty"` // optional 224 | } 225 | 226 | // MustColor constructs a color from a known color name or hex specification 227 | // #xxx or #xxxxxx. It panics if s does not correspond to a valid color. 228 | func MustColor(s string) Color { 229 | var c Color 230 | if err := c.UnmarshalText([]byte(s)); err != nil { 231 | panic("invalid color: " + err.Error()) 232 | } 233 | return c 234 | } 235 | 236 | // A Color represents an RGB color encoded as hex. It supports encoding in JSON 237 | // as a string, allowing "#xxxxxx" or "#xxx" format (the "#" is optional). 238 | type Color [3]float64 239 | 240 | func (c Color) R() float64 { return c[0] } 241 | func (c Color) G() float64 { return c[1] } 242 | func (c Color) B() float64 { return c[2] } 243 | 244 | func (c Color) MarshalText() ([]byte, error) { 245 | s := fmt.Sprintf("#%02x%02x%02x", 246 | byte(c[0]*255), byte(c[1]*255), byte(c[2]*255)) 247 | 248 | // Check for a name mapping. 249 | if n, ok := c2n[s]; ok { 250 | s = n 251 | } 252 | return []byte(s), nil 253 | } 254 | 255 | func (c *Color) UnmarshalText(data []byte) error { 256 | // As a special case, treat an empty string as "white". 257 | if len(data) == 0 { 258 | c[0], c[1], c[2] = 1, 1, 1 259 | return nil 260 | } 261 | p := string(data) 262 | 263 | // Check for a name mapping. 264 | if c, ok := n2c[p]; ok { 265 | p = c 266 | } 267 | 268 | p = strings.TrimPrefix(p, "#") 269 | var r, g, b byte 270 | var err error 271 | switch len(p) { 272 | case 3: 273 | _, err = fmt.Sscanf(p, "%1x%1x%1x", &r, &g, &b) 274 | r |= r << 4 275 | g |= g << 4 276 | b |= b << 4 277 | case 6: 278 | _, err = fmt.Sscanf(p, "%2x%2x%2x", &r, &g, &b) 279 | default: 280 | return errors.New("invalid hex color") 281 | } 282 | if err != nil { 283 | return err 284 | } 285 | c[0], c[1], c[2] = float64(r)/255, float64(g)/255, float64(b)/255 286 | return nil 287 | } 288 | 289 | // n2c maps color names to their equivalent hex strings in standard web RGB 290 | // format (#xxxxxx). Names should be normalized to lower-case. If multiple 291 | // names map to the same hex, the reverse mapping will not be deterministic. 292 | var n2c = map[string]string{ 293 | "white": "#ffffff", 294 | "silver": "#c0c0c0", 295 | "gray": "#808080", 296 | "black": "#000000", 297 | "red": "#ff0000", 298 | "maroon": "#800000", 299 | "yellow": "#ffff00", 300 | "olive": "#808000", 301 | "lime": "#00ff00", 302 | "green": "#008000", 303 | "aqua": "#00ffff", 304 | "teal": "#008080", 305 | "blue": "#0000ff", 306 | "navy": "#000080", 307 | "fuchsia": "#ff00ff", 308 | "purple": "#800080", 309 | } 310 | 311 | var c2n = make(map[string]string) 312 | 313 | func init() { 314 | // Set up the reverse mapping from color code to name. 315 | for n, c := range n2c { 316 | _, ok := c2n[c] 317 | if !ok { 318 | c2n[c] = n 319 | } 320 | } 321 | } 322 | 323 | // ContextRequest is the payload for the /api/context handler. 324 | type ContextRequest struct { 325 | // Action specifies what to do with the context links on the macro. 326 | // The options are "add", "clear", and "remove". 327 | Action string `json:"action"` 328 | 329 | // Link specifies the link to add or remove. At least the URL of the link 330 | // must be specified unless Action is "clear". 331 | Link ContextLink `json:"link"` 332 | } 333 | -------------------------------------------------------------------------------- /types_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package tmemes 5 | 6 | import ( 7 | "encoding/json" 8 | "testing" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | ) 12 | 13 | func TestColorNames(t *testing.T) { 14 | for n, c := range n2c { 15 | var in1 Color 16 | if err := in1.UnmarshalText([]byte(n)); err != nil { 17 | t.Errorf("Unmarshal %q: unexpected error: %v", n, err) 18 | continue 19 | } 20 | var in2 Color 21 | if err := in2.UnmarshalText([]byte(c)); err != nil { 22 | t.Errorf("Unmarshal %q: unexpected error: %v", c, err) 23 | continue 24 | } 25 | 26 | if in1 != in2 { 27 | t.Errorf("Colors differ: %v ≠ %v", in1, in2) 28 | } 29 | 30 | out, err := in1.MarshalText() 31 | if err != nil { 32 | t.Errorf("Marshal %v: unexpected error: %v", in1, err) 33 | continue 34 | } 35 | if got := string(out); got != n { 36 | t.Errorf("Marshal %v: got %q, want %q", in1, got, n) 37 | } 38 | } 39 | } 40 | 41 | func TestAreas(t *testing.T) { 42 | tests := []struct { 43 | input string 44 | want Areas 45 | output string 46 | }{ 47 | {"[]", Areas{}, "[]"}, 48 | {"{}", Areas{{}}, `{"x":0,"y":0}`}, 49 | {`{"x":25, "width": -99}`, Areas{{X: 25, Width: -99}}, `{"x":25,"y":0,"width":-99}`}, 50 | {`[ 51 | {"x": 1, "width": 3, "y": 2, "foo":true}, 52 | { "y": 5, "x":4, "height": 6 } 53 | ]`, Areas{ 54 | {X: 1, Y: 2, Width: 3}, 55 | {X: 4, Y: 5}, 56 | }, `[{"x":1,"y":2,"width":3},{"x":4,"y":5}]`}, 57 | } 58 | for _, tc := range tests { 59 | var val Areas 60 | if err := json.Unmarshal([]byte(tc.input), &val); err != nil { 61 | t.Fatalf("Unmarshal %q: %v", tc.input, err) 62 | } 63 | if diff := cmp.Diff(tc.want, val); diff != "" { 64 | t.Errorf("Incorrect value (-want, +got):\n%s", diff) 65 | } 66 | bits, err := json.Marshal(val) 67 | if err != nil { 68 | t.Fatalf("Marshal %v: %v", val, err) 69 | } 70 | if got := string(bits); got != tc.output { 71 | t.Errorf("Marshal: got %#q, want %#q", got, tc.output) 72 | } 73 | } 74 | } 75 | --------------------------------------------------------------------------------