Hi Alice,
42 | 43 |Frank has posted a new review of The Room:
44 | 45 |https://sj.example.com/movies/1#review25
46 | 47 |-ScreenJournal Bot
`, 48 | }, 49 | expected: normalizeLineEndings(`From: "ScreenJournal Bot"Hi Alice,
71 | 72 |Frank has posted a new review of The Room:
73 | 74 |https://sj.example.= 75 | com/movies/1#review25
76 | 77 |-ScreenJournal Bot
78 | --dummy-boundary-for-testing-- 79 | `), 80 | }, 81 | } 82 | 83 | for _, tt := range tests { 84 | actual, err := convert.FromEmail(tt.input) 85 | if err != nil { 86 | t.Fatalf("failed to generate email: %v", err) 87 | } 88 | 89 | if diff := diff.Diff(actual, tt.expected); diff != "" { 90 | t.Fatalf("unexpected smtp message for email: %s\n%s", tt.input.Subject, diff) 91 | } 92 | } 93 | } 94 | 95 | func normalizeLineEndings(s string) string { 96 | return strings.ReplaceAll(s, "\n", "\r\n") 97 | } 98 | -------------------------------------------------------------------------------- /email/smtp/smtp.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/smtp" 9 | 10 | "github.com/mtlynch/screenjournal/v2/email" 11 | "github.com/mtlynch/screenjournal/v2/email/smtp/convert" 12 | ) 13 | 14 | type ( 15 | config struct { 16 | Host string 17 | Port int 18 | Username string 19 | Password string 20 | } 21 | 22 | sender struct { 23 | config config 24 | } 25 | ) 26 | 27 | func New(host string, port int, username, password string) (email.Sender, error) { 28 | if host == "" { 29 | return sender{}, errors.New("invalid SMTP hostname") 30 | } 31 | if port == 0 { 32 | return sender{}, errors.New("invalid SMTP port") 33 | } 34 | if username == "" || password == "" { 35 | return sender{}, errors.New("invalid SMTP credentials") 36 | } 37 | return sender{ 38 | config: config{ 39 | Host: host, 40 | Port: port, 41 | Username: username, 42 | Password: password, 43 | }, 44 | }, nil 45 | } 46 | 47 | func (s sender) Send(msg email.Message) error { 48 | log.Printf("sending email from %s to %s (%s)", msg.From.String(), msg.To[0].String(), msg.Subject) 49 | 50 | serverName := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) 51 | c, err := smtp.Dial(serverName) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | err = c.StartTLS(&tls.Config{ 57 | ServerName: s.config.Host, 58 | }) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | defer func() { 64 | if err := c.Quit(); err != nil { 65 | log.Printf("failed to close TLS connection: %v", err) 66 | } 67 | }() 68 | 69 | // Plain auth is okay since we're wrapping it in TLS. 70 | if err := c.Auth(smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)); err != nil { 71 | return err 72 | } 73 | 74 | if err := c.Mail(msg.From.Address); err != nil { 75 | return err 76 | } 77 | 78 | rcpts := msg.To 79 | // TODO: Add cc and bcc recepients 80 | for _, rcpt := range rcpts { 81 | if err := c.Rcpt(rcpt.Address); err != nil { 82 | return err 83 | } 84 | } 85 | 86 | w, err := c.Data() 87 | if err != nil { 88 | return err 89 | } 90 | 91 | rawMsg, err := convert.FromEmail(msg) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | _, err = w.Write([]byte(rawMsg)) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Dev environment for ScreenJournal"; 3 | 4 | inputs = { 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | 7 | # 1.23.3 release 8 | go-nixpkgs.url = "github:NixOS/nixpkgs/566e53c2ad750c84f6d31f9ccb9d00f823165550"; 9 | 10 | # 3.44.2 release 11 | sqlite-nixpkgs.url = "github:NixOS/nixpkgs/5ad9903c16126a7d949101687af0aa589b1d7d3d"; 12 | 13 | # 20.6.1 release 14 | nodejs-nixpkgs.url = "github:NixOS/nixpkgs/78058d810644f5ed276804ce7ea9e82d92bee293"; 15 | 16 | # 0.10.0 release 17 | shellcheck-nixpkgs.url = "github:NixOS/nixpkgs/4ae2e647537bcdbb82265469442713d066675275"; 18 | 19 | # 3.3.0 release 20 | sqlfluff-nixpkgs.url = "github:NixOS/nixpkgs/bf689c40d035239a489de5997a4da5352434632e"; 21 | 22 | # 1.40.0 23 | playwright-nixpkgs.url = "github:NixOS/nixpkgs/f5c27c6136db4d76c30e533c20517df6864c46ee"; 24 | 25 | # 0.1.131 release 26 | flyctl-nixpkgs.url = "github:NixOS/nixpkgs/09dc04054ba2ff1f861357d0e7e76d021b273cd7"; 27 | 28 | # 0.3.13 release 29 | litestream-nixpkgs.url = "github:NixOS/nixpkgs/a343533bccc62400e8a9560423486a3b6c11a23b"; 30 | }; 31 | 32 | outputs = { 33 | self, 34 | flake-utils, 35 | go-nixpkgs, 36 | sqlite-nixpkgs, 37 | nodejs-nixpkgs, 38 | shellcheck-nixpkgs, 39 | sqlfluff-nixpkgs, 40 | playwright-nixpkgs, 41 | flyctl-nixpkgs, 42 | litestream-nixpkgs, 43 | } @ inputs: 44 | flake-utils.lib.eachDefaultSystem (system: let 45 | gopkg = go-nixpkgs.legacyPackages.${system}; 46 | go = gopkg.go_1_23; 47 | sqlite = sqlite-nixpkgs.legacyPackages.${system}.sqlite; 48 | nodejs = nodejs-nixpkgs.legacyPackages.${system}.nodejs_20; 49 | shellcheck = shellcheck-nixpkgs.legacyPackages.${system}.shellcheck; 50 | sqlfluff = sqlfluff-nixpkgs.legacyPackages.${system}.sqlfluff; 51 | playwright = playwright-nixpkgs.legacyPackages.${system}.playwright-driver.browsers; 52 | flyctl = flyctl-nixpkgs.legacyPackages.${system}.flyctl; 53 | litestream = litestream-nixpkgs.legacyPackages.${system}.litestream; 54 | in { 55 | devShells.default = gopkg.mkShell { 56 | packages = [ 57 | gopkg.gotools 58 | gopkg.gopls 59 | gopkg.go-outline 60 | gopkg.gopkgs 61 | gopkg.gocode-gomod 62 | gopkg.godef 63 | gopkg.golint 64 | go 65 | sqlite 66 | nodejs 67 | shellcheck 68 | sqlfluff 69 | playwright 70 | flyctl 71 | litestream 72 | ]; 73 | 74 | shellHook = '' 75 | export GOROOT="${go}/share/go" 76 | 77 | export PLAYWRIGHT_BROWSERS_PATH=${playwright} 78 | export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true 79 | 80 | echo "shellcheck" "$(shellcheck --version | grep '^version:')" 81 | sqlfluff --version 82 | fly version | cut -d ' ' -f 1-3 83 | echo "litestream" "$(litestream version)" 84 | echo "node" "$(node --version)" 85 | echo "npm" "$(npm --version)" 86 | echo "sqlite" "$(sqlite3 --version | cut -d ' ' -f 1-2)" 87 | go version 88 | ''; 89 | }; 90 | 91 | formatter = gopkg.alejandra; 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "screenjournal" 2 | 3 | kill_signal = "SIGINT" 4 | kill_timeout = 5 5 | processes = [] 6 | 7 | [build.args] 8 | TZ = "America/New_York" 9 | 10 | [env] 11 | PORT = "8080" 12 | SJ_BEHIND_PROXY = "yes" 13 | SJ_SMTP_HOST = "smtp.postmarkapp.com" 14 | SJ_SMTP_PORT = "2525" 15 | SJ_BASE_URL = "https://thescreenjournal.com" 16 | LITESTREAM_BUCKET="screenjournal-litestream" 17 | LITESTREAM_ENDPOINT="s3.us-west-002.backblazeb2.com" 18 | 19 | [experimental] 20 | allowed_public_ports = [] 21 | auto_rollback = true 22 | 23 | [[services]] 24 | http_checks = [] 25 | internal_port = 8080 26 | processes = ["app"] 27 | protocol = "tcp" 28 | script_checks = [] 29 | 30 | [services.concurrency] 31 | hard_limit = 25 32 | soft_limit = 20 33 | type = "connections" 34 | 35 | [[services.ports]] 36 | handlers = ["http"] 37 | port = 80 38 | 39 | [[services.ports]] 40 | handlers = ["tls", "http"] 41 | port = 443 42 | 43 | [[services.tcp_checks]] 44 | grace_period = "1s" 45 | interval = "15s" 46 | restart_limit = 0 47 | timeout = "2s" 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mtlynch/screenjournal/v2 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/go-test/deep v1.0.8 7 | github.com/gomarkdown/markdown v0.0.0-20240723152757-afa4a469d4f9 8 | github.com/gorilla/mux v1.8.0 9 | github.com/kylelemons/godebug v1.1.0 10 | github.com/microcosm-cc/bluemonday v1.0.27 11 | github.com/mtlynch/gorilla-handlers v1.5.2 12 | github.com/mtlynch/simpleauth/v2 v2.0.0-20241108014613-2f32145d692d 13 | github.com/ncruces/go-sqlite3 v0.22.0 14 | github.com/ryanbradynd05/go-tmdb v0.0.0-20220721194547-2ab6191c6273 15 | ) 16 | 17 | require ( 18 | github.com/aymerick/douceur v0.2.0 // indirect 19 | github.com/felixge/httpsnoop v1.0.1 // indirect 20 | github.com/gorilla/css v1.0.1 // indirect 21 | github.com/kylelemons/go-gypsy v1.0.0 // indirect 22 | github.com/mtlynch/jeff v0.2.4 // indirect 23 | github.com/ncruces/julianday v1.0.0 // indirect 24 | github.com/philhofer/fwd v1.1.1 // indirect 25 | github.com/tetratelabs/wazero v1.8.2 // indirect 26 | github.com/tinylib/msgp v1.1.6 // indirect 27 | golang.org/x/crypto v0.32.0 // indirect 28 | golang.org/x/net v0.26.0 // indirect 29 | golang.org/x/sys v0.29.0 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /handlers/csp.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/mtlynch/screenjournal/v2/random" 11 | ) 12 | 13 | var contextKeyCSPNonce = &contextKey{"csp-nonce"} 14 | 15 | func enforceContentSecurityPolicy(next http.Handler) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | nonce := base64.StdEncoding.EncodeToString(random.Bytes(16)) 18 | 19 | type cspDirective struct { 20 | name string 21 | values []string 22 | } 23 | directives := []cspDirective{ 24 | { 25 | name: "default-src", 26 | values: []string{ 27 | "'self'", 28 | }, 29 | }, 30 | { 31 | name: "script-src-elem", 32 | values: []string{ 33 | "'self'", 34 | "'nonce-" + nonce + "'", 35 | }, 36 | }, 37 | { 38 | name: "style-src-elem", 39 | values: []string{ 40 | "'self'", 41 | "'nonce-" + nonce + "'", 42 | // for htmx 2.0.4 inline style 43 | "'sha256-bsV5JivYxvGywDAZ22EZJKBFip65Ng9xoJVLbBg7bdo='", 44 | }, 45 | }, 46 | { 47 | name: "img-src", 48 | values: []string{ 49 | "'self'", 50 | "data:", 51 | "image.tmdb.org", 52 | }, 53 | }, 54 | { 55 | name: "media-src", 56 | values: []string{ 57 | "'self'", 58 | "data:", 59 | }, 60 | }, 61 | } 62 | policyParts := []string{} 63 | for _, directive := range directives { 64 | policyParts = append(policyParts, fmt.Sprintf("%s %s", directive.name, strings.Join(directive.values, " "))) 65 | } 66 | policy := strings.Join(policyParts, "; ") + ";" 67 | 68 | w.Header().Set("Content-Security-Policy", policy) 69 | 70 | ctx := context.WithValue(r.Context(), contextKeyCSPNonce, nonce) 71 | next.ServeHTTP(w, r.WithContext(ctx)) 72 | }) 73 | } 74 | 75 | func cspNonce(ctx context.Context) string { 76 | key, ok := ctx.Value(contextKeyCSPNonce).(string) 77 | if !ok { 78 | panic("CSP nonce is missing from request context") 79 | } 80 | return key 81 | } 82 | -------------------------------------------------------------------------------- /handlers/db_prod.go: -------------------------------------------------------------------------------- 1 | //go:build !dev 2 | 3 | package handlers 4 | 5 | import ( 6 | "net/http" 7 | ) 8 | 9 | func (s *Server) addDevRoutes() { 10 | // no-op 11 | } 12 | 13 | func (s Server) getDB(*http.Request) Store { 14 | return s.store 15 | } 16 | 17 | func (s Server) getAuthenticator(_ *http.Request) Authenticator { 18 | return s.authenticator 19 | } 20 | -------------------------------------------------------------------------------- /handlers/invites.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "html/template" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/mtlynch/screenjournal/v2/handlers/parse" 9 | "github.com/mtlynch/screenjournal/v2/screenjournal" 10 | ) 11 | 12 | type invitesPostRequest struct { 13 | Invitee screenjournal.Invitee 14 | } 15 | 16 | func (s Server) invitesPost() http.HandlerFunc { 17 | t := template.Must(template.ParseFS(templatesFS, "templates/fragments/invite-row.html")) 18 | return func(w http.ResponseWriter, r *http.Request) { 19 | req, err := parseInvitesPostRequest(r) 20 | if err != nil { 21 | log.Printf("failed to parse invites POST: %v", err) 22 | http.Error(w, "Invalid invite creation", http.StatusBadRequest) 23 | return 24 | } 25 | 26 | invitation := screenjournal.SignupInvitation{ 27 | Invitee: req.Invitee, 28 | InviteCode: screenjournal.NewInviteCode(), 29 | } 30 | if err := s.getDB(r).InsertSignupInvitation(invitation); err != nil { 31 | log.Printf("failed to add new signup invite %+v: %v", invitation, err) 32 | http.Error(w, "Failed to store new signup invite", http.StatusInternalServerError) 33 | return 34 | } 35 | 36 | if err := t.Execute(w, struct { 37 | Invitee screenjournal.Invitee 38 | InviteCode screenjournal.InviteCode 39 | }{ 40 | Invitee: invitation.Invitee, 41 | InviteCode: invitation.InviteCode, 42 | }); err != nil { 43 | http.Error(w, "Failed to render template", http.StatusInternalServerError) 44 | log.Printf("failed to render invite row template: %v", err) 45 | return 46 | } 47 | } 48 | 49 | } 50 | 51 | func parseInvitesPostRequest(r *http.Request) (invitesPostRequest, error) { 52 | if err := r.ParseForm(); err != nil { 53 | return invitesPostRequest{}, err 54 | } 55 | 56 | invitee, err := parse.Invitee(r.PostFormValue("invitee")) 57 | if err != nil { 58 | return invitesPostRequest{}, err 59 | } 60 | 61 | return invitesPostRequest{ 62 | Invitee: invitee, 63 | }, nil 64 | } 65 | -------------------------------------------------------------------------------- /handlers/maintenance.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/mtlynch/screenjournal/v2/screenjournal" 9 | ) 10 | 11 | func (s Server) repopulateMoviesGet() http.HandlerFunc { 12 | return func(w http.ResponseWriter, r *http.Request) { 13 | log.Printf("repopulating movies metadata") 14 | 15 | rr, err := s.getDB(r).ReadReviews() 16 | if err != nil { 17 | log.Printf("failed to read reviews: %v", err) 18 | http.Error(w, fmt.Sprintf("failed to read reviews: %v", err), http.StatusInternalServerError) 19 | return 20 | } 21 | 22 | log.Printf("read movie data from %d reviews", len(rr)) 23 | 24 | // We could parallelize this, but it's a maintenance function we use rarely, 25 | // so we're keeping it simple for now. 26 | for _, rev := range rr { 27 | if !rev.MediaType().Equal(screenjournal.MediaTypeMovie) { 28 | continue 29 | } 30 | movie, err := s.metadataFinder.GetMovie(rev.Movie.TmdbID) 31 | if err != nil { 32 | log.Printf("failed to get metadata for %s (tmdb ID=%v): %v", rev.Movie.Title, rev.Movie.TmdbID, err) 33 | http.Error(w, fmt.Sprintf("Failed to retrieve metadata: %v", err), http.StatusInternalServerError) 34 | return 35 | } 36 | 37 | // Update movie with latest metadata. 38 | movie.ID = rev.Movie.ID 39 | 40 | if err := s.getDB(r).UpdateMovie(movie); err != nil { 41 | log.Printf("failed to update metadata for %s (tmdb ID=%v): %v", rev.Movie.Title, rev.Movie.TmdbID, err) 42 | http.Error(w, fmt.Sprintf("Failed to save updated movie metadata: %v", err), http.StatusInternalServerError) 43 | return 44 | } 45 | } 46 | if _, err := fmt.Fprint(w, "Finished updating movies"); err != nil { 47 | log.Printf("failed to write output: %v", err) 48 | } 49 | } 50 | } 51 | 52 | func (s Server) repopulateTvShowsGet() http.HandlerFunc { 53 | return func(w http.ResponseWriter, r *http.Request) { 54 | log.Printf("repopulating movies metadata") 55 | 56 | rr, err := s.getDB(r).ReadReviews() 57 | if err != nil { 58 | log.Printf("failed to read reviews: %v", err) 59 | http.Error(w, fmt.Sprintf("failed to read reviews: %v", err), http.StatusInternalServerError) 60 | return 61 | } 62 | 63 | log.Printf("read data from %d reviews", len(rr)) 64 | 65 | // We could parallelize this, but it's a maintenance function we use rarely, 66 | // so we're keeping it simple for now. 67 | for _, rev := range rr { 68 | if !rev.MediaType().Equal(screenjournal.MediaTypeTvShow) { 69 | continue 70 | } 71 | 72 | tvShow, err := s.metadataFinder.GetTvShow(rev.TvShow.TmdbID) 73 | if err != nil { 74 | log.Printf("failed to get metadata for %s (tmdb ID=%v): %v", rev.TvShow.Title, rev.TvShow.TmdbID, err) 75 | http.Error(w, fmt.Sprintf("Failed to retrieve metadata: %v", err), http.StatusInternalServerError) 76 | return 77 | } 78 | 79 | // Update movie with latest metadata. 80 | tvShow.ID = rev.TvShow.ID 81 | if err := s.getDB(r).UpdateTvShow(tvShow); err != nil { 82 | log.Printf("failed to update metadata for %s (tmdb ID=%v): %v", rev.TvShow.Title, rev.TvShow.TmdbID, err) 83 | http.Error(w, fmt.Sprintf("Failed to save updated TV show metadata: %v", err), http.StatusInternalServerError) 84 | return 85 | } 86 | } 87 | if _, err := fmt.Fprint(w, "Finished updating TV shows"); err != nil { 88 | log.Printf("failed to write output: %v", err) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /handlers/parse/checkbox.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | func CheckboxToBool(raw string) bool { 4 | return raw == "on" 5 | } 6 | -------------------------------------------------------------------------------- /handlers/parse/comment.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/mtlynch/screenjournal/v2/screenjournal" 10 | ) 11 | 12 | const commentMaxLength = 9000 13 | 14 | var ( 15 | ErrInvalidCommentID = errors.New("invalid comment ID") 16 | ErrInvalidComment = errors.New("invalid comment") 17 | ) 18 | 19 | func CommentID(raw string) (screenjournal.CommentID, error) { 20 | id, err := strconv.ParseUint(raw, 10, 64) 21 | if err != nil { 22 | log.Printf("failed to parse comment ID: %v", err) 23 | return screenjournal.CommentID(0), ErrInvalidCommentID 24 | } 25 | 26 | if id == 0 { 27 | return screenjournal.CommentID(0), ErrInvalidCommentID 28 | } 29 | 30 | return screenjournal.CommentID(id), nil 31 | } 32 | 33 | func CommentText(raw string) (screenjournal.CommentText, error) { 34 | if len(raw) > commentMaxLength { 35 | return screenjournal.CommentText(""), ErrInvalidComment 36 | } 37 | 38 | comment := strings.TrimSpace(raw) 39 | 40 | if isReservedWord(comment) { 41 | return screenjournal.CommentText(""), ErrInvalidComment 42 | } 43 | if len(comment) < 1 { 44 | return screenjournal.CommentText(""), ErrInvalidComment 45 | } 46 | 47 | if scriptTagPattern.FindString(comment) != "" { 48 | return screenjournal.CommentText(""), ErrInvalidComment 49 | } 50 | 51 | return screenjournal.CommentText(comment), nil 52 | } 53 | -------------------------------------------------------------------------------- /handlers/parse/comment_test.go: -------------------------------------------------------------------------------- 1 | package parse_test 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/mtlynch/screenjournal/v2/handlers/parse" 10 | "github.com/mtlynch/screenjournal/v2/screenjournal" 11 | ) 12 | 13 | func TestCommentID(t *testing.T) { 14 | for _, tt := range []struct { 15 | description string 16 | in string 17 | id screenjournal.CommentID 18 | err error 19 | }{ 20 | { 21 | "ID of 1 is valid", 22 | "1", 23 | screenjournal.CommentID(1), 24 | nil, 25 | }, 26 | { 27 | "ID of MaxUint64 is valid", 28 | fmt.Sprintf("%d", uint64(math.MaxUint64)), 29 | screenjournal.CommentID(math.MaxUint64), 30 | nil, 31 | }, 32 | { 33 | "ID of -1 is invalid", 34 | "-1", 35 | screenjournal.CommentID(0), 36 | parse.ErrInvalidCommentID, 37 | }, 38 | { 39 | "ID of 0 is invalid", 40 | "0", 41 | screenjournal.CommentID(0), 42 | parse.ErrInvalidCommentID, 43 | }, 44 | { 45 | "non-numeric ID is invalid", 46 | "banana", 47 | screenjournal.CommentID(0), 48 | parse.ErrInvalidCommentID, 49 | }, 50 | } { 51 | t.Run(fmt.Sprintf("%s [%s]", tt.description, tt.in), func(t *testing.T) { 52 | id, err := parse.CommentID(tt.in) 53 | if got, want := err, tt.err; got != want { 54 | t.Fatalf("err=%v, want=%v", got, want) 55 | } 56 | if got, want := id.UInt64(), tt.id.UInt64(); got != want { 57 | t.Errorf("id=%d, want=%d", got, want) 58 | } 59 | }) 60 | } 61 | } 62 | 63 | func TestCommentText(t *testing.T) { 64 | for _, tt := range []struct { 65 | description string 66 | in string 67 | comment screenjournal.CommentText 68 | err error 69 | }{ 70 | { 71 | "regular comment is valid", 72 | "I agree completely!", 73 | screenjournal.CommentText("I agree completely!"), 74 | nil, 75 | }, 76 | { 77 | "comment with leading spaces is valid", 78 | " I thought it was bad.", 79 | screenjournal.CommentText("I thought it was bad."), 80 | nil, 81 | }, 82 | { 83 | "comment with trailing spaces is valid", 84 | "I thought it was bad. ", 85 | screenjournal.CommentText("I thought it was bad."), 86 | nil, 87 | }, 88 | { 89 | "'undefined' as a comment is invalid", 90 | "undefined", 91 | screenjournal.CommentText(""), 92 | parse.ErrInvalidComment, 93 | }, 94 | { 95 | "'null' as a comment is invalid", 96 | "null", 97 | screenjournal.CommentText(""), 98 | parse.ErrInvalidComment, 99 | }, 100 | { 101 | "empty string is invalid", 102 | "", 103 | screenjournal.CommentText(""), 104 | parse.ErrInvalidComment, 105 | }, 106 | { 107 | "single character comment is valid", 108 | "a", 109 | screenjournal.CommentText("a"), 110 | nil, 111 | }, 112 | { 113 | "comment with more than 9000 characters is invalid", 114 | strings.Repeat("A", 9001), 115 | screenjournal.CommentText(""), 116 | parse.ErrInvalidComment, 117 | }, 118 | { 119 | "comment with tag is invalid", 126 | "Needed more ", 127 | screenjournal.CommentText(""), 128 | parse.ErrInvalidComment, 129 | }, 130 | { 131 | "comment with 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {{ template "navbar.html" . }} 42 | 43 | 44 |