├── .circleci └── config.yml ├── .clinerules ├── .env.example ├── .envrc ├── .eslintrc.js ├── .github └── workflows │ └── cla.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── announce ├── email │ ├── email.go │ ├── email_test.go │ └── templates │ │ ├── new-comment.tmpl.txt │ │ └── new-review.tmpl.txt └── quiet │ └── quiet.go ├── auth ├── auth.go ├── password.go └── store.go ├── cmd └── screenjournal │ ├── main.go │ ├── tls_dev.go │ └── tls_prod.go ├── dev-scripts ├── build-backend ├── check-bash ├── check-frontend ├── check-go-formatting ├── check-trailing-newline ├── check-trailing-whitespace ├── download-prod-db ├── enable-git-hooks ├── enable-multiarch-docker ├── git-hooks │ └── pre-commit ├── lint-sql ├── package-binaries ├── populate-db ├── reset-db ├── run-e2e-tests ├── run-go-tests ├── run-single-go-test ├── serve ├── serve-docker └── upload-prod-db ├── docker-entrypoint ├── docs ├── CLA-v1.md ├── advanced-installation.md └── assets │ └── screenjournal-demo.webp ├── e2e ├── auth.spec.ts ├── changePassword.spec.ts ├── helpers │ ├── db.js │ ├── global-setup.ts │ └── login.js ├── invite.spec.ts ├── navbar.spec.ts ├── notifications.spec.ts ├── reviews.spec.ts └── users.spec.ts ├── email ├── send.go └── smtp │ ├── convert │ ├── convert.go │ └── convert_test.go │ └── smtp.go ├── flake.lock ├── flake.nix ├── fly.toml ├── go.mod ├── go.sum ├── handlers ├── account.go ├── account_test.go ├── auth.go ├── auth_test.go ├── comments.go ├── comments_test.go ├── csp.go ├── db_dev.go ├── db_prod.go ├── invites.go ├── invites_test.go ├── maintenance.go ├── parse │ ├── checkbox.go │ ├── comment.go │ ├── comment_test.go │ ├── email.go │ ├── email_test.go │ ├── invites.go │ ├── invites_test.go │ ├── movie.go │ ├── movie_test.go │ ├── password.go │ ├── password_test.go │ ├── reserved.go │ ├── review.go │ ├── review_test.go │ ├── search.go │ ├── search_test.go │ ├── tmdb.go │ ├── tv_show.go │ ├── tv_show_test.go │ ├── username.go │ └── username_test.go ├── reviews.go ├── reviews_test.go ├── routes.go ├── search.go ├── search_test.go ├── server.go ├── sessions │ ├── serialize.go │ └── sessions.go ├── static.go ├── static │ ├── css │ │ ├── reviews.css │ │ └── screenjournal.css │ ├── js │ │ ├── controllers │ │ │ ├── auth.js │ │ │ ├── common.js │ │ │ └── register.js │ │ ├── htmx-ext │ │ │ └── clear-before-send.js │ │ ├── htmx-settings.js │ │ ├── lib │ │ │ ├── clipboard.js │ │ │ ├── spoilers.js │ │ │ └── ui.js │ │ └── navbar.js │ └── third-party │ │ ├── bootstrap@5.2.2 │ │ ├── css │ │ │ ├── bootstrap.min.css │ │ │ └── bootstrap.min.css.map │ │ └── js │ │ │ ├── bootstrap.bundle.min.js │ │ │ └── bootstrap.bundle.min.js.map │ │ ├── fontawesome@6.2.0 │ │ ├── LICENSE.txt │ │ ├── css │ │ │ ├── fontawesome.min.css │ │ │ ├── regular.min.css │ │ │ └── solid.min.css │ │ └── webfonts │ │ │ ├── fa-brands-400.ttf │ │ │ ├── fa-brands-400.woff2 │ │ │ ├── fa-regular-400.ttf │ │ │ ├── fa-regular-400.woff2 │ │ │ ├── fa-solid-900.ttf │ │ │ ├── fa-solid-900.woff2 │ │ │ ├── fa-v4compatibility.ttf │ │ │ └── fa-v4compatibility.woff2 │ │ ├── htmx-ext-response-targets@2.0.0 │ │ └── response-targets.js │ │ └── htmx@2.0.4 │ │ └── htmx.min.js ├── store.go ├── templates │ ├── fragments │ │ ├── comments-edit.html │ │ ├── invite-row.html │ │ └── search-results.html │ ├── layouts │ │ └── base.html │ ├── pages │ │ ├── about.html │ │ ├── account-change-password.html │ │ ├── account-notifications.html │ │ ├── account-security.html │ │ ├── index.html │ │ ├── invites.html │ │ ├── login.html │ │ ├── reviews-edit.html │ │ ├── reviews-for-single-media-entry.html │ │ ├── reviews-index.html │ │ ├── reviews-new.html │ │ ├── reviews-tv-pick-season.html │ │ ├── sign-up-by-invitation.html │ │ ├── sign-up.html │ │ └── users.html │ └── partials │ │ ├── footer.html │ │ └── navbar.html ├── upgrade_http_dev.go ├── upgrade_http_prod.go ├── url_parse.go ├── users.go ├── users_test.go └── views.go ├── litestream.yml ├── markdown ├── markdown.go └── markdown_test.go ├── metadata ├── metadata.go └── tmdb │ ├── movies.go │ ├── parse.go │ ├── parse_test.go │ ├── search.go │ ├── search_test.go │ ├── tmdb.go │ └── tv_shows.go ├── modd.conf ├── package-lock.json ├── package.json ├── playwright.config.ts ├── random └── random.go ├── screenjournal ├── comments.go ├── email.go ├── invites.go ├── metadata.go ├── notifications.go ├── ordering.go ├── review.go ├── search.go └── user.go └── store ├── sqlite ├── comments.go ├── invites.go ├── litestream.go ├── migrations.go ├── migrations │ ├── 001-reviews-create.sql │ ├── 002-movies-create.sql │ ├── 003-users-create.sql │ ├── 004-invites-create.sql │ ├── 005-adjust-ratings.sql │ ├── 006-notifications-create.sql │ ├── 007-comments-create.sql │ ├── 008-adjust-notifications-add-comments.sql │ ├── 009-ratings-out-of-10.sql │ ├── 010-tv-shows-create-.sql │ ├── 011-strict-tables.sql │ └── 012-nullable-ratings.sql ├── movies.go ├── notifications.go ├── reviews.go ├── sqlite.go ├── tv_shows.go ├── users.go └── wipe_dev.go ├── store.go └── test_sqlite └── db.go /.clinerules: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## Sensitive Files 4 | 5 | DO NOT read or modify: 6 | 7 | - .env files 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # To populate a .env file for prod, development, or devops, copy this file and 2 | # name it appropriately. For example: 3 | # 4 | # cp -n .env.example .env.prod 5 | # cp -n .env.example .env.dev 6 | 7 | BACKBLAZE_REGION='us-west-002' # Replace 8 | LITESTREAM_ENDPOINT="s3.${BACKBLAZE_REGION}.backblazeb2.com" # Replace 9 | LITESTREAM_ACCESS_KEY_ID='' 10 | LITESTREAM_SECRET_ACCESS_KEY='' 11 | LITESTREAM_BUCKET='my-bucket-name' # Replace 12 | 13 | # TMDB API key. 14 | SJ_TMDB_API='' 15 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake . 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | browser: true, 5 | es2022: true, 6 | }, 7 | parserOptions: { 8 | ecmaVersion: 11, 9 | sourceType: "module", 10 | }, 11 | extends: ["eslint:recommended"], 12 | rules: { 13 | // This will produce an error for console.log or console.warn in production 14 | // and a warning in development console.error will not produce an error or 15 | // warning https://eslint.org/docs/rules/no-console#options 16 | "no-console": [ 17 | process.env.NODE_ENV === "production" ? "error" : "warn", 18 | { allow: ["error"] }, 19 | ], 20 | }, 21 | ignorePatterns: [ 22 | "playwright-report/*", 23 | "handlers/static/third-party/**/*.js", 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/cla.yml: -------------------------------------------------------------------------------- 1 | name: "CLA Assistant" 2 | on: 3 | issue_comment: 4 | types: [created] 5 | pull_request_target: 6 | types: [opened, closed, synchronize] 7 | 8 | jobs: 9 | CLAssistant: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: "CLA Assistant" 13 | if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' 14 | uses: contributor-assistant/github-action@v2.6.1 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | with: 18 | path-to-signatures: "signatures/version1/cla.json" 19 | path-to-document: "https://github.com/mtlynch/screenjournal/blob/master/docs/CLA-v1.md" 20 | branch: "cla-signatures" 21 | allowlist: mtlynch, 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | bin/ 15 | node_modules/ 16 | 17 | # Vim temporary files 18 | *.sw? 19 | 20 | # Persistent data directory 21 | data/ 22 | 23 | # Code coverage artifacts 24 | .coverage* 25 | 26 | # Environment variables, which may contain secrets 27 | .env.* 28 | # .env.example is an exception as it only contains dummy secrets. 29 | !.env.example 30 | 31 | # Playwright output 32 | /playwright-report/ 33 | /playwright/.cache/ 34 | /e2e-results/ 35 | 36 | .direnv 37 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | playwright-report/ 2 | handlers/static/third-party 3 | .coverage.html 4 | .direnv/ 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": ["*.html"], 5 | "options": { 6 | "parser": "go-template" 7 | } 8 | } 9 | ], 10 | "goTemplateBracketSpacing": true 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "golang.go"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "[go]": { 4 | "editor.defaultFormatter": "golang.go" 5 | }, 6 | "editor.formatOnSave": true 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.3 AS builder 2 | 3 | ARG TARGETPLATFORM 4 | 5 | COPY ./announce /app/announce 6 | COPY ./auth /app/auth 7 | COPY ./cmd /app/cmd 8 | COPY ./dev-scripts/build-backend /app/dev-scripts/build-backend 9 | COPY ./email /app/email 10 | COPY ./handlers /app/handlers 11 | COPY ./markdown /app/markdown 12 | COPY ./metadata /app/metadata 13 | COPY ./random /app/random 14 | COPY ./screenjournal /app/screenjournal 15 | COPY ./store /app/store 16 | COPY ./go.* /app/ 17 | 18 | WORKDIR /app 19 | 20 | RUN TARGETPLATFORM="${TARGETPLATFORM}" \ 21 | ./dev-scripts/build-backend prod 22 | 23 | FROM scratch AS artifact 24 | COPY --from=builder /app/bin/screenjournal ./ 25 | 26 | FROM debian:stable-20240311-slim AS litestream_downloader 27 | 28 | ARG TARGETPLATFORM 29 | ARG litestream_version="v0.3.13" 30 | 31 | WORKDIR /litestream 32 | 33 | RUN set -x && \ 34 | apt-get update && \ 35 | DEBIAN_FRONTEND=noninteractive apt-get install -y \ 36 | ca-certificates \ 37 | wget 38 | 39 | RUN set -x && \ 40 | if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ 41 | ARCH="arm7" ; \ 42 | elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ 43 | ARCH="arm64" ; \ 44 | else \ 45 | ARCH="amd64" ; \ 46 | fi && \ 47 | set -u && \ 48 | litestream_binary_tgz_filename="litestream-${litestream_version}-linux-${ARCH}.tar.gz" && \ 49 | wget "https://github.com/benbjohnson/litestream/releases/download/${litestream_version}/${litestream_binary_tgz_filename}" && \ 50 | mv "${litestream_binary_tgz_filename}" litestream.tgz 51 | RUN tar -xvzf litestream.tgz 52 | 53 | 54 | FROM alpine:3.15 55 | 56 | RUN apk add --no-cache bash tzdata 57 | 58 | ARG TZ 59 | RUN if [[ -n "${TZ}" ]]; then \ 60 | ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime && \ 61 | echo "${TZ}" > /etc/timezone; \ 62 | fi 63 | 64 | COPY --from=builder /app/bin/screenjournal /app/screenjournal 65 | COPY --from=litestream_downloader /litestream/litestream /app/litestream 66 | COPY ./docker-entrypoint /app/docker-entrypoint 67 | COPY ./litestream.yml /etc/litestream.yml 68 | COPY ./LICENSE /app/LICENSE 69 | 70 | WORKDIR /app 71 | 72 | ENTRYPOINT ["/app/docker-entrypoint"] 73 | CMD ["-db", "/data/store.db"] 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ScreenJournal - Platform for discussing movies and TV. 2 | Copyright (C) 2023 Michael Lynch 3 | Licensing inquiries: licensing@mtlynch.io 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU Affero General Public License for more details. 14 | 15 | You should have received a copy of the GNU Affero General Public License 16 | along with this program. If not, see . 17 | -------------------------------------------------------------------------------- /announce/email/templates/new-comment.tmpl.txt: -------------------------------------------------------------------------------- 1 | Hey {{ .Recipient }}, 2 | 3 | {{ .CommentAuthor }} just commented on {{ .ReviewAuthor }}'s review of *{{ .Title }}*{{ .SeasonSuffix }}! Check it out: 4 | 5 | {{ .BaseURL }}{{ .CommentRoute }} 6 | 7 | -ScreenJournal Bot 8 | 9 | To manage your notifications, visit {{ .BaseURL }}/account/notifications 10 | -------------------------------------------------------------------------------- /announce/email/templates/new-review.tmpl.txt: -------------------------------------------------------------------------------- 1 | Hey {{ .Recipient }}, 2 | 3 | {{ .Author }} just posted a new review of *{{ .Title }}*{{ .SeasonSuffix }}! Check it out: 4 | 5 | {{ .BaseURL }}{{ .ReviewRoute }} 6 | 7 | -ScreenJournal Bot 8 | 9 | To manage your notifications, visit {{ .BaseURL }}/account/notifications 10 | -------------------------------------------------------------------------------- /announce/quiet/quiet.go: -------------------------------------------------------------------------------- 1 | package quiet 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/mtlynch/screenjournal/v2/screenjournal" 7 | ) 8 | 9 | type Announcer struct { 10 | } 11 | 12 | func New() Announcer { 13 | return Announcer{} 14 | } 15 | 16 | func (a Announcer) AnnounceNewReview(r screenjournal.Review) { 17 | log.Printf("skipping announcement of review for %s because no announcer is configured", readMediaTitle(r)) 18 | } 19 | 20 | func (a Announcer) AnnounceNewComment(rc screenjournal.ReviewComment) { 21 | log.Printf("skipping announcement of new comment from %s about %s's review of %s because no announcer is configured", rc.Owner, rc.Review.Owner, readMediaTitle(rc.Review)) 22 | } 23 | 24 | func readMediaTitle(r screenjournal.Review) screenjournal.MediaTitle { 25 | if !r.Movie.ID.IsZero() { 26 | return r.Movie.Title 27 | } 28 | return r.TvShow.Title 29 | } 30 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | simple_auth "github.com/mtlynch/simpleauth/v2/auth" 5 | 6 | "github.com/mtlynch/screenjournal/v2/screenjournal" 7 | ) 8 | 9 | type ( 10 | GenericAuthenticator interface { 11 | Authenticate(username, password string) error 12 | } 13 | 14 | Authenticator struct { 15 | inner GenericAuthenticator 16 | } 17 | 18 | UserStore interface { 19 | ReadUser(screenjournal.Username) (screenjournal.User, error) 20 | } 21 | ) 22 | 23 | func New(userStore UserStore) Authenticator { 24 | return Authenticator{ 25 | inner: simple_auth.New(authStore{ 26 | userStore: userStore, 27 | }), 28 | } 29 | } 30 | 31 | func (a Authenticator) Authenticate(username screenjournal.Username, password screenjournal.Password) error { 32 | return a.inner.Authenticate(username.String(), password.String()) 33 | } 34 | -------------------------------------------------------------------------------- /auth/password.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | simple_auth "github.com/mtlynch/simpleauth/v2/auth" 5 | 6 | "github.com/mtlynch/screenjournal/v2/screenjournal" 7 | ) 8 | 9 | func HashPassword(password screenjournal.Password) (screenjournal.PasswordHash, error) { 10 | h, err := simple_auth.HashPassword(password.String()) 11 | if err != nil { 12 | return screenjournal.PasswordHash{}, err 13 | } 14 | return screenjournal.PasswordHash(h.Bytes()), nil 15 | } 16 | -------------------------------------------------------------------------------- /auth/store.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/mtlynch/screenjournal/v2/handlers/parse" 5 | simple_auth "github.com/mtlynch/simpleauth/v2/auth" 6 | ) 7 | 8 | type authStore struct { 9 | userStore UserStore 10 | } 11 | 12 | func (s authStore) ReadPasswordHash(usernameRaw string) (simple_auth.PasswordHash, error) { 13 | username, err := parse.Username(usernameRaw) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | user, err := s.userStore.ReadUser(username) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return simple_auth.PasswordHashFromBytes(user.PasswordHash.Bytes()), nil 24 | } 25 | -------------------------------------------------------------------------------- /cmd/screenjournal/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | 12 | gorilla "github.com/mtlynch/gorilla-handlers" 13 | 14 | email_announce "github.com/mtlynch/screenjournal/v2/announce/email" 15 | "github.com/mtlynch/screenjournal/v2/announce/quiet" 16 | "github.com/mtlynch/screenjournal/v2/auth" 17 | "github.com/mtlynch/screenjournal/v2/email/smtp" 18 | "github.com/mtlynch/screenjournal/v2/handlers" 19 | "github.com/mtlynch/screenjournal/v2/handlers/sessions" 20 | "github.com/mtlynch/screenjournal/v2/metadata/tmdb" 21 | "github.com/mtlynch/screenjournal/v2/store/sqlite" 22 | ) 23 | 24 | func main() { 25 | log.Print("starting screenjournal server") 26 | 27 | log.SetFlags(log.LstdFlags | log.Llongfile) 28 | dbPath := flag.String("db", "data/store.db", "path to database") 29 | flag.Parse() 30 | 31 | ensureDirExists(filepath.Dir(*dbPath)) 32 | store := sqlite.New(*dbPath, isLitestreamEnabled()) 33 | 34 | authenticator := auth.New(store) 35 | 36 | useTls := isTlsRequired() 37 | if !useTls { 38 | log.Printf("TLS has not been marked as required, so session cookies will not have Secure flag") 39 | } 40 | sessionManager, err := sessions.NewManager(*dbPath, useTls) 41 | if err != nil { 42 | log.Fatalf("failed to create session manager: %v", err) 43 | } 44 | 45 | var announcer handlers.Announcer 46 | if isSmtpEnabled() { 47 | smtpHost := requireEnv("SJ_SMTP_HOST") 48 | smtpPort, err := strconv.Atoi(requireEnv("SJ_SMTP_PORT")) 49 | if err != nil { 50 | log.Printf("failed to parse SMTP port: %v", err) 51 | } 52 | log.Printf("SMTP is enabled using server at %s:%d", smtpHost, smtpPort) 53 | mailSender, err := smtp.New(smtpHost, smtpPort, requireEnv("SJ_SMTP_USERNAME"), requireEnv("SJ_SMTP_PASSWORD")) 54 | if err != nil { 55 | log.Fatalf("failed to create mail sender: %v", err) 56 | } 57 | announcer = email_announce.New(requireEnv("SJ_BASE_URL"), mailSender, store) 58 | } else { 59 | log.Printf("SMTP not configured. Transactional emails are disabled") 60 | announcer = quiet.New() 61 | } 62 | 63 | metadataFinder, err := tmdb.New(requireEnv("SJ_TMDB_API")) 64 | if err != nil { 65 | log.Fatalf("failed to create metadata finder: %v", err) 66 | } 67 | 68 | h := gorilla.LoggingHandler(os.Stdout, handlers.New(authenticator, announcer, sessionManager, store, metadataFinder).Router()) 69 | if os.Getenv("SJ_BEHIND_PROXY") != "" { 70 | h = gorilla.ProxyIPHeadersHandler(h) 71 | } 72 | http.Handle("/", h) 73 | 74 | port := os.Getenv("PORT") 75 | if port == "" { 76 | port = "4003" 77 | } 78 | log.Printf("listening on %s", port) 79 | 80 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil)) 81 | } 82 | 83 | func requireEnv(key string) string { 84 | val := os.Getenv(key) 85 | if val == "" { 86 | log.Fatalf("missing required environment variable: %s", key) 87 | } 88 | return val 89 | } 90 | 91 | func ensureDirExists(dir string) { 92 | if _, err := os.Stat(dir); os.IsNotExist(err) { 93 | if err := os.Mkdir(dir, os.ModePerm); err != nil { 94 | panic(err) 95 | } 96 | } 97 | } 98 | 99 | func isLitestreamEnabled() bool { 100 | return os.Getenv("LITESTREAM_BUCKET") != "" 101 | } 102 | 103 | func isSmtpEnabled() bool { 104 | return os.Getenv("SJ_SMTP_USERNAME") != "" 105 | } 106 | 107 | func isTlsRequired() bool { 108 | if os.Getenv("SJ_REQUIRE_TLS") == "false" { 109 | return false 110 | } 111 | return defaultIsTlsRequired 112 | } 113 | -------------------------------------------------------------------------------- /cmd/screenjournal/tls_dev.go: -------------------------------------------------------------------------------- 1 | //go:build dev 2 | 3 | package main 4 | 5 | const defaultIsTlsRequired = false 6 | -------------------------------------------------------------------------------- /cmd/screenjournal/tls_prod.go: -------------------------------------------------------------------------------- 1 | //go:build !dev 2 | 3 | package main 4 | 5 | const defaultIsTlsRequired = true 6 | -------------------------------------------------------------------------------- /dev-scripts/build-backend: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on first failing command. 4 | set -e 5 | 6 | # Echo commands before executing them, by default to stderr. 7 | set -x 8 | 9 | if [[ -z $1 ]]; then 10 | MODE='prod' 11 | else 12 | MODE="$1" 13 | fi 14 | 15 | PLATFORM="${TARGETPLATFORM:-linux/amd64}" 16 | 17 | # Exit on unset variable. 18 | set -u 19 | 20 | GO_BUILD_TAGS=() 21 | BINARY='./bin/screenjournal' 22 | 23 | GO_BUILD_TAGS+=('netgo') 24 | # Disable dynamically-loaded extensions, which cause a compile time warning. 25 | # https://www.arp242.net/static-go.html 26 | GO_BUILD_TAGS+=('sqlite_omit_load_extension') 27 | 28 | if [[ "${MODE}" != 'prod' ]]; then 29 | BINARY="${BINARY}-${MODE}" 30 | GO_BUILD_TAGS+=("${MODE}") 31 | fi 32 | readonly BINARY 33 | readonly GO_BUILD_TAGS 34 | 35 | readonly GOOS='linux' 36 | export GOOS 37 | if [ "${PLATFORM}" = 'linux/amd64' ]; then 38 | GOARCH='amd64' 39 | elif [ "${PLATFORM}" = 'linux/arm/v7' ]; then 40 | GOARCH='arm' 41 | elif [ "${PLATFORM}" = 'linux/arm64' ]; then 42 | GOARCH='arm64' 43 | else 44 | echo "Unsupported platform: ${PLATFORM}" 45 | exit 1 46 | fi 47 | readonly GOARCH 48 | export GOARCH 49 | 50 | # Join together build tags 51 | BUILD_TAGS_JOINED="" 52 | for tag in "${GO_BUILD_TAGS[@]}"; do 53 | BUILD_TAGS_JOINED+=" $tag" 54 | done 55 | 56 | # Trim leading space. 57 | BUILD_TAGS_JOINED="${BUILD_TAGS_JOINED# }" 58 | readonly BUILD_TAGS_JOINED 59 | 60 | export CGO_ENABLED=0 61 | 62 | go build \ 63 | -tags "${BUILD_TAGS_JOINED}" \ 64 | -o "${BINARY}" \ 65 | ./cmd/screenjournal 66 | -------------------------------------------------------------------------------- /dev-scripts/check-bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Run static analysis on bash scripts. 4 | 5 | # Exit on first failing command. 6 | set -e 7 | 8 | # Exit on unset variable. 9 | set -u 10 | 11 | BASH_SCRIPTS=() 12 | 13 | while read -r filepath; do 14 | # Check shebang for bash. 15 | if head -n 1 "${filepath}" | grep --quiet --regexp 'bash'; then 16 | BASH_SCRIPTS+=("${filepath}") 17 | fi 18 | done < <(git ls-files) 19 | 20 | readonly BASH_SCRIPTS 21 | 22 | # Echo commands before executing them, by default to stderr. 23 | set -x 24 | 25 | shellcheck "${BASH_SCRIPTS[@]}" 26 | -------------------------------------------------------------------------------- /dev-scripts/check-frontend: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit script on first failure. 4 | set -e 5 | 6 | # Echo commands before executing them, by default to stderr. 7 | set -x 8 | 9 | # Exit on unset variable. 10 | set -u 11 | 12 | ./node_modules/.bin/prettier --check . 13 | ./node_modules/.bin/eslint ./**/*.js 14 | -------------------------------------------------------------------------------- /dev-scripts/check-go-formatting: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on first failure. 4 | set -e 5 | 6 | # Exit on unset variable. 7 | set -u 8 | 9 | # Change directory to repository root. 10 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 11 | readonly SCRIPT_DIR 12 | cd "${SCRIPT_DIR}/.." 13 | 14 | test -z "$(gofmt -s -d .)" 15 | -------------------------------------------------------------------------------- /dev-scripts/check-trailing-newline: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Verify that all text files end in a trailing newline. 4 | 5 | # Exit on first failure. 6 | set -e 7 | 8 | # Exit on unset variable. 9 | set -u 10 | 11 | # Change directory to repository root. 12 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 13 | readonly SCRIPT_DIR 14 | cd "${SCRIPT_DIR}/.." 15 | 16 | success=0 17 | 18 | while read -r line; do 19 | if ! [[ -s "${line}" && -z "$(tail -c 1 "${line}")" ]]; then 20 | printf "File must end in a trailing newline: %s\n" "${line}" >&2 21 | success=255 22 | fi 23 | done < <(git ls-files \ 24 | | xargs grep ".*" \ 25 | --files-with-matches \ 26 | --binary-files=without-match \ 27 | --exclude="*third-party*") 28 | 29 | exit "${success}" 30 | -------------------------------------------------------------------------------- /dev-scripts/check-trailing-whitespace: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Check for trailing whitespace 4 | 5 | # Exit on first failure. 6 | set -e 7 | 8 | # Exit on unset variable. 9 | set -u 10 | 11 | # Change directory to repository root. 12 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 13 | readonly SCRIPT_DIR 14 | cd "${SCRIPT_DIR}/.." 15 | 16 | while read -r line; do 17 | if grep \ 18 | "\s$" \ 19 | --line-number \ 20 | --with-filename \ 21 | --binary-files=without-match \ 22 | "${line}"; then 23 | echo "ERROR: Found trailing whitespace"; 24 | exit 1; 25 | fi 26 | done < <(git ls-files) 27 | -------------------------------------------------------------------------------- /dev-scripts/download-prod-db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on first failing command. 4 | set -e 5 | 6 | # Exit on unset variable. 7 | set -u 8 | 9 | # Echo commands 10 | set -x 11 | 12 | # Change directory to repository root. 13 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 14 | readonly SCRIPT_DIR 15 | cd "${SCRIPT_DIR}/.." 16 | 17 | TIMESTAMP=$(date --iso-8601=minutes | sed 's/://g' | sed 's/+0000/Z/g') 18 | export DB_PATH="data/store.db" 19 | export DB_COPY_PATH="data/${TIMESTAMP}.db" 20 | 21 | ./dev-scripts/reset-db 22 | 23 | set +x 24 | # shellcheck disable=SC1091 25 | . .env.prod 26 | set -x 27 | 28 | export LITESTREAM_ENDPOINT 29 | export LITESTREAM_ACCESS_KEY_ID 30 | export LITESTREAM_SECRET_ACCESS_KEY 31 | export LITESTREAM_BUCKET 32 | 33 | # Export DB_PATH so that litestream uses the variable to populate 34 | # litestream.yml. 35 | export DB_PATH 36 | 37 | litestream snapshots -config litestream.yml "${DB_PATH}" 38 | 39 | # Retrieve live DB 40 | litestream restore -config litestream.yml -o "${DB_COPY_PATH}" "${DB_PATH}" 41 | cp "${DB_COPY_PATH}" "${DB_PATH}" 42 | -------------------------------------------------------------------------------- /dev-scripts/enable-git-hooks: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on first failure. 4 | set -e 5 | 6 | # Exit on unset variable. 7 | set -u 8 | 9 | # Echo commands before executing them, by default to stderr. 10 | set -x 11 | 12 | # Change directory to repository root. 13 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 14 | readonly SCRIPT_DIR 15 | cd "${SCRIPT_DIR}/.." 16 | 17 | # If there's an existing symlink, remove it. 18 | if [[ -L .git/hooks ]] 19 | then 20 | rm .git/hooks 21 | fi 22 | 23 | # If it's a regular directory, remove all files. 24 | if [[ -d .git/hooks ]] 25 | then 26 | rm -rf .git/hooks 27 | fi 28 | 29 | ln --symbolic --force ../dev-scripts/git-hooks .git/hooks 30 | -------------------------------------------------------------------------------- /dev-scripts/enable-multiarch-docker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Configure Docker to support multiarch builds, allowing it to use QEMU to build 4 | # images targeting different CPU architectures. 5 | 6 | # Exit script on first failure. 7 | set -e 8 | 9 | # Echo commands before executing them, by default to stderr. 10 | set -x 11 | 12 | # Exit on unset variable. 13 | set -u 14 | 15 | # Enable multiarch builds with QEMU. 16 | docker run \ 17 | --rm \ 18 | --privileged \ 19 | multiarch/qemu-user-static \ 20 | --reset \ 21 | -p yes 22 | 23 | # Create multiarch build context. 24 | docker context create builder 25 | 26 | # Create multiplatform builder. 27 | docker buildx create builder \ 28 | --name builder \ 29 | --driver docker-container \ 30 | --use 31 | 32 | # Ensure builder has booted. 33 | docker buildx inspect --bootstrap 34 | -------------------------------------------------------------------------------- /dev-scripts/git-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit build script on first failure. 4 | set -e 5 | 6 | # Echo commands to stdout. 7 | set -x 8 | 9 | # Exit on unset variable. 10 | set -u 11 | 12 | ./dev-scripts/run-go-tests --quick 13 | ./dev-scripts/check-go-formatting 14 | ./dev-scripts/lint-sql 15 | ./dev-scripts/check-frontend 16 | -------------------------------------------------------------------------------- /dev-scripts/lint-sql: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Check for SQL script anti-patterns. 4 | 5 | # Exit on first failure. 6 | set -e 7 | 8 | # Echo commands before executing them, by default to stderr. 9 | set -x 10 | 11 | # Change directory to repository root. 12 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 13 | readonly SCRIPT_DIR 14 | cd "${SCRIPT_DIR}/.." 15 | 16 | sqlfluff --version 17 | 18 | sqlfluff_flags=("--dialect=sqlite") 19 | if [[ -n "${CI}" ]]; then 20 | sqlfluff_flags+=("--disable-progress-bar") 21 | fi 22 | 23 | sqlfluff lint \ 24 | "${sqlfluff_flags[@]}" \ 25 | . 26 | -------------------------------------------------------------------------------- /dev-scripts/package-binaries: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit script on first failure. 4 | set -e 5 | 6 | # Echo commands before executing them, by default to stderr. 7 | set -x 8 | 9 | VERSION="$1" 10 | if [[ -z "${VERSION}" ]]; then 11 | >&2 echo "Must specify a version number like 1.2.3" 12 | exit 1 13 | fi 14 | readonly VERSION 15 | 16 | # Exit on unset variable. 17 | set -u 18 | 19 | # Change directory to repository root. 20 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 21 | readonly SCRIPT_DIR 22 | cd "${SCRIPT_DIR}/.." 23 | 24 | cd bin 25 | 26 | readonly OUTPUT_DIR="${PWD}/../dist" 27 | mkdir -p "${OUTPUT_DIR}" 28 | 29 | for d in ./*_*; do 30 | FOLDER_NAME="$(basename "$d")" 31 | 32 | # Split FOLDER_NAME into an array with underscore as a delimiter. 33 | IFS="_" read -r -a FOLDER_PARTS <<< "${FOLDER_NAME}" 34 | 35 | OS="${FOLDER_PARTS[0]}" 36 | 37 | # Join remaining parts and remove spaces. 38 | FOLDER_PARTS=("${FOLDER_PARTS[@]:1}") 39 | ARCH="${FOLDER_PARTS[*]}" 40 | ARCH="${ARCH//[[:blank:]]}" 41 | 42 | pushd "$d" 43 | tar \ 44 | --create \ 45 | --compress \ 46 | --file="${OUTPUT_DIR}/screenjournal-v${VERSION}-${OS}-${ARCH}.tar.gz" \ 47 | screenjournal 48 | popd 49 | done 50 | -------------------------------------------------------------------------------- /dev-scripts/populate-db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit build script on first failure. 4 | set -e 5 | 6 | # Echo commands before executing them, by default to stderr. 7 | set -x 8 | 9 | # Exit on unset variable. 10 | set -u 11 | 12 | curl http://localhost:4003/api/debug/db/populate-dummy-data 13 | -------------------------------------------------------------------------------- /dev-scripts/reset-db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | 5 | # Change directory to repository root. 6 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 7 | readonly SCRIPT_DIR 8 | cd "${SCRIPT_DIR}/.." 9 | 10 | readonly DB_PATH="data/store.db" 11 | 12 | rm ${DB_PATH}* || true 13 | -------------------------------------------------------------------------------- /dev-scripts/run-e2e-tests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit build script on first failure 4 | set -e 5 | 6 | # Echo commands to stdout. 7 | set -x 8 | 9 | REBUILD="true" 10 | if [[ "$1" = "--skip-build" ]]; then 11 | REBUILD="false" 12 | shift 13 | fi 14 | readonly REBUILD 15 | 16 | # Exit on unset variable. 17 | set -u 18 | 19 | # Change directory to repository root. 20 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 21 | readonly SCRIPT_DIR 22 | cd "${SCRIPT_DIR}/.." 23 | 24 | if [[ "${REBUILD}" == "true" ]]; then 25 | ./dev-scripts/build-backend dev 26 | fi 27 | 28 | npx playwright test "$@" 29 | -------------------------------------------------------------------------------- /dev-scripts/run-go-tests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Runs all unit tests and performs static code analysis. 4 | # 5 | # Options: 6 | # 7 | # --full Include slower, more exhaustive tests and capture test coverage 8 | # results (outputs to .coverage.html). 9 | 10 | # Exit on first failure. 11 | set -e 12 | 13 | # Echo commands before executing them, by default to stderr. 14 | set -x 15 | 16 | # Fail when piped commands fail. 17 | set -o pipefail 18 | 19 | full_test="" 20 | # Without netgo and osusergo, compilation fails under Nix. 21 | go_test_flags=("-tags=netgo,osusergo") 22 | go_test_flags+=("-fullpath") 23 | readonly COVERAGE_FILE_RAW=".coverage.out" 24 | readonly COVERAGE_FILE_HTML=".coverage.html" 25 | if [[ "$1" = "--full" ]]; then 26 | full_test="1" 27 | go_test_flags+=("-v") 28 | go_test_flags+=("-race") 29 | go_test_flags+=("--coverprofile=${COVERAGE_FILE_RAW}") 30 | fi 31 | 32 | # Exit on unset variable. 33 | set -u 34 | 35 | # Change directory to repository root. 36 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 37 | readonly SCRIPT_DIR 38 | cd "${SCRIPT_DIR}/.." 39 | 40 | go test "${go_test_flags[@]}" ./... 41 | if [[ -n "$full_test" ]]; then 42 | go tool cover -html "${COVERAGE_FILE_RAW}" -o "${COVERAGE_FILE_HTML}" 43 | fi 44 | 45 | go vet ./... 46 | 47 | # Install staticcheck if it's not present. 48 | STATICCHECK_PATH="$(go env GOPATH)/bin/staticcheck" 49 | readonly STATICCHECK_PATH 50 | readonly STATICCHECK_VERSION="v0.5.1" 51 | if [[ ! -f "${STATICCHECK_PATH}" ]]; then 52 | go install \ 53 | -ldflags=-linkmode=external \ 54 | "honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}" 55 | fi 56 | "${STATICCHECK_PATH}" ./... 57 | 58 | # Install errcheck if it's not present. 59 | ERRCHECK_PATH="$(go env GOPATH)/bin/errcheck" 60 | readonly ERRCHECK_PATH 61 | readonly ERRCHECK_VERSION="v1.7.0" 62 | if [[ ! -f "${ERRCHECK_PATH}" ]]; then 63 | go install \ 64 | -ldflags=-linkmode=external \ 65 | "github.com/kisielk/errcheck@${ERRCHECK_VERSION}" 66 | fi 67 | "${ERRCHECK_PATH}" -ignoretests ./... 68 | -------------------------------------------------------------------------------- /dev-scripts/run-single-go-test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Run a single Go test with the specified test function name. 4 | 5 | # Exit on first failure. 6 | set -e 7 | 8 | # Echo commands before executing them, by default to stderr. 9 | set -x 10 | 11 | # Fail when piped commands fail. 12 | set -o pipefail 13 | 14 | readonly TEST_NAME="$1" 15 | 16 | go test \ 17 | -tags=netgo,osusergo,sqlite_json \ 18 | -fullpath \ 19 | -run "^${TEST_NAME}\$" \ 20 | ./... 21 | -------------------------------------------------------------------------------- /dev-scripts/serve: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on first failing command. 4 | set -e 5 | 6 | # Exit on unset variable. 7 | set -u 8 | 9 | # Echo commands 10 | set -x 11 | 12 | # Change directory to repository root. 13 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 14 | readonly SCRIPT_DIR 15 | cd "${SCRIPT_DIR}/.." 16 | 17 | # Install modd if it's not present. 18 | MODD_PATH="$(go env GOPATH)/bin/modd" 19 | readonly MODD_PATH 20 | readonly MODD_VERSION="v0.0.0-20211215124449-6083f9d1c171" 21 | if [[ ! -f "${MODD_PATH}" ]]; then 22 | go install \ 23 | -ldflags=-linkmode=external \ 24 | "github.com/cortesi/modd/cmd/modd@${MODD_VERSION}" 25 | fi 26 | 27 | # Load dev environment vars. 28 | set +x 29 | # shellcheck disable=SC1091 30 | . .env.dev 31 | set -x 32 | 33 | # Run modd for hot reloading. 34 | $MODD_PATH 35 | -------------------------------------------------------------------------------- /dev-scripts/serve-docker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit build script on first failure. 4 | set -e 5 | 6 | # Echo commands to stdout. 7 | set -x 8 | 9 | # Change directory to repository root. 10 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 11 | readonly SCRIPT_DIR 12 | cd "${SCRIPT_DIR}/.." 13 | 14 | 15 | DOCKER_BUILDKIT=1 \ 16 | docker build \ 17 | --build-arg TZ="${TZ}" \ 18 | --tag screenjournal . 19 | 20 | docker rm -f screenjournal || true 21 | 22 | PORT=4009 23 | 24 | docker run \ 25 | --env "PORT=${PORT}" \ 26 | --env "SJ_TMDB_API=${SJ_TMDB_API}" \ 27 | --env "SJ_SMTP_HOST=${SJ_SMTP_HOST:-}" \ 28 | --env "SJ_SMTP_PORT=${SJ_SMTP_PORT:-}" \ 29 | --env "SJ_SMTP_USERNAME=${SJ_SMTP_USERNAME:-}" \ 30 | --env "SJ_SMTP_PASSWORD=${SJ_SMTP_PASSWORD:-}" \ 31 | --env "LITESTREAM_BUCKET=${LITESTREAM_BUCKET:-}" \ 32 | --env "LITESTREAM_ENDPOINT=${LITESTREAM_ENDPOINT:-}" \ 33 | --env "LITESTREAM_ACCESS_KEY_ID=${LITESTREAM_ACCESS_KEY_ID:-}" \ 34 | --env "LITESTREAM_SECRET_ACCESS_KEY=${LITESTREAM_SECRET_ACCESS_KEY:-}" \ 35 | --publish "0.0.0.0:${PORT}:${PORT}" \ 36 | --name screenjournal \ 37 | screenjournal 38 | -------------------------------------------------------------------------------- /dev-scripts/upload-prod-db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | # Change directory to repository root. 6 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 7 | readonly SCRIPT_DIR 8 | cd "${SCRIPT_DIR}/.." 9 | 10 | export DB_PATH="$1" 11 | 12 | set -u 13 | 14 | set +x 15 | # shellcheck disable=SC1091 16 | . .env.prod 17 | set -x 18 | 19 | if [[ -z "${DB_PATH}" ]]; then 20 | echo "usage: upload-prod-db db_path" && exit 1 21 | fi 22 | 23 | read -r -p "Really overwrite prod database? (y/N): " choice 24 | 25 | echo "Choice is ${choice}" 26 | 27 | if [[ $choice != "y" ]]; then 28 | echo "Upload aborted" 29 | exit 1 30 | fi 31 | 32 | flyctl scale count 0 33 | 34 | echo "Replacing prod database" 35 | 36 | litestream replicate -config litestream.yml -exec "sleep 30" 37 | 38 | flyctl scale count 1 39 | -------------------------------------------------------------------------------- /docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit build script on first failure. 4 | set -e 5 | 6 | # Exit on unset variable. 7 | set -u 8 | 9 | SJ_ARGS="$*" 10 | readonly SJ_ARGS 11 | 12 | # Parse the -db flag to screenjournal since we need to know it before passing it 13 | # along. 14 | while [ "$#" -gt 0 ]; do 15 | case "$1" in 16 | -db) DB_PATH="$2"; shift 2;; 17 | -db=*) DB_PATH="${1#*=}"; shift 1;; 18 | *) shift 1;; 19 | esac 20 | done 21 | readonly DB_PATH 22 | # We need to export DB_PATH because litestream.yml references it. 23 | export DB_PATH 24 | 25 | is_litestream_enabled() { 26 | set +ux 27 | 28 | local IS_ENABLED='false' 29 | 30 | if [[ -n "${LITESTREAM_BUCKET}" ]]; then 31 | IS_ENABLED='true'; 32 | fi 33 | 34 | set -ux 35 | 36 | echo "${IS_ENABLED}" 37 | } 38 | 39 | IS_LITESTREAM_ENABLED="$(is_litestream_enabled)" 40 | readonly IS_LITESTREAM_ENABLED 41 | 42 | # Echo commands to stdout. 43 | set -x 44 | 45 | SJ_LAUNCH_CMD="/app/screenjournal ${SJ_ARGS}" 46 | 47 | if [[ "${IS_LITESTREAM_ENABLED}" == 'true' ]]; then 48 | /app/litestream version 49 | echo "LITESTREAM_BUCKET=${LITESTREAM_BUCKET}" 50 | echo "LITESTREAM_ENDPOINT=${LITESTREAM_ENDPOINT}" 51 | 52 | if [[ -f "$DB_PATH" ]]; then 53 | echo "Existing database is $(stat -c %s "${DB_PATH}") bytes" 54 | else 55 | echo "No existing database found" 56 | # Restore database from remote storage. 57 | /app/litestream restore -if-replica-exists "${DB_PATH}" 58 | fi 59 | 60 | # Let Litestream start screenjournal as a child process 61 | /app/litestream replicate -exec "$SJ_LAUNCH_CMD" 62 | else 63 | echo "Starting without litestream" 64 | eval "${SJ_LAUNCH_CMD}" 65 | fi 66 | -------------------------------------------------------------------------------- /docs/advanced-installation.md: -------------------------------------------------------------------------------- 1 | # Advanced Installation 2 | 3 | ## Running ScreenJournal from a precompiled binary 4 | 5 | Linux binaries are available for ScreenJournal. 6 | 7 | ScreenJournal runs as a single-file binary, so installation is straightforward. 8 | 9 | First, download the binary for your architecture from the [latest release](https://github.com/mtlynch/screenjournal/releases/latest). Extract the file from the archive, and run it with the following command: 10 | 11 | ```bash 12 | SJ_TMDB_API='your-TMDB-api-key' # Replace with your own 13 | 14 | SJ_REQUIRE_TLS=false \ 15 | PORT=4003 \ 16 | SJ_TMDB_API="${SJ_TMDB_API}" \ 17 | ./screenjournal 18 | ``` 19 | 20 | ScreenJournal will be running at 21 | 22 | ## Running ScreenJournal from source 23 | 24 | ```bash 25 | SJ_TMDB_API='your-TMDB-api-key' # Replace with your own 26 | 27 | SJ_REQUIRE_TLS=false \ 28 | PORT=4003 \ 29 | SJ_TMDB_API="${SJ_TMDB_API}" \ 30 | go run ./cmd/screenjournal 31 | ``` 32 | 33 | ScreenJournal will be running at 34 | -------------------------------------------------------------------------------- /docs/assets/screenjournal-demo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtlynch/screenjournal/a1ce9e9d681f4c267923a14175e98cfb39e1b39a/docs/assets/screenjournal-demo.webp -------------------------------------------------------------------------------- /e2e/changePassword.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { populateDummyData } from "./helpers/db.js"; 3 | import { loginAsUser, loginAsUserA } from "./helpers/login.js"; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await populateDummyData(page); 7 | await loginAsUserA(page); 8 | }); 9 | 10 | test("user can change their password and log in again", async ({ page }) => { 11 | await page.getByRole("menuitem", { name: "Account" }).click(); 12 | await page.getByRole("menuitem", { name: "Security" }).click(); 13 | 14 | await expect(page).toHaveURL("/account/security"); 15 | await page.getByText("Change password").click(); 16 | 17 | await expect(page).toHaveURL("/account/change-password"); 18 | await page.getByLabel("Current Password").fill("password123"); 19 | await page.getByLabel(/^New Password$/).fill("password321"); 20 | await page.getByLabel("Confirm New Password").fill("password321"); 21 | await page.getByRole("button", { name: /Change password/i }).click(); 22 | 23 | await expect(page.getByText("Password updated")).toBeVisible(); 24 | 25 | await page.getByRole("menuitem", { name: "Account" }).click(); 26 | await page.getByRole("menuitem", { name: "Log out" }).click(); 27 | 28 | await loginAsUser(page, "userA", "password321"); 29 | }); 30 | 31 | test("user can cancel their password password change and log in with their original credentials", async ({ 32 | page, 33 | }) => { 34 | await page.getByRole("menuitem", { name: "Account" }).click(); 35 | await page.getByRole("menuitem", { name: "Security" }).click(); 36 | 37 | await expect(page).toHaveURL("/account/security"); 38 | await page.getByText("Change password").click(); 39 | 40 | await expect(page).toHaveURL("/account/change-password"); 41 | await page.getByLabel("Current Password").fill("password123"); 42 | await page.getByLabel(/^New Password$/).fill("password321"); 43 | await page.getByLabel("Confirm New Password").fill("password321"); 44 | await page.getByRole("button", { name: /Cancel/i }).click(); 45 | 46 | await expect(page).toHaveURL("/account/security"); 47 | 48 | await page.getByRole("menuitem", { name: "Account" }).click(); 49 | await page.getByRole("menuitem", { name: "Log out" }).click(); 50 | 51 | await loginAsUserA(page); 52 | }); 53 | 54 | test("user can't change their password if their original is incorrect", async ({ 55 | page, 56 | }) => { 57 | await page.getByRole("menuitem", { name: "Account" }).click(); 58 | await page.getByRole("menuitem", { name: "Security" }).click(); 59 | 60 | await expect(page).toHaveURL("/account/security"); 61 | await page.getByText("Change password").click(); 62 | 63 | await expect(page).toHaveURL("/account/change-password"); 64 | await page.getByLabel("Current Password").fill("imawrongpassword"); 65 | await page.getByLabel(/^New Password$/).fill("password321"); 66 | await page.getByLabel("Confirm New Password").fill("password321"); 67 | await page.getByRole("button", { name: /Change password/i }).click(); 68 | 69 | await expect(page).toHaveURL("/account/change-password"); 70 | 71 | await expect( 72 | page.getByText("Failed to change password: current password is incorrect") 73 | ).toBeVisible(); 74 | }); 75 | -------------------------------------------------------------------------------- /e2e/helpers/db.js: -------------------------------------------------------------------------------- 1 | import { expect } from "@playwright/test"; 2 | 3 | export async function populateDummyData(page) { 4 | await page.goto("/"); // hack to populate the DB cookie 5 | const response = await page.goto("/api/debug/db/populate-dummy-data"); 6 | await expect(response?.status()).toBe(200); 7 | await page.goto("/"); 8 | } 9 | 10 | export function readDbTokenCookie(cookies) { 11 | for (const cookie of cookies) { 12 | if (cookie.name === "db-token") { 13 | return cookie; 14 | } 15 | } 16 | return undefined; 17 | } 18 | -------------------------------------------------------------------------------- /e2e/helpers/global-setup.ts: -------------------------------------------------------------------------------- 1 | import fetch from "isomorphic-fetch"; 2 | 3 | import { FullConfig } from "@playwright/test"; 4 | 5 | async function globalSetup(config: FullConfig) { 6 | const { baseURL } = config.projects[0].use; 7 | 8 | // Enable per-session databases so that test results stay independent. 9 | await fetch(baseURL + "/api/debug/db/per-session", { method: "POST" }); 10 | } 11 | 12 | export default globalSetup; 13 | -------------------------------------------------------------------------------- /e2e/helpers/login.js: -------------------------------------------------------------------------------- 1 | import { expect } from "@playwright/test"; 2 | 3 | export async function loginAsUser(page, username, password) { 4 | await page.getByRole("menuitem", { name: "Log in" }).click(); 5 | 6 | await page.getByRole("textbox", { name: /username/i }).fill(username); 7 | await page.getByRole("textbox", { name: /password/i }).fill(password); 8 | await page.getByRole("button", { name: "Log in" }).click(); 9 | 10 | await expect(page).toHaveURL("/reviews"); 11 | } 12 | 13 | export async function loginAsAdmin(page) { 14 | await loginAsUser(page, "dummyadmin", "dummypass"); 15 | } 16 | 17 | export async function loginAsUserA(page) { 18 | await loginAsUser(page, "userA", "password123"); 19 | } 20 | 21 | export async function loginAsUserB(page) { 22 | await loginAsUser(page, "userB", "password456"); 23 | } 24 | -------------------------------------------------------------------------------- /e2e/invite.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { loginAsAdmin } from "./helpers/login.js"; 3 | import { populateDummyData, readDbTokenCookie } from "./helpers/db.js"; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await populateDummyData(page); 7 | await loginAsAdmin(page); 8 | }); 9 | 10 | test("signing up with a valid invite code succeeds", async ({ 11 | page, 12 | browser, 13 | }) => { 14 | await page.getByRole("menuitem", { name: "Admin" }).click(); 15 | await page.getByRole("menuitem", { name: "Invites" }).click(); 16 | 17 | await expect(page).toHaveURL("/admin/invites"); 18 | await expect(page.getByLabel("Invitee's name")).toBeFocused(); 19 | await page.getByLabel("Invitee's name").fill("Billy"); 20 | await page.locator("form input[type='submit']").click(); 21 | 22 | await expect(page).toHaveURL("/admin/invites"); 23 | 24 | const inviteLink = 25 | (await page.getByTestId("invite-link").getAttribute("href")) || ""; 26 | 27 | const guestContext = await browser.newContext(); 28 | 29 | // Share database across users. 30 | await guestContext.addCookies([ 31 | readDbTokenCookie(await page.context().cookies()), 32 | ]); 33 | 34 | const guestPage = await guestContext.newPage(); 35 | await guestPage.goto(inviteLink); 36 | 37 | await expect(guestPage.locator(".alert-info")).toHaveText( 38 | "Welcome, Billy! We've been expecting you." 39 | ); 40 | await expect(guestPage.getByLabel("Username")).toHaveValue("billy"); 41 | await guestPage.getByLabel("Username").fill("billy123"); 42 | await guestPage.getByLabel("Email Address").fill("billy@example.com"); 43 | await guestPage.getByLabel("Password", { exact: true }).fill("billypass"); 44 | await guestPage.getByLabel("Confirm Password").fill("billypass"); 45 | await guestPage.locator("form input[type='submit']").click(); 46 | 47 | await expect(guestPage).toHaveURL("/reviews"); 48 | await guestContext.close(); 49 | }); 50 | 51 | test("signing up with an invalid invite code fails", async ({ 52 | page, 53 | browser, 54 | }) => { 55 | await page.getByRole("menuitem", { name: "Admin" }).click(); 56 | await page.getByRole("menuitem", { name: "Invites" }).click(); 57 | 58 | await expect(page).toHaveURL("/admin/invites"); 59 | await expect(page.getByLabel("Invitee's name")).toBeFocused(); 60 | await page.getByLabel("Invitee's name").fill("Nigel"); 61 | await page.locator("form input[type='submit']").click(); 62 | 63 | await expect(page).toHaveURL("/admin/invites"); 64 | 65 | const guestContext = await browser.newContext(); 66 | 67 | // Share database across users. 68 | await guestContext.addCookies([ 69 | readDbTokenCookie(await page.context().cookies()), 70 | ]); 71 | 72 | const guestPage = await guestContext.newPage(); 73 | const response = await guestPage.goto("/sign-up?invite=222333"); 74 | await expect(response?.status()).toBe(401); 75 | 76 | await guestContext.close(); 77 | }); 78 | -------------------------------------------------------------------------------- /e2e/navbar.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { populateDummyData } from "./helpers/db.js"; 3 | import { loginAsAdmin } from "./helpers/login.js"; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await populateDummyData(page); 7 | }); 8 | 9 | test("navbar updates links based on auth state", async ({ page }) => { 10 | await page.goto("/"); 11 | 12 | await page.locator(".navbar-brand").click(); 13 | await expect(page).toHaveURL("/"); 14 | 15 | await page.locator(".navbar").getByText("Home").click(); 16 | await expect(page).toHaveURL("/"); 17 | 18 | await page.locator(".navbar").getByText("About").click(); 19 | await expect(page).toHaveURL("/about"); 20 | 21 | await expect(page.locator(".navbar").getByText("Account")).toHaveCount(0); 22 | 23 | await loginAsAdmin(page); 24 | 25 | await page.locator(".navbar-brand").click(); 26 | await expect(page).toHaveURL("/reviews"); 27 | 28 | await page.locator(".navbar").getByText("Home").click(); 29 | await expect(page).toHaveURL("/reviews"); 30 | 31 | await page.locator(".navbar").getByText("About").click(); 32 | await expect(page).toHaveURL("/about"); 33 | 34 | await expect(page.locator(".navbar").getByText("Account")).toHaveCount(1); 35 | }); 36 | -------------------------------------------------------------------------------- /e2e/notifications.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { populateDummyData } from "./helpers/db.js"; 3 | import { loginAsUserA } from "./helpers/login.js"; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await populateDummyData(page); 7 | await loginAsUserA(page); 8 | }); 9 | 10 | test("notifications page reflects the backend store for new reviews", async ({ 11 | page, 12 | }) => { 13 | await page.getByRole("menuitem", { name: "Account" }).click(); 14 | await page.getByRole("menuitem", { name: "Notifications" }).click(); 15 | 16 | await expect(page).toHaveURL("/account/notifications"); 17 | 18 | await expect( 19 | page.getByLabel("Email me when users post reviews") 20 | ).toBeChecked(); 21 | 22 | // Turn off new review notifications. 23 | await page.getByLabel("Email me when users post reviews").click(); 24 | await page.locator("form .btn-primary").click(); 25 | 26 | await expect( 27 | page.getByLabel("Email me when users post reviews") 28 | ).not.toBeChecked(); 29 | 30 | await page.reload(); 31 | 32 | await expect( 33 | page.getByLabel("Email me when users post reviews") 34 | ).not.toBeChecked(); 35 | 36 | // Turn on new review notifications. 37 | await page.getByLabel("Email me when users post reviews").click(); 38 | await page.locator("form .btn-primary").click(); 39 | 40 | await expect( 41 | page.getByLabel("Email me when users post reviews") 42 | ).toBeChecked(); 43 | 44 | await page.reload(); 45 | 46 | await expect( 47 | page.getByLabel("Email me when users post reviews") 48 | ).toBeChecked(); 49 | }); 50 | 51 | test("notifications page reflects the backend store for new comments", async ({ 52 | page, 53 | }) => { 54 | await page.getByRole("menuitem", { name: "Account" }).click(); 55 | await page.getByRole("menuitem", { name: "Notifications" }).click(); 56 | 57 | await expect(page).toHaveURL("/account/notifications"); 58 | 59 | await expect( 60 | page.getByLabel("Email me when users add comments") 61 | ).toBeChecked(); 62 | 63 | // Turn off new comment notifications. 64 | await page.getByLabel("Email me when users add comments").click(); 65 | await page.locator("form .btn-primary").click(); 66 | 67 | await expect( 68 | page.getByLabel("Email me when users add comments") 69 | ).not.toBeChecked(); 70 | 71 | await page.reload(); 72 | 73 | await expect( 74 | page.getByLabel("Email me when users add comments") 75 | ).not.toBeChecked(); 76 | 77 | // Turn on new comment notifications. 78 | await page.getByLabel("Email me when users add comments").click(); 79 | await page.locator("form .btn-primary").click(); 80 | 81 | await expect( 82 | page.getByLabel("Email me when users add comments") 83 | ).toBeChecked(); 84 | 85 | await page.reload(); 86 | 87 | await expect( 88 | page.getByLabel("Email me when users add comments") 89 | ).toBeChecked(); 90 | }); 91 | -------------------------------------------------------------------------------- /e2e/users.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { populateDummyData } from "./helpers/db.js"; 3 | import { loginAsUserA } from "./helpers/login.js"; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await populateDummyData(page); 7 | await loginAsUserA(page); 8 | }); 9 | 10 | async function normalizedTextContent(locator: Locator): Promise { 11 | const text = await locator.textContent(); 12 | return text?.replace(/\s+/g, " ").trim() ?? ""; 13 | } 14 | 15 | test("views list of users", async ({ page }) => { 16 | await page.getByRole("menuitem", { name: "Users" }).click(); 17 | 18 | await expect(page).toHaveURL("/users"); 19 | 20 | expect(await normalizedTextContent(page.locator("ol li").nth(0))).toMatch( 21 | /dummyadmin joined \d{4}-\d{2}-\d{2} and has written 0 reviews\./ 22 | ); 23 | expect(await normalizedTextContent(page.locator("ol li").nth(1))).toMatch( 24 | /userA joined \d{4}-\d{2}-\d{2} and has written 1 reviews\./ 25 | ); 26 | expect(await normalizedTextContent(page.locator("ol li").nth(2))).toMatch( 27 | /userB joined \d{4}-\d{2}-\d{2} and has written 3 reviews\./ 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /email/send.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "net/mail" 5 | "time" 6 | ) 7 | 8 | type Message struct { 9 | From mail.Address 10 | To []mail.Address 11 | Subject string 12 | Date time.Time 13 | TextBody string 14 | HtmlBody string 15 | } 16 | 17 | type Sender interface { 18 | Send(message Message) error 19 | } 20 | -------------------------------------------------------------------------------- /email/smtp/convert/convert.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "fmt" 5 | "mime/multipart" 6 | "mime/quotedprintable" 7 | "net/textproto" 8 | "strings" 9 | 10 | "github.com/mtlynch/screenjournal/v2/email" 11 | ) 12 | 13 | type header struct { 14 | Name string 15 | Value string 16 | } 17 | 18 | // Boundary to use in generating multipart messages. Really only useful in 19 | // testing. 20 | var MultipartBoundary = "" 21 | 22 | func FromEmail(msg email.Message) (string, error) { 23 | var sb strings.Builder 24 | 25 | mpw := multipart.NewWriter(&sb) 26 | 27 | if MultipartBoundary != "" { 28 | if err := mpw.SetBoundary(MultipartBoundary); err != nil { 29 | panic(err) 30 | } 31 | } 32 | 33 | headers := []header{} 34 | headers = append(headers, makeHeader("From", msg.From.String())) 35 | headers = append(headers, makeHeader("To", msg.To[0].String())) 36 | headers = append(headers, makeHeader("Subject", msg.Subject)) 37 | headers = append(headers, makeHeader("MIME-Version", "1.0")) 38 | headers = append(headers, makeHeader("Content-Type", fmt.Sprintf("multipart/alternative; boundary=\"%s\"", mpw.Boundary()))) 39 | for _, hdr := range headers { 40 | sb.WriteString(fmt.Sprintf("%s: %s\r\n", hdr.Name, hdr.Value)) 41 | } 42 | 43 | writePart(mpw, "text/plain", msg.TextBody) 44 | writePart(mpw, "text/html", msg.HtmlBody) 45 | 46 | if err := mpw.Close(); err != nil { 47 | panic(err) 48 | } 49 | 50 | return sb.String(), nil 51 | } 52 | 53 | func writePart(mpw *multipart.Writer, contentType, content string) { 54 | part, err := mpw.CreatePart(textproto.MIMEHeader{ 55 | "Content-Type": {fmt.Sprintf("%s; charset=\"UTF-8\"", contentType)}, 56 | "Content-Transfer-Encoding": {"quoted-printable"}, 57 | }) 58 | if err != nil { 59 | panic(err) 60 | } 61 | 62 | qpw := quotedprintable.NewWriter(part) 63 | if _, err := qpw.Write([]byte(content)); err != nil { 64 | panic(err) 65 | } 66 | if err := qpw.Close(); err != nil { 67 | panic(err) 68 | } 69 | } 70 | 71 | func makeHeader(key, value string) header { 72 | return header{ 73 | Name: key, 74 | Value: value, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /email/smtp/convert/convert_test.go: -------------------------------------------------------------------------------- 1 | package convert_test 2 | 3 | import ( 4 | "net/mail" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/kylelemons/godebug/diff" 9 | 10 | "github.com/mtlynch/screenjournal/v2/email" 11 | "github.com/mtlynch/screenjournal/v2/email/smtp/convert" 12 | ) 13 | 14 | func TestFromEmail(t *testing.T) { 15 | convert.MultipartBoundary = "dummy-boundary-for-testing" 16 | var tests = []struct { 17 | input email.Message 18 | expected string 19 | }{ 20 | { 21 | input: email.Message{ 22 | From: mail.Address{ 23 | Name: "ScreenJournal Bot", 24 | Address: "bot@sj.example.com", 25 | }, 26 | To: []mail.Address{ 27 | { 28 | Name: "Alice User", 29 | Address: "alice@user.example.com", 30 | }, 31 | }, 32 | Subject: "Frank posted a review of The Room", 33 | TextBody: `Hi Alice, 34 | 35 | Frank has posted a new review of *The Room*: 36 | 37 | https://sj.example.com/movies/1#review25 38 | 39 | Sincerely, 40 | ScreenJournal Bot`, 41 | HtmlBody: `

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" 50 | To: "Alice User" 51 | Subject: Frank posted a review of The Room 52 | MIME-Version: 1.0 53 | Content-Type: multipart/alternative; boundary="dummy-boundary-for-testing" 54 | --dummy-boundary-for-testing 55 | Content-Transfer-Encoding: quoted-printable 56 | Content-Type: text/plain; charset="UTF-8" 57 | 58 | Hi Alice, 59 | 60 | Frank has posted a new review of *The Room*: 61 | 62 | https://sj.example.com/movies/1#review25 63 | 64 | Sincerely, 65 | ScreenJournal Bot 66 | --dummy-boundary-for-testing 67 | Content-Transfer-Encoding: quoted-printable 68 | Content-Type: text/html; charset="UTF-8" 69 | 70 |

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 |
48 |

{{ template "title" . }}

49 | 50 | {{ template "content" . }} 51 |
52 | 53 | {{ template "footer.html" . }} 54 | 55 | 56 | -------------------------------------------------------------------------------- /handlers/templates/pages/about.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | About ScreenJournal 3 | {{ end }} 4 | 5 | {{ define "content" }} 6 |

7 | ScreenJournal is a way to share and discuss movie recommendations with your 8 | friends. 9 |

10 | 11 |

ScreenJournal is private

12 | 13 |

14 | The ScreenJournal server you join is a private community. Nobody outside of 15 | the group can read your reviews or see your ratings. 16 |

17 | 18 |

ScreenJournal is free

19 | 20 |

21 | ScreenJournal is free to use. There are no ads, and we don't sell your data 22 | to anyone. 23 |

24 | 25 |

ScreenJournal is a work in progress

26 | 27 |

28 | It's still early days for ScreenJournal. It's being actively developed, but 29 | right now, it's only a minimal set of features. 30 |

31 | 32 |

Who made this?

33 | 34 |

35 | ScreenJournal is developed by 36 | Michael Lynch. 37 |

38 | {{ end }} 39 | -------------------------------------------------------------------------------- /handlers/templates/pages/account-change-password.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | Change Password 3 | {{ end }} 4 | 5 | {{ define "content" }} 6 |
15 |
16 | 25 | 26 |
27 |
28 | 36 | 37 |
38 |
39 | 47 | 50 |
51 | 52 |
53 | 58 | 63 | Cancel 64 | 65 |
66 | 67 |
68 | Loading... 69 |
70 |
71 | 72 | 73 | 74 | {{ end }} 75 | 76 | {{ define "script-tags" }} 77 | 101 | {{ end }} 102 | -------------------------------------------------------------------------------- /handlers/templates/pages/account-notifications.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | Manage Notifications 3 | {{ end }} 4 | 5 | {{ define "content" }} 6 |
15 |
16 | 23 | 26 |
27 |
28 | 35 | 38 |
39 | 40 |
41 | 45 |
46 | 47 |
48 | Loading... 49 |
50 |
51 | 52 | 53 | {{ end }} 54 | -------------------------------------------------------------------------------- /handlers/templates/pages/account-security.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | Account Security 3 | {{ end }} 4 | 5 | {{ define "content" }} 6 | 9 | {{ end }} 10 | -------------------------------------------------------------------------------- /handlers/templates/pages/index.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | ScreenJournal 3 | {{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |

Like Goodreads, but for couch potatoes.

9 | 17 |
18 |
19 | 20 |
21 | 22 | 70 | {{ end }} 71 | -------------------------------------------------------------------------------- /handlers/templates/pages/invites.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | Invites 3 | {{ end }} 4 | 5 | {{ define "script-tags" }} 6 | 18 | {{ end }} 19 | 20 | {{ define "content" }} 21 |
30 |
31 | 32 | 39 |
40 | 41 | 42 | 43 |
44 | Loading... 45 |
46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | {{ range .Invites }} 59 | {{ template "invite-row.html" . }} 60 | {{ end }} 61 | 62 |
InviteeActions
63 | {{ end }} 64 | -------------------------------------------------------------------------------- /handlers/templates/pages/login.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | Log In 3 | {{ end }} 4 | 5 | {{ define "style-tags" }} 6 | 13 | {{ end }} 14 | 15 | {{ define "script-tags" }} 16 | 64 | {{ end }} 65 | 66 | {{ define "content" }} 67 |
68 |
69 |
70 | 77 | 78 |
79 |
80 | 87 | 88 |
89 |
90 |
91 |
92 | 98 | 101 |
102 |
103 |
104 | 105 |
106 | 111 |
112 |
113 |

Not a member? Sign Up

114 |
115 |
116 | 117 | 120 |
121 | {{ end }} 122 | -------------------------------------------------------------------------------- /handlers/templates/pages/reviews-new.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | Add Review 3 | {{ end }} 4 | 5 | {{ define "style-tags" }} 6 | 31 | {{ end }} 32 | 33 | {{ define "script-tags" }} 34 | 52 | {{ end }} 53 | 54 | {{ define "content" }} 55 | 56 |
57 |
62 |
63 |
64 | 71 | 72 |
73 |
74 | 75 | 76 |
77 |
78 | 79 | 89 | 90 | 93 |
94 | 95 |
96 |
97 | {{ end }} 98 | -------------------------------------------------------------------------------- /handlers/templates/pages/reviews-tv-pick-season.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | Add Review 3 | {{ end }} 4 | 5 | {{ define "content" }} 6 | 7 |
8 |

{{ .TvShowTitle }} ({{ .ReleaseYear }})

9 |
10 |
11 | 12 | 31 |
32 | 33 | 36 |
37 |
38 | {{ end }} 39 | -------------------------------------------------------------------------------- /handlers/templates/pages/sign-up-by-invitation.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | Sign Up 3 | {{ end }} 4 | 5 | {{ define "content" }} 6 |

Sorry! Signups are currently by invitation only.

7 |

8 | To create an account, please request an invite from the administrator of 9 | this ScreenJournal server. 10 |

11 | {{ end }} 12 | -------------------------------------------------------------------------------- /handlers/templates/pages/users.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | Users 3 | {{ end }} 4 | 5 | {{ define "content" }} 6 |
    7 | {{ range .Users }} 8 |
  1. 9 | {{ .Username }} joined 10 | {{ .JoinDate.Format "2006-01-02" }} and has written 11 | {{ .ReviewCount }} reviews. 12 |
  2. 13 | {{ end }} 14 |
15 | {{ end }} 16 | -------------------------------------------------------------------------------- /handlers/templates/partials/footer.html: -------------------------------------------------------------------------------- 1 |
2 | 19 |
20 | -------------------------------------------------------------------------------- /handlers/upgrade_http_dev.go: -------------------------------------------------------------------------------- 1 | //go:build dev 2 | 3 | package handlers 4 | 5 | import "net/http" 6 | 7 | func upgradeToHttps(h http.Handler) http.Handler { 8 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 9 | // In dev-mode, this is a no-op, as we don't want to upgrade to HTTPS. 10 | h.ServeHTTP(w, r) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /handlers/upgrade_http_prod.go: -------------------------------------------------------------------------------- 1 | //go:build !dev 2 | 3 | package handlers 4 | 5 | import "net/http" 6 | 7 | func upgradeToHttps(h http.Handler) http.Handler { 8 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 9 | // If client is conecting over plaintext HTTP, upgrade to HTTPS. 10 | if r.Header.Get("X-Forwarded-Proto") == "http" { 11 | http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently) 12 | return 13 | } 14 | h.ServeHTTP(w, r) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /handlers/users.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/mtlynch/screenjournal/v2/auth" 10 | "github.com/mtlynch/screenjournal/v2/handlers/parse" 11 | "github.com/mtlynch/screenjournal/v2/screenjournal" 12 | "github.com/mtlynch/screenjournal/v2/store" 13 | ) 14 | 15 | type userPutRequest struct { 16 | Email screenjournal.Email 17 | Username screenjournal.Username 18 | PasswordHash screenjournal.PasswordHash 19 | InviteCode screenjournal.InviteCode 20 | } 21 | 22 | func (s Server) usersPut() http.HandlerFunc { 23 | return func(w http.ResponseWriter, r *http.Request) { 24 | req, err := newUserFromRequest(r) 25 | if err != nil { 26 | log.Printf("invalid new user form: %v", err) 27 | http.Error(w, fmt.Sprintf("Signup failed: %v", err), http.StatusBadRequest) 28 | return 29 | } 30 | 31 | c, err := s.getDB(r).CountUsers() 32 | if err != nil { 33 | log.Printf("failed to query user count: %v", err) 34 | http.Error(w, "Failed to query user count", http.StatusInternalServerError) 35 | return 36 | } 37 | 38 | user := screenjournal.User{ 39 | IsAdmin: c == 0, // First user is automatically admin 40 | Email: req.Email, 41 | Username: req.Username, 42 | PasswordHash: req.PasswordHash, 43 | } 44 | 45 | if c >= 1 { 46 | if _, err := s.getDB(r).ReadSignupInvitation(req.InviteCode); err != nil { 47 | log.Printf("invalid invite code: %v", err) 48 | http.Error(w, "Invalid invite code", http.StatusForbidden) 49 | return 50 | } 51 | } 52 | 53 | if err := s.getDB(r).InsertUser(user); err != nil { 54 | if err == store.ErrEmailAssociatedWithAnotherAccount { 55 | http.Error(w, "Failed to add new user", http.StatusConflict) 56 | } else if err == store.ErrUsernameNotAvailable { 57 | http.Error(w, "Username is not avilable", http.StatusConflict) 58 | } 59 | log.Printf("failed to add new user: %v", err) 60 | http.Error(w, "Failed to add new user", http.StatusInternalServerError) 61 | return 62 | } 63 | 64 | if err := s.sessionManager.CreateSession(w, r.Context(), user.Username, user.IsAdmin); err != nil { 65 | log.Printf("failed to create session for new user %+v: %v", user, err) 66 | http.Error(w, "Failed to create session", http.StatusInternalServerError) 67 | return 68 | } 69 | 70 | if !req.InviteCode.Empty() { 71 | if err := s.getDB(r).DeleteSignupInvitation(req.InviteCode); err != nil { 72 | log.Printf("failed to delete used signup invitation code: %v", err) 73 | } 74 | } 75 | 76 | } 77 | } 78 | 79 | func newUserFromRequest(r *http.Request) (userPutRequest, error) { 80 | username, err := usernameFromRequestPath(r) 81 | if err != nil { 82 | return userPutRequest{}, err 83 | } 84 | 85 | var payload struct { 86 | Email string `json:"email"` 87 | Password string `json:"password"` 88 | InviteCode string `json:"inviteCode"` 89 | } 90 | if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 91 | log.Printf("failed to decode JSON request: %v", err) 92 | return userPutRequest{}, err 93 | } 94 | 95 | email, err := parse.Email(payload.Email) 96 | if err != nil { 97 | return userPutRequest{}, err 98 | } 99 | 100 | plaintextPassword, err := parse.Password(payload.Password) 101 | if err != nil { 102 | return userPutRequest{}, err 103 | } 104 | 105 | var inviteCode screenjournal.InviteCode 106 | if payload.InviteCode != "" { 107 | if inviteCode, err = parse.InviteCode(payload.InviteCode); err != nil { 108 | return userPutRequest{}, err 109 | } 110 | } 111 | 112 | hash, err := auth.HashPassword(plaintextPassword) 113 | if err != nil { 114 | return userPutRequest{}, err 115 | } 116 | 117 | return userPutRequest{ 118 | Email: email, 119 | Username: username, 120 | PasswordHash: screenjournal.PasswordHash(hash.Bytes()), 121 | InviteCode: inviteCode, 122 | }, nil 123 | } 124 | -------------------------------------------------------------------------------- /litestream.yml: -------------------------------------------------------------------------------- 1 | access-key-id: ${LITESTREAM_ACCESS_KEY_ID} 2 | secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY} 3 | dbs: 4 | - path: ${DB_PATH} 5 | replicas: 6 | - type: s3 7 | bucket: ${LITESTREAM_BUCKET} 8 | path: db 9 | endpoint: ${LITESTREAM_ENDPOINT} 10 | force-path-style: true 11 | retention: 24000h # 1000 days, roughly 3 years 12 | snapshot-interval: 24h 13 | validation-interval: 72h 14 | sync-interval: 10m 15 | -------------------------------------------------------------------------------- /markdown/markdown.go: -------------------------------------------------------------------------------- 1 | package markdown 2 | 3 | import ( 4 | "html" 5 | "strings" 6 | 7 | gomarkdown "github.com/gomarkdown/markdown" 8 | gomarkdown_html "github.com/gomarkdown/markdown/html" 9 | gomarkdown_parser "github.com/gomarkdown/markdown/parser" 10 | "github.com/microcosm-cc/bluemonday" 11 | 12 | "github.com/mtlynch/screenjournal/v2/screenjournal" 13 | ) 14 | 15 | const SpoilersKeyword = "!spoilers" 16 | 17 | var ( 18 | untrustedRenderer *gomarkdown_html.Renderer 19 | trustedRenderer *gomarkdown_html.Renderer 20 | ) 21 | 22 | func init() { 23 | untrustedRenderer = gomarkdown_html.NewRenderer(gomarkdown_html.RendererOptions{Flags: gomarkdown_html.SkipHTML | gomarkdown_html.SkipImages}) 24 | trustedRenderer = gomarkdown_html.NewRenderer(gomarkdown_html.RendererOptions{Flags: gomarkdown_html.SkipHTML | gomarkdown_html.SkipImages}) 25 | } 26 | 27 | func RenderBlurb(blurb screenjournal.Blurb) string { 28 | return renderUntrusted(blurb.String()) 29 | } 30 | 31 | func RenderBlurbAsPlaintext(blurb screenjournal.Blurb) string { 32 | unspoiled, _, _ := splitSpoilers(blurb.String()) 33 | asHtml := renderUntrusted(unspoiled) 34 | plaintext := bluemonday.StrictPolicy().Sanitize(asHtml) 35 | 36 | // Decode HTML entities like ' back to characters 37 | plaintext = html.UnescapeString(plaintext) 38 | 39 | return strings.TrimSpace(plaintext) 40 | } 41 | 42 | func RenderComment(comment screenjournal.CommentText) string { 43 | return renderUntrusted(comment.String()) 44 | } 45 | 46 | func renderUntrusted(s string) string { 47 | renderMarkdown := func(markdown string) string { 48 | parser := gomarkdown_parser.NewWithExtensions(gomarkdown_parser.NoExtensions) 49 | trimmed := trimSpacesFromEachLine(markdown) 50 | asHtml := string(gomarkdown.ToHTML([]byte(trimmed), parser, untrustedRenderer)) 51 | return strings.TrimSpace(asHtml) 52 | } 53 | 54 | unspoiled, spoilers, hasSpoilers := strings.Cut(s, SpoilersKeyword) 55 | unspoiledRendered := renderMarkdown(unspoiled) 56 | if !hasSpoilers { 57 | return unspoiledRendered 58 | } 59 | 60 | return strings.Join([]string{ 61 | unspoiledRendered, 62 | `
`, 63 | renderMarkdown(spoilers), 64 | "
", 65 | }, "\n\n") 66 | } 67 | 68 | func RenderEmail(body screenjournal.EmailBodyMarkdown) string { 69 | parser := gomarkdown_parser.NewWithExtensions(gomarkdown_parser.Autolink) 70 | asHtml := string(gomarkdown.ToHTML([]byte(body.String()), parser, trustedRenderer)) 71 | 72 | return strings.TrimSpace(asHtml) 73 | } 74 | 75 | func trimSpacesFromEachLine(s string) string { 76 | lines := strings.Split(s, "\n") 77 | for i, line := range lines { 78 | lines[i] = strings.TrimSpace(line) 79 | } 80 | return strings.Join(lines, "\n") 81 | } 82 | 83 | func splitSpoilers(s string) (string, string, bool) { 84 | return strings.Cut(s, SpoilersKeyword) 85 | } 86 | -------------------------------------------------------------------------------- /metadata/metadata.go: -------------------------------------------------------------------------------- 1 | package metadata 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/mtlynch/screenjournal/v2/screenjournal" 7 | ) 8 | 9 | type ( 10 | SearchResult struct { 11 | TmdbID screenjournal.TmdbID 12 | Title screenjournal.MediaTitle 13 | ReleaseDate screenjournal.ReleaseDate 14 | PosterPath url.URL 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /metadata/tmdb/movies.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "log" 5 | "net/url" 6 | 7 | "github.com/mtlynch/screenjournal/v2/handlers/parse" 8 | "github.com/mtlynch/screenjournal/v2/screenjournal" 9 | ) 10 | 11 | func (f Finder) GetMovie(id screenjournal.TmdbID) (screenjournal.Movie, error) { 12 | m, err := f.tmdbAPI.GetMovieInfo(int(id.Int32()), map[string]string{}) 13 | if err != nil { 14 | return screenjournal.Movie{}, err 15 | } 16 | 17 | info := screenjournal.Movie{ 18 | TmdbID: id, 19 | } 20 | 21 | info.Title, err = parse.MediaTitle(m.Title) 22 | if err != nil { 23 | return screenjournal.Movie{}, err 24 | } 25 | 26 | if len(m.ImdbID) > 0 { 27 | imdbID, err := ParseImdbID(m.ImdbID) 28 | if err != nil { 29 | log.Printf("failed to parse IMDB ID (%s) from TMDB ID %v: %v", m.ImdbID, id, err) 30 | } else { 31 | info.ImdbID = imdbID 32 | } 33 | } 34 | 35 | if len(m.ReleaseDate) > 0 { 36 | rd, err := ParseReleaseDate(m.ReleaseDate) 37 | if err != nil { 38 | log.Printf("failed to parse release date (%s) from TMDB ID %v: %v", m.ReleaseDate, id, err) 39 | } else { 40 | info.ReleaseDate = rd 41 | } 42 | } 43 | 44 | if len(m.PosterPath) > 0 { 45 | pp, err := url.Parse(m.PosterPath) 46 | if err != nil { 47 | log.Printf("failed to parse poster path (%s) from TMDB ID %v: %v", m.PosterPath, id, err) 48 | } else { 49 | info.PosterPath = *pp 50 | } 51 | } 52 | 53 | return info, nil 54 | } 55 | -------------------------------------------------------------------------------- /metadata/tmdb/parse.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "time" 7 | 8 | "github.com/mtlynch/screenjournal/v2/screenjournal" 9 | ) 10 | 11 | var ( 12 | ErrInvalidImdbID = errors.New("invalid IMDB ID") 13 | ErrInvalidReleaseDate = errors.New("invalid release date") 14 | 15 | imdbIDPattern = regexp.MustCompile(`^tt[0-9]{7,8}$`) 16 | ) 17 | 18 | func ParseImdbID(raw string) (screenjournal.ImdbID, error) { 19 | if !imdbIDPattern.MatchString(raw) { 20 | return screenjournal.ImdbID(""), ErrInvalidImdbID 21 | } 22 | return screenjournal.ImdbID(raw), nil 23 | } 24 | 25 | func ParseReleaseDate(raw string) (screenjournal.ReleaseDate, error) { 26 | t, err := time.Parse(time.DateOnly, raw) 27 | if err != nil { 28 | return screenjournal.ReleaseDate{}, ErrInvalidReleaseDate 29 | } 30 | return screenjournal.ReleaseDate(t), nil 31 | } 32 | -------------------------------------------------------------------------------- /metadata/tmdb/parse_test.go: -------------------------------------------------------------------------------- 1 | package tmdb_test 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "testing" 7 | "time" 8 | 9 | "github.com/mtlynch/screenjournal/v2/handlers/parse" 10 | "github.com/mtlynch/screenjournal/v2/metadata/tmdb" 11 | "github.com/mtlynch/screenjournal/v2/screenjournal" 12 | ) 13 | 14 | func TestParseTmdbID(t *testing.T) { 15 | for _, tt := range []struct { 16 | description string 17 | in int 18 | id screenjournal.TmdbID 19 | err error 20 | }{ 21 | { 22 | "ID of 36955 is valid", 23 | 36955, 24 | screenjournal.TmdbID(36955), 25 | nil, 26 | }, 27 | { 28 | "ID of math.MaxInt32 is valid", 29 | math.MaxInt32, 30 | screenjournal.TmdbID(math.MaxInt32), 31 | nil, 32 | }, 33 | { 34 | "ID of math.MaxInt64 is invalid", 35 | math.MaxInt64, 36 | screenjournal.TmdbID(0), 37 | parse.ErrInvalidTmdbID, 38 | }, 39 | { 40 | "zero ID is invalid", 41 | 0, 42 | screenjournal.TmdbID(0), 43 | parse.ErrInvalidTmdbID, 44 | }, 45 | { 46 | "negative ID is invalid", 47 | -1, 48 | screenjournal.TmdbID(0), 49 | parse.ErrInvalidTmdbID, 50 | }, 51 | } { 52 | t.Run(fmt.Sprintf("%s [%d]", tt.description, tt.in), func(t *testing.T) { 53 | id, err := parse.TmdbID(tt.in) 54 | if got, want := err, tt.err; got != want { 55 | t.Fatalf("err=%v, want=%v", got, want) 56 | } 57 | if got, want := id.Int32(), tt.id.Int32(); got != want { 58 | t.Errorf("id=%d, want=%d", got, want) 59 | } 60 | }) 61 | } 62 | } 63 | 64 | func TestParseImdbID(t *testing.T) { 65 | for _, tt := range []struct { 66 | description string 67 | in string 68 | id screenjournal.ImdbID 69 | err error 70 | }{ 71 | { 72 | "ID with 7 digits is valid", 73 | "tt0079367", 74 | screenjournal.ImdbID("tt0079367"), 75 | nil, 76 | }, 77 | { 78 | "ID with 8 digits is valid", 79 | "tt14596320", 80 | screenjournal.ImdbID("tt14596320"), 81 | nil, 82 | }, 83 | { 84 | "empty string is invalid", 85 | "", 86 | screenjournal.ImdbID(""), 87 | tmdb.ErrInvalidImdbID, 88 | }, 89 | { 90 | "ID with missing prefix is invalid", 91 | "0079367", 92 | screenjournal.ImdbID(""), 93 | tmdb.ErrInvalidImdbID, 94 | }, 95 | { 96 | "ID with too few characters is invalid", 97 | "tt007936", 98 | screenjournal.ImdbID(""), 99 | tmdb.ErrInvalidImdbID, 100 | }, 101 | { 102 | "ID with too many characters is invalid", 103 | "tt012345678", 104 | screenjournal.ImdbID(""), 105 | tmdb.ErrInvalidImdbID, 106 | }, 107 | } { 108 | t.Run(fmt.Sprintf("%s [%s]", tt.description, tt.in), func(t *testing.T) { 109 | id, err := tmdb.ParseImdbID(tt.in) 110 | if got, want := err, tt.err; got != want { 111 | t.Fatalf("err=%v, want=%v", got, want) 112 | } 113 | if got, want := id.String(), tt.id.String(); got != want { 114 | t.Errorf("id=%s, want=%s", got, want) 115 | } 116 | }) 117 | } 118 | } 119 | 120 | func TestParseReleaseDate(t *testing.T) { 121 | for _, tt := range []struct { 122 | description string 123 | in string 124 | releaseDate screenjournal.ReleaseDate 125 | err error 126 | }{ 127 | { 128 | "standard release date is valid", 129 | "2022-07-15", 130 | screenjournal.ReleaseDate(time.Date(2022, time.July, 15, 0, 0, 0, 0, time.UTC)), 131 | nil, 132 | }, 133 | { 134 | "empty string is invalid", 135 | "", 136 | screenjournal.ReleaseDate{}, 137 | tmdb.ErrInvalidReleaseDate, 138 | }, 139 | } { 140 | t.Run(fmt.Sprintf("%s [%s]", tt.description, tt.in), func(t *testing.T) { 141 | rd, err := tmdb.ParseReleaseDate(tt.in) 142 | if got, want := err, tt.err; got != want { 143 | t.Fatalf("err=%v, want=%v", got, want) 144 | } 145 | if got, want := rd.Time(), tt.releaseDate.Time(); got != want { 146 | t.Errorf("releaseDate=%v, want=%v", got, want) 147 | } 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /metadata/tmdb/search.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "github.com/mtlynch/screenjournal/v2/handlers/parse" 5 | "github.com/mtlynch/screenjournal/v2/metadata" 6 | "github.com/mtlynch/screenjournal/v2/screenjournal" 7 | ) 8 | 9 | func (f Finder) SearchMovies(query screenjournal.SearchQuery) ([]metadata.SearchResult, error) { 10 | tmdbResults, err := f.tmdbAPI.SearchMovie(query.String(), map[string]string{ 11 | "include_adult": "false", 12 | }) 13 | if err != nil { 14 | return []metadata.SearchResult{}, err 15 | } 16 | 17 | matches := []metadata.SearchResult{} 18 | for _, match := range tmdbResults.Results { 19 | info := metadata.SearchResult{} 20 | 21 | info.TmdbID, err = parse.TmdbID(match.ID) 22 | if err != nil { 23 | return []metadata.SearchResult{}, err 24 | } 25 | 26 | info.Title, err = parse.MediaTitle(match.Title) 27 | if err != nil { 28 | return []metadata.SearchResult{}, err 29 | } 30 | 31 | if match.ReleaseDate == "" { 32 | continue 33 | } 34 | info.ReleaseDate, err = ParseReleaseDate(match.ReleaseDate) 35 | if err != nil { 36 | return []metadata.SearchResult{}, err 37 | } 38 | 39 | if match.PosterPath == "" { 40 | continue 41 | } 42 | info.PosterPath, err = parse.PosterPath(match.PosterPath) 43 | if err != nil { 44 | return []metadata.SearchResult{}, err 45 | } 46 | 47 | matches = append(matches, info) 48 | } 49 | 50 | return matches, nil 51 | } 52 | 53 | func (f Finder) SearchTvShows(query screenjournal.SearchQuery) ([]metadata.SearchResult, error) { 54 | tmdbResults, err := f.tmdbAPI.SearchTv(query.String(), map[string]string{ 55 | "include_adult": "false", 56 | }) 57 | if err != nil { 58 | return []metadata.SearchResult{}, err 59 | } 60 | 61 | matches := []metadata.SearchResult{} 62 | for _, match := range tmdbResults.Results { 63 | info := metadata.SearchResult{} 64 | 65 | info.TmdbID, err = parse.TmdbID(match.ID) 66 | if err != nil { 67 | return []metadata.SearchResult{}, err 68 | } 69 | 70 | info.Title, err = parse.MediaTitle(match.Name) 71 | if err != nil { 72 | return []metadata.SearchResult{}, err 73 | } 74 | 75 | if match.FirstAirDate == "" { 76 | continue 77 | } 78 | info.ReleaseDate, err = ParseReleaseDate(match.FirstAirDate) 79 | if err != nil { 80 | return []metadata.SearchResult{}, err 81 | } 82 | 83 | if match.PosterPath == "" { 84 | continue 85 | } 86 | info.PosterPath, err = parse.PosterPath(match.PosterPath) 87 | if err != nil { 88 | return []metadata.SearchResult{}, err 89 | } 90 | 91 | matches = append(matches, info) 92 | } 93 | 94 | return matches, nil 95 | } 96 | -------------------------------------------------------------------------------- /metadata/tmdb/tmdb.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | tmdbWrapper "github.com/ryanbradynd05/go-tmdb" 5 | ) 6 | 7 | type tmdbAPI interface { 8 | GetMovieInfo(int, map[string]string) (*tmdbWrapper.Movie, error) 9 | GetTvInfo(int, map[string]string) (*tmdbWrapper.TV, error) 10 | GetTvExternalIds(int, map[string]string) (*tmdbWrapper.TvExternalIds, error) 11 | SearchMovie(query string, options map[string]string) (*tmdbWrapper.MovieSearchResults, error) 12 | SearchTv(query string, options map[string]string) (*tmdbWrapper.TvSearchResults, error) 13 | } 14 | 15 | type Finder struct { 16 | tmdbAPI tmdbAPI 17 | } 18 | 19 | func New(apiKey string) (Finder, error) { 20 | tmbdAPI := tmdbWrapper.Init(tmdbWrapper.Config{ 21 | APIKey: apiKey, 22 | }) 23 | return NewWithAPI(tmbdAPI), nil 24 | } 25 | 26 | func NewWithAPI(api tmdbAPI) Finder { 27 | return Finder{ 28 | tmdbAPI: api, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /metadata/tmdb/tv_shows.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/mtlynch/screenjournal/v2/handlers/parse" 10 | "github.com/mtlynch/screenjournal/v2/screenjournal" 11 | ) 12 | 13 | func (f Finder) GetTvShow(id screenjournal.TmdbID) (screenjournal.TvShow, error) { 14 | m, err := f.tmdbAPI.GetTvInfo(int(id.Int32()), map[string]string{}) 15 | if err != nil { 16 | return screenjournal.TvShow{}, err 17 | } 18 | 19 | tvShow := screenjournal.TvShow{ 20 | TmdbID: id, 21 | } 22 | 23 | tvShow.Title, err = parse.MediaTitle(m.Name) 24 | if err != nil { 25 | return screenjournal.TvShow{}, err 26 | } 27 | 28 | imdbID, err := f.readImdbID(id) 29 | if err != nil { 30 | log.Printf("failed to read IMDB ID from TMDB ID %v: %v", id, err) 31 | } else { 32 | tvShow.ImdbID = imdbID 33 | } 34 | 35 | if len(m.FirstAirDate) > 0 { 36 | ad, err := ParseReleaseDate(m.FirstAirDate) 37 | if err != nil { 38 | log.Printf("failed to parse air date (%s) from TMDB ID %v: %v", m.FirstAirDate, id, err) 39 | } else { 40 | tvShow.AirDate = ad 41 | } 42 | } 43 | 44 | tvShow.SeasonCount = uint8(1) 45 | for _, s := range m.Seasons { 46 | // Sometimes specials are listed as Season 0. (e.g., Friends) 47 | if s.SeasonNumber == 0 { 48 | continue 49 | } 50 | if s.Name == "Specials" { 51 | continue 52 | } 53 | // Some shows list empty seasons (e.g. Nobody Wants This) 54 | if s.EpisodeCount == 0 { 55 | continue 56 | } 57 | hasAiredFn := func(airDateRaw string) bool { 58 | airDate, err := ParseReleaseDate(airDateRaw) 59 | if err != nil { 60 | return false 61 | } 62 | return time.Now().After(airDate.Time()) 63 | } 64 | if !hasAiredFn(s.AirDate) { 65 | continue 66 | } 67 | if s.SeasonNumber > int(tvShow.SeasonCount) { 68 | tvShow.SeasonCount = uint8(s.SeasonNumber) 69 | } 70 | } 71 | 72 | if len(m.PosterPath) > 0 { 73 | pp, err := url.Parse(m.PosterPath) 74 | if err != nil { 75 | log.Printf("failed to parse poster path (%s) from TMDB ID %v: %v", m.PosterPath, id, err) 76 | } else { 77 | tvShow.PosterPath = *pp 78 | } 79 | } 80 | 81 | return tvShow, nil 82 | } 83 | 84 | func (f Finder) readImdbID(id screenjournal.TmdbID) (screenjournal.ImdbID, error) { 85 | externalIDs, err := f.tmdbAPI.GetTvExternalIds(int(id.Int32()), map[string]string{}) 86 | if err != nil { 87 | return screenjournal.ImdbID(""), fmt.Errorf("failed to read external IDs: %v", err) 88 | } 89 | 90 | imdbID, err := ParseImdbID(externalIDs.ImdbID) 91 | if err != nil { 92 | return screenjournal.ImdbID(""), fmt.Errorf("failed to parse IMDB ID %v: %v", externalIDs.ImdbID, err) 93 | } 94 | 95 | return imdbID, nil 96 | } 97 | -------------------------------------------------------------------------------- /modd.conf: -------------------------------------------------------------------------------- 1 | **/*.html **/*.css **/*.go !**/*_test.go { 2 | daemon: ./dev-scripts/build-backend dev && ./bin/screenjournal-dev 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "screenjournal-dev", 3 | "version": "1.0.0", 4 | "description": "ScreenJournal dev tools", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/mtlynch/screenjournal.git" 8 | }, 9 | "author": "Michael Lynch", 10 | "license": "AGPL-3.0", 11 | "bugs": { 12 | "url": "https://github.com/mtlynch/screenjournal/issues" 13 | }, 14 | "devDependencies": { 15 | "@playwright/test": "1.40.0", 16 | "eslint": "^8.11.0", 17 | "eslint-plugin-playwright": "^0.11.2", 18 | "isomorphic-fetch": "^3.0.0", 19 | "prettier": "2.7.1", 20 | "prettier-plugin-go-template": "0.0.13" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | import { devices } from "@playwright/test"; 3 | 4 | const config: PlaywrightTestConfig = { 5 | testDir: "./e2e", 6 | timeout: 5 * 1000, 7 | expect: { 8 | timeout: 5 * 1000, 9 | }, 10 | fullyParallel: true, 11 | forbidOnly: !!process.env.CI, 12 | retries: 0, 13 | workers: 1, 14 | reporter: "html", 15 | globalSetup: require.resolve("./e2e/helpers/global-setup"), 16 | use: { 17 | baseURL: "http://localhost:6001", 18 | actionTimeout: 0, 19 | trace: "on", 20 | video: "on", 21 | }, 22 | 23 | projects: [ 24 | { 25 | name: "chromium", 26 | use: { 27 | ...devices["Desktop Chrome"], 28 | }, 29 | }, 30 | ], 31 | 32 | outputDir: "e2e-results/", 33 | 34 | webServer: { 35 | command: "PORT=6001 ./bin/screenjournal-dev", 36 | port: 6001, 37 | }, 38 | }; 39 | 40 | export default config; 41 | -------------------------------------------------------------------------------- /random/random.go: -------------------------------------------------------------------------------- 1 | package random 2 | 3 | import ( 4 | cryptrand "crypto/rand" 5 | "log" 6 | "math/rand" 7 | ) 8 | 9 | func String(n int, characters []rune) string { 10 | b := make([]rune, n) 11 | for i := range b { 12 | b[i] = characters[rand.Intn(len(characters))] 13 | } 14 | return string(b) 15 | } 16 | 17 | func Bytes(n int) []byte { 18 | b := make([]byte, n) 19 | if _, err := cryptrand.Read(b); err != nil { 20 | log.Fatalf("failed to generate random bytes: %v", err) 21 | } 22 | return b 23 | } 24 | -------------------------------------------------------------------------------- /screenjournal/comments.go: -------------------------------------------------------------------------------- 1 | package screenjournal 2 | 3 | import "strconv" 4 | 5 | type ( 6 | CommentID uint64 7 | CommentText string 8 | ) 9 | 10 | func (id CommentID) UInt64() uint64 { 11 | return uint64(id) 12 | } 13 | 14 | func (id CommentID) String() string { 15 | return strconv.FormatUint(id.UInt64(), 10) 16 | } 17 | 18 | func (ct CommentText) String() string { 19 | return string(ct) 20 | } 21 | -------------------------------------------------------------------------------- /screenjournal/email.go: -------------------------------------------------------------------------------- 1 | package screenjournal 2 | 3 | type EmailBodyMarkdown string 4 | 5 | func (m EmailBodyMarkdown) String() string { 6 | return string(m) 7 | } 8 | -------------------------------------------------------------------------------- /screenjournal/invites.go: -------------------------------------------------------------------------------- 1 | package screenjournal 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/mtlynch/screenjournal/v2/random" 7 | ) 8 | 9 | type ( 10 | Invitee string 11 | InviteCode string 12 | 13 | SignupInvitation struct { 14 | Invitee Invitee 15 | InviteCode InviteCode 16 | } 17 | ) 18 | 19 | const ( 20 | InviteCodeLength = 6 21 | ) 22 | 23 | var ( 24 | InviteePattern = regexp.MustCompile(`(?i)^[a-z0-9áàâüñçå\-\. ]{1,80}$`) 25 | 26 | // InviteCodeCharset contains the allowed characters for an invite code. It 27 | // includes alphanumeric characters with commonly-confused characters removed. 28 | InviteCodeCharset = []rune("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789") 29 | ) 30 | 31 | func (i Invitee) String() string { 32 | return string(i) 33 | } 34 | 35 | func (i Invitee) Empty() bool { 36 | return i.String() == "" 37 | } 38 | 39 | func (i Invitee) Equal(other Invitee) bool { 40 | return i.String() == other.String() 41 | } 42 | 43 | func NewInviteCode() InviteCode { 44 | return InviteCode(random.String(InviteCodeLength, InviteCodeCharset)) 45 | } 46 | 47 | func (ic InviteCode) String() string { 48 | return string(ic) 49 | } 50 | 51 | func (ic InviteCode) Empty() bool { 52 | return ic.String() == "" 53 | } 54 | 55 | func (ic InviteCode) Equal(other InviteCode) bool { 56 | return ic.String() == other.String() 57 | } 58 | 59 | func (si SignupInvitation) Empty() bool { 60 | return si.Invitee.Empty() 61 | } 62 | -------------------------------------------------------------------------------- /screenjournal/metadata.go: -------------------------------------------------------------------------------- 1 | package screenjournal 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | type ( 11 | // MovieID represents the ID for a movie in the local datastore. 12 | MovieID int64 13 | 14 | // TvShowID represents the ID for a TV show in the local datastore. 15 | TvShowID int64 16 | 17 | TmdbID int32 18 | ImdbID string 19 | 20 | ReleaseDate time.Time 21 | 22 | Movie struct { 23 | ID MovieID 24 | TmdbID TmdbID 25 | ImdbID ImdbID 26 | Title MediaTitle 27 | ReleaseDate ReleaseDate 28 | PosterPath url.URL 29 | } 30 | 31 | TvShowSeason uint8 32 | 33 | TvShow struct { 34 | ID TvShowID 35 | TmdbID TmdbID 36 | ImdbID ImdbID 37 | Title MediaTitle 38 | AirDate ReleaseDate 39 | SeasonCount uint8 40 | PosterPath url.URL 41 | } 42 | ) 43 | 44 | func (mid MovieID) IsZero() bool { 45 | return mid.Equal(MovieID(0)) 46 | } 47 | 48 | func (mid MovieID) Equal(o MovieID) bool { 49 | return mid.Int64() == o.Int64() 50 | } 51 | 52 | func (mid MovieID) Int64() int64 { 53 | return int64(mid) 54 | } 55 | 56 | func (mid MovieID) String() string { 57 | return strconv.FormatInt(mid.Int64(), 10) 58 | } 59 | 60 | func (tvID TvShowID) IsZero() bool { 61 | return tvID.Equal(TvShowID(0)) 62 | } 63 | 64 | func (tvID TvShowID) Equal(o TvShowID) bool { 65 | return tvID.Int64() == o.Int64() 66 | } 67 | 68 | func (tvID TvShowID) Int64() int64 { 69 | return int64(tvID) 70 | } 71 | 72 | func (tvID TvShowID) String() string { 73 | return strconv.FormatInt(tvID.Int64(), 10) 74 | } 75 | 76 | func (m TmdbID) Equal(o TmdbID) bool { 77 | return m.Int32() == o.Int32() 78 | } 79 | 80 | func (m TmdbID) Int32() int32 { 81 | return int32(m) 82 | } 83 | 84 | func (m TmdbID) String() string { 85 | return fmt.Sprintf("%d", m) 86 | } 87 | 88 | func (id ImdbID) String() string { 89 | return string(id) 90 | } 91 | 92 | func (rd ReleaseDate) Year() int { 93 | if rd.Time().IsZero() { 94 | return 0 95 | } 96 | return rd.Time().Year() 97 | } 98 | 99 | func (rd ReleaseDate) Time() time.Time { 100 | return time.Time(rd) 101 | } 102 | 103 | func (tvs TvShowSeason) UInt8() uint8 { 104 | return uint8(tvs) 105 | } 106 | 107 | func (tvs TvShowSeason) Equal(o TvShowSeason) bool { 108 | return tvs.UInt8() == o.UInt8() 109 | } 110 | -------------------------------------------------------------------------------- /screenjournal/notifications.go: -------------------------------------------------------------------------------- 1 | package screenjournal 2 | 3 | type NotificationPreferences struct { 4 | NewReviews bool 5 | AllNewComments bool 6 | } 7 | -------------------------------------------------------------------------------- /screenjournal/ordering.go: -------------------------------------------------------------------------------- 1 | package screenjournal 2 | 3 | type SortOrder string 4 | 5 | const ( 6 | ByRating SortOrder = "rating" 7 | ByWatchDate SortOrder = "watch-date" 8 | ) 9 | -------------------------------------------------------------------------------- /screenjournal/review.go: -------------------------------------------------------------------------------- 1 | package screenjournal 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | type ( 10 | ReviewID uint64 11 | MediaType string 12 | MediaTitle string 13 | Blurb string 14 | WatchDate time.Time 15 | 16 | Rating struct { 17 | Value *uint8 18 | } 19 | 20 | Review struct { 21 | ID ReviewID 22 | Owner Username 23 | Rating Rating 24 | Blurb Blurb 25 | Watched WatchDate 26 | Created time.Time 27 | Modified time.Time 28 | Movie Movie 29 | TvShow TvShow 30 | TvShowSeason TvShowSeason 31 | Comments []ReviewComment 32 | } 33 | 34 | ReviewComment struct { 35 | ID CommentID 36 | Owner Username 37 | CommentText CommentText 38 | Created time.Time 39 | Modified time.Time 40 | Review Review 41 | } 42 | ) 43 | 44 | const ( 45 | MediaTypeMovie = MediaType("movie") 46 | MediaTypeTvShow = MediaType("tv-show") 47 | ) 48 | 49 | func (id ReviewID) UInt64() uint64 { 50 | return uint64(id) 51 | } 52 | 53 | func (id ReviewID) String() string { 54 | return strconv.FormatUint(id.UInt64(), 10) 55 | } 56 | 57 | func (id ReviewID) IsZero() bool { 58 | return id == ReviewID(0) 59 | } 60 | 61 | func (mt MediaType) IsEmpty() bool { 62 | return mt.String() == "" 63 | } 64 | 65 | func (mt MediaType) Equal(o MediaType) bool { 66 | return mt.String() == o.String() 67 | } 68 | 69 | func (mt MediaType) String() string { 70 | return string(mt) 71 | } 72 | 73 | func (mt MediaTitle) String() string { 74 | return string(mt) 75 | } 76 | 77 | func (r Rating) UInt8() uint8 { 78 | if r.IsNil() { 79 | return 0 80 | } 81 | return *r.Value 82 | } 83 | 84 | func (r Rating) String() string { 85 | if r.IsNil() { 86 | return "nil" 87 | } 88 | return fmt.Sprintf("%d", r.UInt8()) 89 | } 90 | 91 | func NewRating(val uint8) Rating { 92 | return Rating{Value: &val} 93 | } 94 | 95 | func (r Rating) IsNil() bool { 96 | return r.Value == nil 97 | } 98 | 99 | func (r Rating) Equal(other Rating) bool { 100 | if r.IsNil() && other.IsNil() { 101 | return true 102 | } 103 | if r.IsNil() || other.IsNil() { 104 | return false 105 | } 106 | return *r.Value == *other.Value 107 | } 108 | 109 | func (wd WatchDate) Time() time.Time { 110 | return time.Time(wd) 111 | } 112 | 113 | func (b Blurb) String() string { 114 | return string(b) 115 | } 116 | 117 | func (r Review) MediaType() MediaType { 118 | if !r.Movie.ID.IsZero() { 119 | return MediaTypeMovie 120 | } 121 | return MediaTypeTvShow 122 | } 123 | -------------------------------------------------------------------------------- /screenjournal/search.go: -------------------------------------------------------------------------------- 1 | package screenjournal 2 | 3 | type SearchQuery string 4 | 5 | func (q SearchQuery) String() string { 6 | return string(q) 7 | } 8 | -------------------------------------------------------------------------------- /screenjournal/user.go: -------------------------------------------------------------------------------- 1 | package screenjournal 2 | 3 | import "time" 4 | 5 | type ( 6 | Email string 7 | Username string 8 | Password string 9 | 10 | // EmailSubscriber represents a user or entity that subscribes to events via 11 | // email notifications. 12 | EmailSubscriber struct { 13 | Username Username 14 | Email Email 15 | } 16 | 17 | PasswordHash []byte 18 | 19 | User struct { 20 | IsAdmin bool 21 | Username Username 22 | Email Email 23 | PasswordHash PasswordHash 24 | } 25 | 26 | UserPublicMeta struct { 27 | Username Username 28 | JoinDate time.Time 29 | ReviewCount uint 30 | } 31 | ) 32 | 33 | func (e Email) String() string { 34 | return string(e) 35 | } 36 | 37 | func (u Username) String() string { 38 | return string(u) 39 | } 40 | 41 | func (u Username) Equal(o Username) bool { 42 | return u.String() == o.String() 43 | } 44 | 45 | func (pw Password) String() string { 46 | return string(pw) 47 | } 48 | 49 | // Equal returns true if two passwords match. Only use this in testing, never as 50 | // a way of authenticating actual user passwords. 51 | func (pw Password) Equal(o Password) bool { 52 | return pw.String() == o.String() 53 | } 54 | 55 | func (ph PasswordHash) Bytes() []byte { 56 | return []byte(ph) 57 | } 58 | 59 | func (u User) IsEmpty() bool { 60 | return u.Username == "" 61 | } 62 | -------------------------------------------------------------------------------- /store/sqlite/invites.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "time" 7 | 8 | "github.com/mtlynch/screenjournal/v2/screenjournal" 9 | ) 10 | 11 | func (s Store) InsertSignupInvitation(invite screenjournal.SignupInvitation) error { 12 | log.Printf("inserting new signup invite code for %s: %v", invite.Invitee, invite.InviteCode) 13 | 14 | now := time.Now() 15 | 16 | if _, err := s.ctx.Exec(` 17 | INSERT INTO 18 | invites 19 | ( 20 | invitee, 21 | code, 22 | created_time 23 | ) 24 | VALUES ( 25 | :invitee, :code, :created_time 26 | ) 27 | `, 28 | sql.Named("invitee", invite.Invitee), 29 | sql.Named("code", invite.InviteCode), 30 | sql.Named("created_time", formatTime(now))); err != nil { 31 | return err 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func (s Store) ReadSignupInvitation(code screenjournal.InviteCode) (screenjournal.SignupInvitation, error) { 38 | var invitee string 39 | if err := s.ctx.QueryRow(` 40 | SELECT 41 | invitee 42 | FROM 43 | invites 44 | WHERE 45 | code = :code`, sql.Named("code", code)).Scan(&invitee); err != nil { 46 | return screenjournal.SignupInvitation{}, err 47 | } 48 | 49 | return screenjournal.SignupInvitation{ 50 | Invitee: screenjournal.Invitee(invitee), 51 | InviteCode: code, 52 | }, nil 53 | } 54 | 55 | func (s Store) ReadSignupInvitations() ([]screenjournal.SignupInvitation, error) { 56 | rows, err := s.ctx.Query(` 57 | SELECT 58 | invitee, 59 | code 60 | FROM 61 | invites 62 | ORDER BY 63 | created_time DESC`) 64 | if err != nil { 65 | return []screenjournal.SignupInvitation{}, err 66 | } 67 | 68 | invites := []screenjournal.SignupInvitation{} 69 | for rows.Next() { 70 | var inviteeRaw string 71 | var inviteCodeRaw string 72 | if err := rows.Scan(&inviteeRaw, &inviteCodeRaw); err != nil { 73 | return []screenjournal.SignupInvitation{}, err 74 | } 75 | 76 | invites = append(invites, screenjournal.SignupInvitation{ 77 | Invitee: screenjournal.Invitee(inviteeRaw), 78 | InviteCode: screenjournal.InviteCode(inviteCodeRaw), 79 | }) 80 | } 81 | 82 | return invites, nil 83 | } 84 | 85 | func (s Store) DeleteSignupInvitation(code screenjournal.InviteCode) error { 86 | log.Printf("deleting signup code: %s", code) 87 | _, err := s.ctx.Exec(`DELETE FROM invites WHERE code = :code`, sql.Named("code", code.String())) 88 | if err != nil { 89 | return err 90 | } 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /store/sqlite/litestream.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import "log" 4 | 5 | func (s Store) optimizeForLitestream() { 6 | if _, err := s.ctx.Exec(` 7 | -- Apply Litestream recommendations: https://litestream.io/tips/ 8 | PRAGMA busy_timeout = 5000; 9 | PRAGMA synchronous = NORMAL; 10 | PRAGMA wal_autocheckpoint = 0; 11 | `); err != nil { 12 | log.Fatalf("failed to set Litestream compatibility pragmas: %v", err) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /store/sqlite/migrations.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "embed" 7 | "fmt" 8 | "log" 9 | "path" 10 | "sort" 11 | "strconv" 12 | ) 13 | 14 | type dbMigration struct { 15 | version int 16 | query string 17 | } 18 | 19 | //go:embed migrations/*.sql 20 | var migrationsFs embed.FS 21 | 22 | func (s Store) applyMigrations() { 23 | var version int 24 | if err := s.ctx.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { 25 | log.Fatalf("failed to get user_version: %v", err) 26 | } 27 | 28 | migrations, err := loadMigrations() 29 | if err != nil { 30 | log.Fatalf("error loading database migrations: %v", err) 31 | } 32 | 33 | log.Printf("migration counter: %d/%d", version, len(migrations)) 34 | 35 | for _, migration := range migrations { 36 | if migration.version <= version { 37 | continue 38 | } 39 | tx, err := s.ctx.BeginTx(context.Background(), nil) 40 | if err != nil { 41 | log.Fatalf("failed to create migration transaction %d: %v", migration.version, err) 42 | } 43 | 44 | defer func() { 45 | if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { 46 | log.Printf("failed to rollback migration %d: %v", migration.version, err) 47 | } 48 | }() 49 | 50 | _, err = tx.Exec(migration.query) 51 | if err != nil { 52 | log.Fatalf("failed to perform DB migration %d: %v", migration.version, err) 53 | } 54 | 55 | _, err = tx.Exec(fmt.Sprintf(`pragma user_version=%d`, migration.version)) 56 | if err != nil { 57 | log.Fatalf("failed to update DB version to %d: %v", migration.version, err) 58 | } 59 | 60 | if err = tx.Commit(); err != nil { 61 | log.Fatalf("failed to commit migration %d: %v", migration.version, err) 62 | } 63 | 64 | log.Printf("migration counter: %d/%d", migration.version, len(migrations)) 65 | } 66 | } 67 | 68 | func loadMigrations() ([]dbMigration, error) { 69 | migrations := []dbMigration{} 70 | 71 | migrationsDir := "migrations" 72 | 73 | entries, err := migrationsFs.ReadDir(migrationsDir) 74 | if err != nil { 75 | return []dbMigration{}, err 76 | } 77 | 78 | for _, entry := range entries { 79 | if entry.IsDir() { 80 | continue 81 | } 82 | 83 | version := migrationVersionFromFilename(entry.Name()) 84 | 85 | query, err := migrationsFs.ReadFile(path.Join(migrationsDir, entry.Name())) 86 | if err != nil { 87 | return []dbMigration{}, err 88 | } 89 | 90 | migrations = append(migrations, dbMigration{version, string(query)}) 91 | } 92 | sort.Slice(migrations, func(i, j int) bool { 93 | return migrations[i].version < migrations[j].version 94 | }) 95 | 96 | return migrations, nil 97 | } 98 | 99 | func migrationVersionFromFilename(filename string) int { 100 | version, err := strconv.ParseInt(filename[:3], 10, 32) 101 | if err != nil { 102 | log.Fatalf("invalid migration number in filename: %v", filename) 103 | } 104 | 105 | return int(version) 106 | } 107 | -------------------------------------------------------------------------------- /store/sqlite/migrations/001-reviews-create.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE reviews ( 2 | id INTEGER PRIMARY KEY, 3 | review_owner TEXT, 4 | title TEXT, 5 | rating INTEGER, 6 | blurb TEXT, 7 | watched_date TEXT, 8 | created_time TEXT, 9 | last_modified_time TEXT 10 | ); 11 | -------------------------------------------------------------------------------- /store/sqlite/migrations/002-movies-create.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE movies ( 2 | id INTEGER PRIMARY KEY, 3 | tmdb_id INTEGER UNIQUE, 4 | imdb_id TEXT, 5 | title TEXT NOT NULL, 6 | release_date TEXT, 7 | poster_path TEXT, 8 | backdrop_path TEXT, 9 | summary TEXT 10 | ); 11 | 12 | -- Move movie titles to movies table. 13 | INSERT INTO movies 14 | ( 15 | title 16 | ) 17 | SELECT title 18 | FROM 19 | reviews; 20 | 21 | ALTER TABLE reviews 22 | ADD COLUMN movie_id 23 | INTEGER; 24 | 25 | -- Make reviews table reference movies table by ID. 26 | UPDATE reviews SET movie_id = ( 27 | SELECT movies.id FROM movies 28 | WHERE movies.title = reviews.title 29 | ); 30 | 31 | -- Re-do reviews table to add foreign key constraint to movie_id and drop title 32 | -- column. 33 | CREATE TABLE reviews2 ( 34 | id INTEGER PRIMARY KEY, 35 | movie_id INTEGER, 36 | review_owner TEXT, 37 | rating INTEGER, 38 | blurb TEXT, 39 | watched_date TEXT, 40 | created_time TEXT, 41 | last_modified_time TEXT, 42 | FOREIGN KEY (movie_id) REFERENCES movies (id) 43 | ); 44 | 45 | INSERT INTO reviews2 46 | SELECT 47 | id, 48 | movie_id, 49 | review_owner, 50 | rating, 51 | blurb, 52 | watched_date, 53 | created_time, 54 | last_modified_time 55 | FROM 56 | reviews; 57 | 58 | DROP TABLE reviews; 59 | 60 | ALTER TABLE reviews2 61 | RENAME TO reviews; 62 | -------------------------------------------------------------------------------- /store/sqlite/migrations/003-users-create.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id INTEGER PRIMARY KEY, 3 | username TEXT NOT NULL UNIQUE, 4 | is_admin INTEGER, 5 | email TEXT NOT NULL UNIQUE, 6 | password_hash TEXT NOT NULL, 7 | created_time TEXT NOT NULL, 8 | last_modified_time TEXT NOT NULL 9 | ); 10 | 11 | -- Re-do reviews table to add foreign key constraint to review_owner column. 12 | CREATE TABLE reviews2 ( 13 | id INTEGER PRIMARY KEY, 14 | movie_id INTEGER, 15 | review_owner TEXT, 16 | rating INTEGER, 17 | blurb TEXT, 18 | watched_date TEXT, 19 | created_time TEXT, 20 | last_modified_time TEXT, 21 | FOREIGN KEY (movie_id) REFERENCES movies (id), 22 | FOREIGN KEY (review_owner) REFERENCES users (username) 23 | ); 24 | 25 | INSERT INTO reviews2 26 | SELECT 27 | id, 28 | movie_id, 29 | review_owner, 30 | rating, 31 | blurb, 32 | watched_date, 33 | created_time, 34 | last_modified_time 35 | FROM 36 | reviews; 37 | 38 | DROP TABLE reviews; 39 | 40 | ALTER TABLE reviews2 41 | RENAME TO reviews; 42 | -------------------------------------------------------------------------------- /store/sqlite/migrations/004-invites-create.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE invites ( 2 | id INTEGER PRIMARY KEY, 3 | invitee TEXT NOT NULL, 4 | code TEXT NOT NULL UNIQUE, 5 | created_time TEXT NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /store/sqlite/migrations/005-adjust-ratings.sql: -------------------------------------------------------------------------------- 1 | -- Change ratings from being out of 10 to out of 5. 2 | UPDATE reviews 3 | SET rating = rating / 2; 4 | -------------------------------------------------------------------------------- /store/sqlite/migrations/006-notifications-create.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE notification_preferences ( 2 | username TEXT PRIMARY KEY, 3 | new_reviews INTEGER, 4 | FOREIGN KEY (username) REFERENCES users (username) 5 | ); 6 | 7 | INSERT INTO notification_preferences ( 8 | username 9 | ) 10 | SELECT username 11 | FROM 12 | users; 13 | 14 | -- Subscribe everyone to new review notifications by default. 15 | UPDATE notification_preferences 16 | SET new_reviews = 1; 17 | -------------------------------------------------------------------------------- /store/sqlite/migrations/007-comments-create.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE review_comments ( 2 | id INTEGER PRIMARY KEY, 3 | review_id INTEGER, 4 | comment_owner TEXT, 5 | comment_text TEXT, 6 | created_time TEXT, 7 | last_modified_time TEXT, 8 | FOREIGN KEY (review_id) REFERENCES reviews (id), 9 | FOREIGN KEY (comment_owner) REFERENCES users (username) 10 | ); 11 | -------------------------------------------------------------------------------- /store/sqlite/migrations/008-adjust-notifications-add-comments.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE notification_preferences ADD COLUMN all_new_comments INTEGER; 2 | ALTER TABLE notification_preferences ADD COLUMN comments_on_my_reviews INTEGER; 3 | 4 | -- Subscribe everyone to new review notifications by default. 5 | UPDATE notification_preferences 6 | SET 7 | all_new_comments = 1, 8 | comments_on_my_reviews = 1; 9 | -------------------------------------------------------------------------------- /store/sqlite/migrations/009-ratings-out-of-10.sql: -------------------------------------------------------------------------------- 1 | -- Change ratings from being out of 5 to out of 10. 2 | -- This undoes migration 005. 3 | UPDATE reviews 4 | SET rating = rating * 2; 5 | -------------------------------------------------------------------------------- /store/sqlite/migrations/010-tv-shows-create-.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tv_shows ( 2 | id INTEGER PRIMARY KEY, 3 | tmdb_id INTEGER UNIQUE, 4 | imdb_id TEXT, 5 | title TEXT NOT NULL, 6 | first_air_date TEXT, 7 | poster_path TEXT, 8 | backdrop_path TEXT, 9 | summary TEXT 10 | ); 11 | 12 | 13 | -- Re-do reviews table to add tv_show_id and tv_show_season columns. 14 | CREATE TABLE reviews2 ( 15 | id INTEGER PRIMARY KEY, 16 | movie_id INTEGER, 17 | tv_show_id INTEGER, 18 | tv_show_season INTEGER, 19 | review_owner TEXT, 20 | rating INTEGER, 21 | blurb TEXT, 22 | watched_date TEXT, 23 | created_time TEXT, 24 | last_modified_time TEXT, 25 | FOREIGN KEY (movie_id) REFERENCES movies (id), 26 | FOREIGN KEY (tv_show_id) REFERENCES tv_shows (id), 27 | FOREIGN KEY (review_owner) REFERENCES users (username) 28 | ); 29 | 30 | INSERT INTO reviews2 31 | SELECT 32 | id, 33 | movie_id, 34 | NULL AS tv_show_id, 35 | NULL AS tv_show_season, 36 | review_owner, 37 | rating, 38 | blurb, 39 | watched_date, 40 | created_time, 41 | last_modified_time 42 | FROM 43 | reviews; 44 | 45 | DROP TABLE reviews; 46 | 47 | ALTER TABLE reviews2 48 | RENAME TO reviews; 49 | -------------------------------------------------------------------------------- /store/sqlite/migrations/012-nullable-ratings.sql: -------------------------------------------------------------------------------- 1 | -- Make the rating column nullable to support reviews without ratings 2 | PRAGMA foreign_keys = OFF; 3 | 4 | -- Create a new reviews table with the rating column as nullable 5 | CREATE TABLE reviews_new ( 6 | id INTEGER PRIMARY KEY, 7 | review_owner TEXT NOT NULL, 8 | movie_id INTEGER, 9 | tv_show_id INTEGER, 10 | tv_show_season INTEGER, 11 | rating INTEGER, -- Now nullable 12 | blurb TEXT NOT NULL, 13 | watched_date TEXT NOT NULL, 14 | created_time TEXT NOT NULL, 15 | last_modified_time TEXT NOT NULL, 16 | FOREIGN KEY (review_owner) REFERENCES users (username), 17 | FOREIGN KEY (movie_id) REFERENCES movies (id), 18 | FOREIGN KEY (tv_show_id) REFERENCES tv_shows (id), 19 | CHECK ( 20 | (movie_id IS NULL AND tv_show_id IS NOT NULL) 21 | OR (movie_id IS NOT NULL AND tv_show_id IS NULL) 22 | ) 23 | ); 24 | 25 | -- Create a new review_comments table that references the new reviews table 26 | CREATE TABLE review_comments_new ( 27 | id INTEGER PRIMARY KEY, 28 | review_id INTEGER NOT NULL, 29 | comment_owner TEXT NOT NULL, 30 | comment_text TEXT NOT NULL CHECK (length(comment_text) > 0), 31 | created_time TEXT NOT NULL CHECK ( 32 | datetime(created_time) IS NOT NULL 33 | AND datetime(created_time) >= datetime('2020-01-01') 34 | ), 35 | last_modified_time TEXT NOT NULL CHECK ( 36 | datetime(last_modified_time) IS NOT NULL 37 | AND datetime(last_modified_time) >= datetime('2020-01-01') 38 | ), 39 | FOREIGN KEY (review_id) REFERENCES reviews_new (id), 40 | FOREIGN KEY (comment_owner) REFERENCES users (username) 41 | ) STRICT; 42 | 43 | -- Copy all data from the old reviews table to the new table 44 | INSERT INTO reviews_new 45 | SELECT 46 | id, 47 | review_owner, 48 | movie_id, 49 | tv_show_id, 50 | tv_show_season, 51 | rating, 52 | blurb, 53 | watched_date, 54 | created_time, 55 | last_modified_time 56 | FROM reviews; 57 | 58 | -- Copy all data from the old review_comments table to the new table 59 | INSERT INTO review_comments_new 60 | SELECT 61 | id, 62 | review_id, 63 | comment_owner, 64 | comment_text, 65 | created_time, 66 | last_modified_time 67 | FROM review_comments; 68 | 69 | -- Drop the old tables (order matters - drop child tables first) 70 | DROP TABLE review_comments; 71 | DROP TABLE reviews; 72 | 73 | -- Rename the new tables to the original names 74 | ALTER TABLE reviews_new RENAME TO reviews; 75 | ALTER TABLE review_comments_new RENAME TO review_comments; 76 | 77 | -- Create indexes for the new tables 78 | CREATE INDEX idx_reviews_owner ON reviews (review_owner); 79 | CREATE INDEX idx_reviews_movie_id ON reviews (movie_id); 80 | CREATE INDEX idx_reviews_tv_show_id ON reviews (tv_show_id); 81 | CREATE INDEX idx_review_comments_review_id ON review_comments (review_id); 82 | 83 | PRAGMA foreign_keys = ON; 84 | -------------------------------------------------------------------------------- /store/sqlite/movies.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "net/url" 7 | 8 | "github.com/mtlynch/screenjournal/v2/screenjournal" 9 | "github.com/mtlynch/screenjournal/v2/store" 10 | ) 11 | 12 | func (s Store) ReadMovie(id screenjournal.MovieID) (screenjournal.Movie, error) { 13 | row := s.ctx.QueryRow(` 14 | SELECT 15 | id, 16 | tmdb_id, 17 | imdb_id, 18 | title, 19 | release_date, 20 | poster_path 21 | FROM 22 | movies 23 | WHERE 24 | id = :id`, sql.Named("id", id.Int64())) 25 | 26 | return movieFromRow(row) 27 | } 28 | 29 | func (s Store) ReadMovieByTmdbID(tmdbID screenjournal.TmdbID) (screenjournal.Movie, error) { 30 | row := s.ctx.QueryRow(` 31 | SELECT 32 | id, 33 | tmdb_id, 34 | imdb_id, 35 | title, 36 | release_date, 37 | poster_path 38 | FROM 39 | movies 40 | WHERE 41 | tmdb_id = :tmdb_id`, sql.Named("tmdb_id", tmdbID.Int32())) 42 | 43 | return movieFromRow(row) 44 | } 45 | 46 | func (s Store) InsertMovie(m screenjournal.Movie) (screenjournal.MovieID, error) { 47 | log.Printf("inserting new movie %s", m.Title) 48 | 49 | res, err := s.ctx.Exec(` 50 | INSERT INTO 51 | movies 52 | ( 53 | tmdb_id, 54 | imdb_id, 55 | title, 56 | release_date, 57 | poster_path 58 | ) 59 | VALUES ( 60 | :tmdb_id, :imdb_id, :title, :release_date, :poster_path 61 | )`, 62 | sql.Named("tmdb_id", m.TmdbID), 63 | sql.Named("imdb_id", m.ImdbID), 64 | sql.Named("title", m.Title), 65 | sql.Named("release_date", formatReleaseDate(m.ReleaseDate)), 66 | sql.Named("poster_path", m.PosterPath.String()), 67 | ) 68 | if err != nil { 69 | return screenjournal.MovieID(0), err 70 | } 71 | 72 | lastID, err := res.LastInsertId() 73 | if err != nil { 74 | return screenjournal.MovieID(0), err 75 | } 76 | 77 | return screenjournal.MovieID(lastID), nil 78 | } 79 | 80 | func (s Store) UpdateMovie(m screenjournal.Movie) error { 81 | log.Printf("updating movie information for %s (id=%v)", m.Title, m.ID) 82 | 83 | if _, err := s.ctx.Exec(` 84 | UPDATE movies 85 | SET 86 | title = :title, 87 | imdb_id = :imdb_id, 88 | release_date = :release_date, 89 | poster_path = :poster_path 90 | WHERE 91 | id = :id`, 92 | sql.Named("title", m.Title), 93 | sql.Named("imdb_id", m.ImdbID), 94 | sql.Named("release_date", formatReleaseDate(m.ReleaseDate)), 95 | sql.Named("poster_path", m.PosterPath.String()), 96 | sql.Named("id", m.ID.Int64())); err != nil { 97 | return err 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func movieFromRow(row rowScanner) (screenjournal.Movie, error) { 104 | var id int 105 | var tmdbID int 106 | var imdbIDRaw *string 107 | var title string 108 | var releaseDateRaw *string 109 | var posterPathRaw *string 110 | 111 | err := row.Scan(&id, &tmdbID, &imdbIDRaw, &title, &releaseDateRaw, &posterPathRaw) 112 | if err == sql.ErrNoRows { 113 | return screenjournal.Movie{}, store.ErrMovieNotFound 114 | } else if err != nil { 115 | return screenjournal.Movie{}, err 116 | } 117 | 118 | var imdbID screenjournal.ImdbID 119 | if imdbIDRaw != nil { 120 | imdbID = screenjournal.ImdbID(*imdbIDRaw) 121 | } 122 | 123 | var releaseDate screenjournal.ReleaseDate 124 | if releaseDateRaw != nil { 125 | rd, err := parseDatetime(*releaseDateRaw) 126 | if err != nil { 127 | log.Printf("failed to parse release date %s: %v", *releaseDateRaw, err) 128 | } else { 129 | releaseDate = screenjournal.ReleaseDate(rd) 130 | } 131 | } 132 | 133 | var posterPath url.URL 134 | if posterPathRaw != nil { 135 | pp, err := url.Parse(*posterPathRaw) 136 | if err != nil { 137 | log.Printf("failed to parse poster path: %s", *posterPathRaw) 138 | } else { 139 | posterPath = *pp 140 | } 141 | } 142 | 143 | return screenjournal.Movie{ 144 | ID: screenjournal.MovieID(id), 145 | TmdbID: screenjournal.TmdbID(tmdbID), 146 | ImdbID: imdbID, 147 | Title: screenjournal.MediaTitle(title), 148 | ReleaseDate: releaseDate, 149 | PosterPath: posterPath, 150 | }, nil 151 | } 152 | -------------------------------------------------------------------------------- /store/sqlite/notifications.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | 7 | "github.com/mtlynch/screenjournal/v2/screenjournal" 8 | ) 9 | 10 | func (s Store) ReadReviewSubscribers() ([]screenjournal.EmailSubscriber, error) { 11 | rows, err := s.ctx.Query(` 12 | SELECT 13 | users.username AS username, 14 | users.email AS email 15 | FROM 16 | users, notification_preferences 17 | WHERE 18 | users.username = notification_preferences.username AND 19 | notification_preferences.new_reviews = 1`) 20 | if err != nil { 21 | if err == sql.ErrNoRows { 22 | return []screenjournal.EmailSubscriber{}, nil 23 | } 24 | return []screenjournal.EmailSubscriber{}, err 25 | } 26 | 27 | subscribers := []screenjournal.EmailSubscriber{} 28 | for rows.Next() { 29 | subscriber, err := emailSubscriberFromRow(rows) 30 | if err != nil { 31 | return []screenjournal.EmailSubscriber{}, err 32 | } 33 | subscribers = append(subscribers, subscriber) 34 | } 35 | return subscribers, nil 36 | } 37 | 38 | func (s Store) ReadCommentSubscribers() ([]screenjournal.EmailSubscriber, error) { 39 | rows, err := s.ctx.Query(` 40 | SELECT 41 | users.username AS username, 42 | users.email AS email 43 | FROM 44 | users, notification_preferences 45 | WHERE 46 | users.username = notification_preferences.username AND 47 | notification_preferences.all_new_comments = 1`) 48 | if err != nil { 49 | if err == sql.ErrNoRows { 50 | return []screenjournal.EmailSubscriber{}, nil 51 | } 52 | return []screenjournal.EmailSubscriber{}, err 53 | } 54 | 55 | subscribers := []screenjournal.EmailSubscriber{} 56 | for rows.Next() { 57 | subscriber, err := emailSubscriberFromRow(rows) 58 | if err != nil { 59 | return []screenjournal.EmailSubscriber{}, err 60 | } 61 | subscribers = append(subscribers, subscriber) 62 | } 63 | return subscribers, nil 64 | } 65 | 66 | func (s Store) ReadNotificationPreferences(username screenjournal.Username) (screenjournal.NotificationPreferences, error) { 67 | var newReviews bool 68 | var allNewComments bool 69 | err := s.ctx.QueryRow(` 70 | SELECT 71 | new_reviews, 72 | all_new_comments 73 | FROM 74 | notification_preferences 75 | WHERE 76 | username = :username`, sql.Named("username", username.String())).Scan(&newReviews, &allNewComments) 77 | if err != nil { 78 | return screenjournal.NotificationPreferences{}, err 79 | } 80 | 81 | return screenjournal.NotificationPreferences{ 82 | NewReviews: newReviews, 83 | AllNewComments: allNewComments, 84 | }, nil 85 | } 86 | 87 | func (s Store) UpdateNotificationPreferences(username screenjournal.Username, prefs screenjournal.NotificationPreferences) error { 88 | log.Printf("updating notifications preferences for %s: newReviews=%v, allNewComments=%v", username, prefs.NewReviews, prefs.AllNewComments) 89 | if _, err := s.ctx.Exec(` 90 | UPDATE notification_preferences 91 | SET 92 | new_reviews = :new_reviews, 93 | all_new_comments = :all_new_comments 94 | WHERE 95 | username = :username`, 96 | sql.Named("new_reviews", prefs.NewReviews), 97 | sql.Named("all_new_comments", prefs.AllNewComments), 98 | sql.Named("username", username)); err != nil { 99 | return err 100 | } 101 | 102 | return nil 103 | } 104 | 105 | func emailSubscriberFromRow(row rowScanner) (screenjournal.EmailSubscriber, error) { 106 | var username string 107 | var email string 108 | if err := row.Scan(&username, &email); err != nil { 109 | return screenjournal.EmailSubscriber{}, err 110 | } 111 | return screenjournal.EmailSubscriber{ 112 | Username: screenjournal.Username(username), 113 | Email: screenjournal.Email(email), 114 | }, nil 115 | } 116 | -------------------------------------------------------------------------------- /store/sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "time" 7 | 8 | "github.com/ncruces/go-sqlite3/driver" 9 | _ "github.com/ncruces/go-sqlite3/embed" 10 | 11 | "github.com/mtlynch/screenjournal/v2/screenjournal" 12 | ) 13 | 14 | const ( 15 | timeFormat = time.RFC3339 16 | ) 17 | 18 | type ( 19 | Store struct { 20 | ctx *sql.DB 21 | } 22 | 23 | rowScanner interface { 24 | Scan(...interface{}) error 25 | } 26 | ) 27 | 28 | func New(path string, optimizeForLitestream bool) Store { 29 | log.Printf("reading DB from %s", path) 30 | ctx, err := driver.Open(path) 31 | if err != nil { 32 | log.Fatalln(err) 33 | } 34 | 35 | if _, err := ctx.Exec(` 36 | PRAGMA temp_store = FILE; 37 | PRAGMA journal_mode = WAL; 38 | `); err != nil { 39 | log.Fatalf("failed to set pragmas: %v", err) 40 | } 41 | 42 | store := Store{ctx: ctx} 43 | if optimizeForLitestream { 44 | store.optimizeForLitestream() 45 | } 46 | 47 | store.applyMigrations() 48 | 49 | return store 50 | } 51 | 52 | func parseDatetime(s string) (time.Time, error) { 53 | return time.Parse(timeFormat, s) 54 | } 55 | 56 | func formatTime(t time.Time) string { 57 | return t.Format(timeFormat) 58 | } 59 | 60 | func formatWatchDate(w screenjournal.WatchDate) string { 61 | return formatTime(w.Time()) 62 | } 63 | 64 | func formatReleaseDate(rd screenjournal.ReleaseDate) string { 65 | return formatTime(rd.Time()) 66 | } 67 | -------------------------------------------------------------------------------- /store/sqlite/tv_shows.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "net/url" 7 | 8 | "github.com/mtlynch/screenjournal/v2/screenjournal" 9 | "github.com/mtlynch/screenjournal/v2/store" 10 | ) 11 | 12 | func (s Store) ReadTvShow(id screenjournal.TvShowID) (screenjournal.TvShow, error) { 13 | row := s.ctx.QueryRow(` 14 | SELECT 15 | id, 16 | tmdb_id, 17 | imdb_id, 18 | title, 19 | first_air_date, 20 | poster_path 21 | FROM 22 | tv_shows 23 | WHERE 24 | id = :id`, sql.Named("id", id.Int64())) 25 | 26 | return tvShowFromRow(row) 27 | } 28 | 29 | func (s Store) ReadTvShowByTmdbID(tmdbID screenjournal.TmdbID) (screenjournal.TvShow, error) { 30 | row := s.ctx.QueryRow(` 31 | SELECT 32 | id, 33 | tmdb_id, 34 | imdb_id, 35 | title, 36 | first_air_date, 37 | poster_path 38 | FROM 39 | tv_shows 40 | WHERE 41 | tmdb_id = :tmdb_id`, sql.Named("tmdb_id", tmdbID.Int32())) 42 | 43 | return tvShowFromRow(row) 44 | } 45 | 46 | func (s Store) InsertTvShow(t screenjournal.TvShow) (screenjournal.TvShowID, error) { 47 | log.Printf("inserting new TV show %s", t.Title) 48 | 49 | res, err := s.ctx.Exec(` 50 | INSERT INTO 51 | tv_shows 52 | ( 53 | tmdb_id, 54 | imdb_id, 55 | title, 56 | first_air_date, 57 | poster_path 58 | ) 59 | VALUES ( 60 | :tmdb_id, :imdb_id, :title, :first_air_date, :poster_path 61 | )`, 62 | sql.Named("tmdb_id", t.TmdbID), 63 | sql.Named("imdb_id", t.ImdbID), 64 | sql.Named("title", t.Title), 65 | sql.Named("first_air_date", formatReleaseDate(t.AirDate)), 66 | sql.Named("poster_path", t.PosterPath.String()), 67 | ) 68 | if err != nil { 69 | return screenjournal.TvShowID(0), err 70 | } 71 | 72 | lastID, err := res.LastInsertId() 73 | if err != nil { 74 | return screenjournal.TvShowID(0), err 75 | } 76 | 77 | return screenjournal.TvShowID(lastID), nil 78 | } 79 | 80 | func (s Store) UpdateTvShow(t screenjournal.TvShow) error { 81 | log.Printf("updating TV show %s", t.Title) 82 | 83 | _, err := s.ctx.Exec(` 84 | UPDATE 85 | tv_shows 86 | SET 87 | tmdb_id = :tmdb_id, 88 | imdb_id = :imdb_id, 89 | title = :title, 90 | first_air_date = :first_air_date, 91 | poster_path = :poster_path 92 | WHERE 93 | id = :id 94 | `, 95 | sql.Named("tmdb_id", t.TmdbID), 96 | sql.Named("imdb_id", t.ImdbID), 97 | sql.Named("title", t.Title), 98 | sql.Named("first_air_date", formatReleaseDate(t.AirDate)), 99 | sql.Named("poster_path", t.PosterPath.String()), 100 | sql.Named("id", t.ID.Int64()), 101 | ) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | return nil 107 | } 108 | 109 | func tvShowFromRow(row rowScanner) (screenjournal.TvShow, error) { 110 | var id int 111 | var tmdbID int 112 | var imdbIDRaw *string 113 | var title string 114 | var firstAirDateRaw *string 115 | var posterPathRaw *string 116 | 117 | err := row.Scan(&id, &tmdbID, &imdbIDRaw, &title, &firstAirDateRaw, &posterPathRaw) 118 | if err == sql.ErrNoRows { 119 | return screenjournal.TvShow{}, store.ErrTvShowNotFound 120 | } else if err != nil { 121 | log.Printf("failed to read TV show from row: %v", err) 122 | return screenjournal.TvShow{}, err 123 | } 124 | 125 | var imdbID screenjournal.ImdbID 126 | if imdbIDRaw != nil { 127 | imdbID = screenjournal.ImdbID(*imdbIDRaw) 128 | } 129 | 130 | var firstAirDate screenjournal.ReleaseDate 131 | if firstAirDateRaw != nil { 132 | rd, err := parseDatetime(*firstAirDateRaw) 133 | if err != nil { 134 | log.Printf("failed to parse release date %s: %v", *firstAirDateRaw, err) 135 | } else { 136 | firstAirDate = screenjournal.ReleaseDate(rd) 137 | } 138 | } 139 | 140 | var posterPath url.URL 141 | if posterPathRaw != nil { 142 | pp, err := url.Parse(*posterPathRaw) 143 | if err != nil { 144 | log.Printf("failed to parse poster path: %s", *posterPathRaw) 145 | } else { 146 | posterPath = *pp 147 | } 148 | } 149 | 150 | return screenjournal.TvShow{ 151 | ID: screenjournal.TvShowID(id), 152 | TmdbID: screenjournal.TmdbID(tmdbID), 153 | ImdbID: imdbID, 154 | Title: screenjournal.MediaTitle(title), 155 | AirDate: firstAirDate, 156 | PosterPath: posterPath, 157 | }, nil 158 | } 159 | -------------------------------------------------------------------------------- /store/sqlite/wipe_dev.go: -------------------------------------------------------------------------------- 1 | //go:build dev 2 | 3 | package sqlite 4 | 5 | import "log" 6 | 7 | func (s Store) Clear() { 8 | log.Printf("clearing all SQLite tables") 9 | if _, err := s.ctx.Exec(`DELETE FROM movies`); err != nil { 10 | log.Fatalf("failed to delete movies: %v", err) 11 | } 12 | if _, err := s.ctx.Exec(`DELETE FROM reviews`); err != nil { 13 | log.Fatalf("failed to delete reviews: %v", err) 14 | } 15 | if _, err := s.ctx.Exec(`DELETE FROM users`); err != nil { 16 | log.Fatalf("failed to delete users: %v", err) 17 | } 18 | if _, err := s.ctx.Exec(`DELETE FROM invites`); err != nil { 19 | log.Fatalf("failed to delete invites: %v", err) 20 | } 21 | if _, err := s.ctx.Exec(`DELETE FROM notification_preferences`); err != nil { 22 | log.Fatalf("failed to delete notification_preferences: %v", err) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/mtlynch/screenjournal/v2/screenjournal" 7 | ) 8 | 9 | type ( 10 | reviewFilters struct { 11 | Username *screenjournal.Username 12 | MovieID *screenjournal.MovieID 13 | TvShowID *screenjournal.TvShowID 14 | TvShowSeason *screenjournal.TvShowSeason 15 | } 16 | 17 | ReadReviewsParams struct { 18 | Filters reviewFilters 19 | Order *screenjournal.SortOrder 20 | } 21 | 22 | ReadReviewsOption func(*ReadReviewsParams) 23 | ) 24 | 25 | var ( 26 | ErrMovieNotFound = errors.New("could not find movie") 27 | ErrTvShowNotFound = errors.New("could not find TV show") 28 | ErrCommentNotFound = errors.New("could not find comment") 29 | ErrReviewNotFound = errors.New("could not find review") 30 | ErrUserNotFound = errors.New("could not find user") 31 | ErrUsernameNotAvailable = errors.New("username is not available") 32 | ErrEmailAssociatedWithAnotherAccount = errors.New("email address is associated with another account") 33 | ) 34 | 35 | func FilterReviewsByUsername(u screenjournal.Username) func(*ReadReviewsParams) { 36 | return func(p *ReadReviewsParams) { 37 | p.Filters.Username = &u 38 | } 39 | } 40 | 41 | func FilterReviewsByMovieID(id screenjournal.MovieID) func(*ReadReviewsParams) { 42 | return func(p *ReadReviewsParams) { 43 | p.Filters.MovieID = &id 44 | } 45 | } 46 | 47 | func FilterReviewsByTvShowID(id screenjournal.TvShowID) func(*ReadReviewsParams) { 48 | return func(p *ReadReviewsParams) { 49 | p.Filters.TvShowID = &id 50 | } 51 | } 52 | 53 | func FilterReviewsByTvShowSeason(season screenjournal.TvShowSeason) func(*ReadReviewsParams) { 54 | return func(p *ReadReviewsParams) { 55 | p.Filters.TvShowSeason = &season 56 | } 57 | } 58 | 59 | func SortReviews(order screenjournal.SortOrder) func(*ReadReviewsParams) { 60 | return func(p *ReadReviewsParams) { 61 | p.Order = &order 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /store/test_sqlite/db.go: -------------------------------------------------------------------------------- 1 | package test_sqlite 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/mtlynch/screenjournal/v2/random" 9 | "github.com/mtlynch/screenjournal/v2/store/sqlite" 10 | ) 11 | 12 | func New() sqlite.Store { 13 | // Suppress log output, as the migration logs are too noisy during tests. 14 | defer quietLogs()() 15 | const optimizeForLitestream = false 16 | return sqlite.New(ephemeralDbURI(), optimizeForLitestream) 17 | } 18 | 19 | func ephemeralDbURI() string { 20 | name := random.String( 21 | 10, 22 | []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")) 23 | return fmt.Sprintf("file:%s?mode=memory&cache=shared", name) 24 | } 25 | 26 | // quietLogs suppresses log output during a function execution. 27 | func quietLogs() func() { 28 | devNull, _ := os.Open(os.DevNull) 29 | stdout := os.Stdout 30 | stderr := os.Stderr 31 | os.Stdout = devNull 32 | os.Stderr = devNull 33 | log.SetOutput(devNull) 34 | return func() { 35 | defer func() { 36 | if err := devNull.Close(); err != nil { 37 | log.Printf("failed to close handle to /dev/null") 38 | return 39 | } 40 | }() 41 | os.Stdout = stdout 42 | os.Stderr = stderr 43 | log.SetOutput(os.Stderr) 44 | } 45 | } 46 | --------------------------------------------------------------------------------