├── .env.sample ├── .gitignore ├── Dockerfile ├── README.md ├── bin ├── backup ├── css ├── db └── deploy ├── common.go ├── db.go ├── db ├── migrate │ └── 20220218225900_add_read_column_to_books.sql ├── query.sql ├── seeds.sql ├── sqlc.yaml └── structure.sql ├── docker-compose.yml ├── go.mod ├── go.sum ├── helpers.go ├── main.go ├── middlewares.go ├── models.go ├── public ├── books │ └── image │ │ └── .keep ├── default_book ├── default_user ├── favicon.ico ├── fonts │ ├── 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 ├── google_books.js ├── highlights │ └── image │ │ └── .keep └── style.css ├── query.sql.go ├── validation.go └── views ├── books ├── book.html ├── new.html └── show.html ├── common ├── errors.html ├── google_login.html └── separator.html ├── highlights └── new.html ├── index.html ├── layout.html ├── partials ├── header.html └── user_nav.html ├── privacy.html ├── shelves ├── edit.html └── index.html ├── users ├── edit.html └── show.html └── wide_layout.html /.env.sample: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://postgres:@localhost/library_development?sslmode=disable 2 | SESSION_SECRET= 3 | GOOGLE_CLIENT_ID= 4 | GOOGLE_CLIENT_SECRET= 5 | BACKUPS_PATH=/path/to/backups 6 | BACKUPS_LIMIT=30 7 | DOMAIN=http://localhost:3000 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite3 2 | /.env 3 | 4 | /public/books/image/* 5 | !/public/books/image/ 6 | !/public/books/image/.keep 7 | 8 | /public/highlights/image/* 9 | !/public/highlights/image/ 10 | !/public/highlights/image/.keep 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17.7-alpine 2 | 3 | ENV LANG=C.UTF-8 4 | RUN apk update && apk add --no-cache postgresql-client 5 | 6 | ENV app /app 7 | RUN mkdir -p $app 8 | CMD ./main 9 | 10 | WORKDIR $app 11 | ADD . $app 12 | EXPOSE 3000 13 | RUN go build -o main *.go 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | LIBRARY 2 | ========= 3 | 4 | I used to share what I'm reading on Goodreads.com but over time: 5 | 6 | - It became very slow 7 | - It has 3 trackers as of writing this readme. 8 | - It has many features that is confusing for me. 9 | 10 | So I sat down and wrote my own simple library program. 11 | 12 | - A reflection of my physical library 13 | - It's simple 14 | - Fast 15 | - ~~Depends on my input (titles, images...etc)~~ : This proved to be a lot of effort to do when I first tried inserting all my books 16 | - ~~Doesn't depend on any other system~~ : I needed the option to get the book information from google books 17 | - Free to use 18 | - Free to fork and modify and redistribute 19 | - Has a feature to lend my books to other people 20 | - Doesn't track me 21 | 22 | # What's done so far: 23 | 24 | - Allows adding books and taking pictures for covers from phone 25 | - Allows creating book shelves 26 | - Each book can be put in one shelf like real books. no multiple lists nonsense. 27 | - User login 28 | 29 | # Guidelines 30 | 31 | - Keep it simple 32 | - Minimize dependencies 33 | - Don't add javascript 34 | - Don't write custom CSS. use bulma.io 35 | - ~~Don't depend on any external system like social login..etc.~~ I had to create login with google for easier implementation 36 | 37 | # Start the server 38 | 39 | - Clone it 40 | - You need Go installed 41 | - Install dependencies `go get .` 42 | - Setup the database `bin/db setup` 43 | - Run the server `go run *.go` 44 | 45 | # Deployment 46 | 47 | - a remote ssh access to a server with docker and docker-compose 48 | - clone the repo to you machine 49 | - copy `.env` to the remote server `/root/env/library/.env` and fill it 50 | - from your machine `bin/deploy master user@ip-address` 51 | - This will deploy all services to the remote server 52 | 53 | # Contributions 54 | 55 | - Make it simpler 56 | - Make it faster 57 | - Make it more secure 58 | -------------------------------------------------------------------------------- /bin/backup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function sleep_until { 4 | # Use $* to eliminate need for quotes 5 | seconds=$(( $(date -d "$*" +%s) - $(date +%s) )) 6 | 7 | # if it passed today, get it tomorrow 8 | if [ $seconds -le 0 ] 9 | then 10 | seconds=$(( $(date -d "tomorrow $*" +%s) - $(date +%s) )) 11 | fi 12 | 13 | echo "Sleeping for $seconds seconds" 14 | sleep $seconds 15 | } 16 | 17 | while [ true ] 18 | do 19 | sleep_until '4:00' 20 | 21 | mkdir -p $BACKUPS_PATH 22 | filename="${BACKUPS_PATH}/`date +%F`.dump" 23 | 24 | echo "Taking backup ${filename}" 25 | pg_dump -f $filename -Fc $DATABASE_URL 26 | sync 27 | 28 | # delete backups older than the limit 29 | echo "Deleting old backup older than ${BACKUPS_LIMIT}" 30 | find $BACKUPS_PATH -mtime +$BACKUPS_LIMIT -exec rm {} \; 31 | done -------------------------------------------------------------------------------- /bin/css: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | gem install --conservative sass 4 | wget --no-clobber --output-document=bulma.zip https://github.com/jgthms/bulma/releases/download/0.9.2/bulma-0.9.2.zip 5 | unzip -u bulma.zip 6 | 7 | wget --no-clobber --output-document=fontawesome.zip https://use.fontawesome.com/releases/v6.0.0/fontawesome-free-6.0.0-web.zip 8 | unzip -u fontawesome.zip 9 | mv fontawesome-free* fontawesome 10 | 11 | cat << EOT > bulma.scss 12 | @charset "utf-8"; 13 | 14 | \$body-size: 13px; 15 | \$fa-font-path: "fonts"; 16 | 17 | @import "bulma/bulma.sass"; 18 | @import "./fontawesome/scss/fontawesome.scss"; 19 | @import "./fontawesome/scss/solid.scss"; 20 | @import "./fontawesome/scss/brands.scss"; 21 | 22 | .cover { 23 | box-sizing: border-box; 24 | border-radius: 0px 0.3em 0.0em 0px; 25 | box-shadow: 0 0 1em #DDD; 26 | } 27 | 28 | #menu-switch { 29 | display: none; 30 | } 31 | #menu-switch:checked + .navbar-brand .navbar-burger span:nth-child(1) { 32 | transform: translateY(5px) rotate(45deg); 33 | } 34 | #menu-switch:checked + .navbar-brand .navbar-burger span:nth-child(2) { 35 | opacity: 0; 36 | } 37 | #menu-switch:checked + .navbar-brand .navbar-burger span:nth-child(3) { 38 | transform: translateY(-5px) rotate(-45deg); 39 | } 40 | #menu-switch:checked ~ .navbar-menu{ 41 | display: block; 42 | } 43 | 44 | .is-attached { 45 | border-radius: 0 0 0.3em 0; 46 | height:0.5rem; 47 | } 48 | EOT 49 | 50 | sass --sourcemap=none \ 51 | --style compressed \ 52 | bulma.scss:public/style.css 53 | 54 | rm -rf public/fonts 55 | mv fontawesome/webfonts public/fonts 56 | rm -rf bulma* .sass-cache fontawesome* 57 | -------------------------------------------------------------------------------- /bin/db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | mkdir -p db/migrate 5 | 6 | PGUSER=`echo $DATABASE_URL | sed 's/postgres:\/\///' | awk -F"[@/?:]" '{ print $1 }'` 7 | PGPASSWORD=`echo $DATABASE_URL | sed 's/postgres:\/\///' | awk -F"[@/?:]" '{ print $2 }'` 8 | PGHOST=`echo $DATABASE_URL | sed 's/postgres:\/\///' | awk -F"[@/?:]" '{ print $3 }'` 9 | PGDATABASE=`echo $DATABASE_URL | sed 's/postgres:\/\///' | awk -F"[@/?:]" '{ print $4 }'` 10 | 11 | create_schema_migrations() { 12 | echo "CREATE TABLE IF NOT EXISTS schema_migrations (version character varying NOT NULL UNIQUE);" \ 13 | | psql -v ON_ERROR_STOP=1 --no-psqlrc --quiet $DATABASE_URL 14 | } 15 | 16 | create() { 17 | echo "Create database $PGDATABASE" 18 | createdb --host=$PGHOST --username=$PGUSER $PGDATABASE 19 | } 20 | 21 | drop() { 22 | echo "Dropping database $PGDATABASE" 23 | dropdb --host=$PGHOST --username=$PGUSER $PGDATABASE 24 | } 25 | 26 | seed() { 27 | echo "Loading seeds..." 28 | psql -v ON_ERROR_STOP=1 --no-psqlrc --quiet $DATABASE_URL \ 29 | < db/seeds.sql 30 | } 31 | 32 | load() { 33 | echo "Loading schema..." 34 | psql -v ON_ERROR_STOP=1 --no-psqlrc --quiet $DATABASE_URL \ 35 | < db/structure.sql 36 | } 37 | 38 | up() { 39 | f=`ls db/migrate/$1_*.sql` 40 | echo "Migrating $f" 41 | awk '/-- up/{flag=1; next} /-- down/{flag=0} flag' $f \ 42 | | tee \ 43 | | psql -v ON_ERROR_STOP=1 --no-psqlrc --echo-queries $DATABASE_URL 44 | create_schema_migrations 45 | psql -v ON_ERROR_STOP=1 --no-psqlrc $DATABASE_URL --command "INSERT INTO schema_migrations (version) VALUES($1);" 46 | dump 47 | } 48 | 49 | down() { 50 | f=`ls db/migrate/$1_*.sql` 51 | echo "Rolling back $f" 52 | awk '/-- down/{flag=1; next} /-- up/{flag=0} flag' $f \ 53 | | tee \ 54 | | psql -v ON_ERROR_STOP=1 --no-psqlrc --echo-queries $DATABASE_URL 55 | create_schema_migrations 56 | psql -v ON_ERROR_STOP=1 --no-psqlrc $DATABASE_URL --command "DELETE FROM schema_migrations WHERE version='$1';" 57 | dump 58 | } 59 | 60 | dump() { 61 | echo "Dumping database schema..." 62 | # dump schema of all tables 63 | pg_dump --format=plain --schema-only --no-owner $DATABASE_URL \ 64 | > db/structure.sql 65 | # dump schema_migrations table 66 | pg_dump --format=plain --inserts --data-only -t schema_migrations $DATABASE_URL \ 67 | >> db/structure.sql 68 | } 69 | 70 | create_migration() { 71 | ts=`date +"%Y%m%d%H%M%S"` 72 | f="db/migrate/${ts}_$1.sql" 73 | touch $f 74 | echo "-- up" >> $f 75 | echo "-- down" >> $f 76 | echo "Migration created: $f" 77 | } 78 | 79 | migrate(){ 80 | # List all migrations from the database 81 | psql --no-psqlrc --echo-queries --tuples-only $DATABASE_URL --command "SELECT * from schema_migrations" \ 82 | | awk '{ print $1 }' \ 83 | > /tmp/migrated 84 | 85 | # List all migrations files 86 | ls db/migrate/*.sql \ 87 | | awk -F"/" '{ print $3 }' \ 88 | | awk -F"_" '{ print $1 }' \ 89 | > /tmp/migrations 90 | 91 | # list of timestamps yet to be migrated 92 | uplist=`comm -23 <(sort /tmp/migrations) <(sort /tmp/migrated)` 93 | 94 | # migrate up every one of them in order 95 | for ts in $uplist; do 96 | up $ts 97 | done 98 | } 99 | 100 | rollback() { 101 | version=`psql --no-psqlrc --echo-queries --tuples-only $DATABASE_URL --command "SELECT * from schema_migrations ORDER BY version DESC LIMIT 1" | awk '{ print $1 }'` 102 | down $version 103 | } 104 | 105 | case $1 in 106 | "create") create;; 107 | "drop") drop;; 108 | "status") status;; 109 | "create_migration") create_migration $2;; 110 | "dump") dump;; 111 | "load") load;; 112 | "seed") seed;; 113 | "migrate") migrate;; 114 | "rollback") rollback;; 115 | "setup") 116 | create 117 | load 118 | seed 119 | ;; 120 | "reset") 121 | drop 122 | create 123 | load 124 | seed 125 | ;; 126 | esac 127 | -------------------------------------------------------------------------------- /bin/deploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Usage: deploy 4 | # example: deploy master root@123.123.123.123 db web 5 | 6 | set -e 7 | 8 | BRANCH=$1 9 | SERVER=$2 10 | SERVICES=${@:3} 11 | 12 | APP=/root/projects/library 13 | REPO=git@github.com:emad-elsaid/library.git 14 | ENVFILE=/root/env/library/.env 15 | 16 | sshin() { 17 | ssh -o LogLevel=QUIET -t $SERVER "cd $APP; $@" 18 | } 19 | 20 | echo "[*] Deleting old files" 21 | ssh -o LogLevel=QUIET -t $SERVER rm -rf $APP 22 | 23 | echo "[*] Clone branch" 24 | ssh -o LogLevel=QUIET -t $SERVER git clone --depth=1 --branch $BRANCH $REPO $APP 25 | 26 | echo "[*] Copy .env file" 27 | ssh -o LogLevel=QUIET -t $SERVER cp $ENVFILE $APP/.env 28 | 29 | echo "[*] Pulling new docker images" 30 | sshin docker-compose pull 31 | 32 | echo "[*] Building images" 33 | sshin docker-compose build $SERVICES 34 | 35 | echo "[*] Migrating database" 36 | sshin docker-compose run -T --rm web bin/db migrate 37 | 38 | echo "[*] Stop old containers" 39 | sshin docker-compose stop $SERVICES 40 | 41 | echo "[*] Bring up new containers" 42 | sshin docker-compose up -d $SERVICES 43 | 44 | echo "[*] Clean docker" 45 | sshin docker system prune 46 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | //go:generate sqlc generate --file db/sqlc.yaml 2 | package main 3 | 4 | // This is an experiment 5 | 6 | // This file should be copied to new projects that needs connection to DB, HTTP 7 | // server, views and helpers setup. So it should do as much work as possible 8 | // just by including it in the project. I imagine that I will use it by copying 9 | // the code instead of referencing it. and changing the constants to what I 10 | // think is suitable for the new project. 11 | 12 | // HOW TO USE 13 | 14 | // 1. Copy common.go, .env.sample, sqlc.yaml 15 | // 2. Write queries in query.sql and use `go generate` to generate functions with sqlc 16 | // 3. Use `router` to add your gorilla routes, or shorthand methods GET, POST, DELETE...etc 17 | // 4. Add Helpers to `helpers` map 18 | // 5. call `Start()` to start the server 19 | 20 | import ( 21 | "bytes" 22 | "context" 23 | "database/sql" 24 | "embed" 25 | "fmt" 26 | "html/template" 27 | "image" 28 | _ "image/gif" 29 | _ "image/jpeg" 30 | _ "image/png" 31 | "io" 32 | "io/fs" 33 | "log" 34 | "net/http" 35 | "os" 36 | "regexp" 37 | "strconv" 38 | "strings" 39 | "time" 40 | 41 | _ "embed" 42 | 43 | "github.com/gorilla/csrf" 44 | "github.com/gorilla/sessions" 45 | "github.com/jmoiron/sqlx" 46 | _ "github.com/lib/pq" 47 | ) 48 | 49 | const ( 50 | APP_NAME = "library" 51 | MAX_DB_OPEN_CONNECTIONS = 5 52 | MAX_DB_IDLE_CONNECTIONS = 5 53 | STATIC_DIR_PATH = "public" 54 | BIND_ADDRESS = "0.0.0.0:3000" 55 | VIEWS_EXTENSION = ".html" 56 | SESSION_COOKIE_NAME = APP_NAME + "_session" 57 | CSRF_COOKIE_NAME = APP_NAME + "_csrf" 58 | ) 59 | 60 | var ( 61 | Q *Queries 62 | router *Handler = &Handler{} 63 | session *sessions.CookieStore 64 | 65 | CSRF = csrf.TemplateField 66 | ) 67 | 68 | // Some aliases to make it easier 69 | type Response = http.ResponseWriter 70 | type Request = *http.Request 71 | type Output = http.HandlerFunc 72 | type Locals map[string]interface{} // passed to views/templates 73 | 74 | const ( 75 | _ = iota 76 | KB int64 = 1 << (10 * iota) 77 | MB 78 | GB 79 | TB 80 | PB 81 | ) 82 | 83 | func init() { 84 | log.SetFlags(log.Ltime) 85 | 86 | db, err := sqlx.Connect("postgres", os.Getenv("DATABASE_URL")) 87 | if err != nil { 88 | log.Fatal(err) 89 | } 90 | 91 | db.SetMaxOpenConns(MAX_DB_OPEN_CONNECTIONS) 92 | db.SetMaxIdleConns(MAX_DB_IDLE_CONNECTIONS) 93 | 94 | Q = New(queryLogger{db}) 95 | session = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET"))) 96 | session.Options.HttpOnly = true 97 | } 98 | 99 | func Start() { 100 | compileViews() 101 | middlewares := []func(http.Handler) http.Handler{ 102 | methodOverrideHandler, 103 | csrf.Protect( 104 | []byte(os.Getenv("SESSION_SECRET")), 105 | csrf.Path("/"), 106 | csrf.FieldName("csrf"), 107 | csrf.CookieName(CSRF_COOKIE_NAME), 108 | ), 109 | RequestLoggerHandler, 110 | } 111 | 112 | ROUTE(staticWithoutDirectoryListingHandler()) 113 | 114 | var handler http.Handler = router 115 | for _, v := range middlewares { 116 | handler = v(handler) 117 | } 118 | 119 | srv := &http.Server{ 120 | Handler: handler, 121 | Addr: BIND_ADDRESS, 122 | WriteTimeout: 15 * time.Second, 123 | ReadTimeout: 15 * time.Second, 124 | } 125 | 126 | log.Printf("Starting server: %s", BIND_ADDRESS) 127 | log.Fatal(srv.ListenAndServe()) 128 | } 129 | 130 | // Mux/Handler =========================================== 131 | type RouteCheck func(Request) (Request, bool) 132 | 133 | type Route struct { 134 | checks []RouteCheck 135 | route http.HandlerFunc 136 | } 137 | 138 | type Handler struct { 139 | routes []Route 140 | } 141 | 142 | func (h *Handler) ServeHTTP(w Response, r Request) { 143 | ROUTES: 144 | for _, route := range h.routes { 145 | rn := r 146 | ok := false 147 | for _, check := range route.checks { 148 | if rn, ok = check(rn); !ok { 149 | continue ROUTES 150 | } 151 | } 152 | 153 | route.route(w, rn) 154 | return 155 | } 156 | } 157 | 158 | func checkMethod(method string) RouteCheck { 159 | return func(r Request) (Request, bool) { return r, r.Method == method } 160 | } 161 | 162 | func checkPath(path string) RouteCheck { 163 | placeholder := regexp.MustCompile("{([^}]*)}") 164 | path = "^" + placeholder.ReplaceAllString(path, "(?P<$1>[^/]+)") + "$" 165 | reg := regexp.MustCompile(path) 166 | groups := reg.SubexpNames() 167 | 168 | return func(r Request) (Request, bool) { 169 | if !reg.MatchString(r.URL.Path) { 170 | return r, false 171 | } 172 | 173 | values := reg.FindStringSubmatch(r.URL.Path) 174 | vars := map[string]string{} 175 | for i, g := range groups { 176 | vars[g] = values[i] 177 | } 178 | 179 | ctx := context.WithValue(r.Context(), "vars", vars) 180 | return r.WithContext(ctx), true 181 | } 182 | } 183 | 184 | func VARS(r Request) map[string]string { 185 | if rv := r.Context().Value("vars"); rv != nil { 186 | return rv.(map[string]string) 187 | } 188 | return map[string]string{} 189 | } 190 | 191 | // LOGGING =============================================== 192 | 193 | const ( 194 | DEBUG = "\033[97;42m" 195 | INFO = "\033[97;43m" 196 | ) 197 | 198 | func Log(level, label, text string, args ...interface{}) func() { 199 | start := time.Now() 200 | return func() { 201 | if len(args) > 0 { 202 | log.Printf("%s %s \033[0m (%s) %s %v", level, label, time.Now().Sub(start), text, args) 203 | } else { 204 | log.Printf("%s %s \033[0m (%s) %s", level, label, time.Now().Sub(start), text) 205 | } 206 | } 207 | } 208 | 209 | // DATABASE CONNECTION =================================== 210 | 211 | type queryLogger struct { 212 | db *sqlx.DB 213 | } 214 | 215 | func (p queryLogger) ExecContext(ctx context.Context, q string, args ...interface{}) (sql.Result, error) { 216 | defer Log(DEBUG, "DB Exec", q, args)() 217 | return p.db.ExecContext(ctx, q, args...) 218 | } 219 | func (p queryLogger) PrepareContext(ctx context.Context, q string) (*sql.Stmt, error) { 220 | return p.db.PrepareContext(ctx, q) 221 | } 222 | func (p queryLogger) QueryContext(ctx context.Context, q string, args ...interface{}) (*sql.Rows, error) { 223 | defer Log(DEBUG, "DB Query", q, args)() 224 | return p.db.QueryContext(ctx, q, args...) 225 | } 226 | func (p queryLogger) QueryRowContext(ctx context.Context, q string, args ...interface{}) *sql.Row { 227 | defer Log(DEBUG, "DB Row", q, args)() 228 | return p.db.QueryRowContext(ctx, q, args...) 229 | } 230 | 231 | // ROUTES HELPERS ========================================== 232 | 233 | type HandlerFunc func(http.ResponseWriter, *http.Request) http.HandlerFunc 234 | 235 | func handlerFuncToHttpHandler(handler HandlerFunc) http.HandlerFunc { 236 | return func(w http.ResponseWriter, r *http.Request) { 237 | handler(w, r)(w, r) 238 | } 239 | } 240 | 241 | func NotFound(w http.ResponseWriter, r *http.Request) { 242 | http.Error(w, "", http.StatusNotFound) 243 | } 244 | 245 | func BadRequest(w http.ResponseWriter, r *http.Request) { 246 | http.Error(w, "", http.StatusBadRequest) 247 | } 248 | 249 | func Unauthorized(w http.ResponseWriter, r *http.Request) { 250 | http.Error(w, "", http.StatusUnauthorized) 251 | } 252 | 253 | func InternalServerError(err error) http.HandlerFunc { 254 | return func(w http.ResponseWriter, r *http.Request) { 255 | http.Error(w, err.Error(), http.StatusInternalServerError) 256 | } 257 | } 258 | 259 | func Redirect(url string) http.HandlerFunc { 260 | return func(w http.ResponseWriter, r *http.Request) { 261 | http.Redirect(w, r, url, http.StatusFound) 262 | } 263 | } 264 | 265 | func ROUTE(route http.HandlerFunc, checks ...RouteCheck) { 266 | router.routes = append(router.routes, Route{ 267 | checks: checks, 268 | route: route, 269 | }) 270 | } 271 | 272 | func GET(path string, handler HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) { 273 | ROUTE( 274 | applyMiddlewares(handlerFuncToHttpHandler(handler), middlewares...), 275 | checkMethod(http.MethodGet), checkPath(path), 276 | ) 277 | } 278 | 279 | func POST(path string, handler HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) { 280 | ROUTE( 281 | applyMiddlewares(handlerFuncToHttpHandler(handler), middlewares...), 282 | checkMethod(http.MethodPost), checkPath(path), 283 | ) 284 | } 285 | 286 | func DELETE(path string, handler HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) { 287 | ROUTE( 288 | applyMiddlewares(handlerFuncToHttpHandler(handler), middlewares...), 289 | checkMethod(http.MethodDelete), checkPath(path), 290 | ) 291 | } 292 | 293 | // VIEWS ==================== 294 | 295 | //go:embed views 296 | var views embed.FS 297 | var templates *template.Template 298 | var helpers = template.FuncMap{} 299 | 300 | func compileViews() { 301 | templates = template.New("") 302 | fs.WalkDir(views, ".", func(path string, d fs.DirEntry, err error) error { 303 | if err != nil { 304 | return err 305 | } 306 | 307 | if strings.HasSuffix(path, VIEWS_EXTENSION) && d.Type().IsRegular() { 308 | name := strings.TrimPrefix(path, "views/") 309 | name = strings.TrimSuffix(name, VIEWS_EXTENSION) 310 | defer Log(DEBUG, "View", name)() 311 | 312 | c, err := fs.ReadFile(views, path) 313 | if err != nil { 314 | return err 315 | } 316 | 317 | template.Must(templates.New(name).Funcs(helpers).Parse(string(c))) 318 | } 319 | 320 | return nil 321 | }) 322 | } 323 | 324 | func partial(path string, data interface{}) string { 325 | v := templates.Lookup(path) 326 | if v == nil { 327 | return fmt.Sprintf("view %s not found", path) 328 | } 329 | 330 | w := bytes.NewBufferString("") 331 | err := v.Execute(w, data) 332 | if err != nil { 333 | return "rendering error " + path + " " + err.Error() 334 | } 335 | 336 | return w.String() 337 | } 338 | 339 | func Render(path string, view string, data Locals) http.HandlerFunc { 340 | return func(w http.ResponseWriter, r *http.Request) { 341 | data["view"] = view 342 | data["request"] = r 343 | fmt.Fprint(w, partial(path, data)) 344 | } 345 | } 346 | 347 | func HELPER(name string, f interface{}) { 348 | if _, ok := helpers[name]; ok { 349 | log.Fatalf("Helper: %s has been defined already", name) 350 | } 351 | 352 | helpers[name] = f 353 | } 354 | 355 | // SESSION ================================= 356 | 357 | func SESSION(r *http.Request) *sessions.Session { 358 | s, _ := session.Get(r, SESSION_COOKIE_NAME) 359 | return s 360 | } 361 | 362 | // HANDLERS MIDDLEWARES ============================= 363 | 364 | // First middleware gets executed first 365 | func applyMiddlewares(handler http.HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) http.HandlerFunc { 366 | for i := len(middlewares) - 1; i >= 0; i-- { 367 | handler = middlewares[i](handler) 368 | } 369 | return handler 370 | } 371 | 372 | // SERVER MIDDLEWARES ============================== 373 | 374 | func staticWithoutDirectoryListingHandler() http.HandlerFunc { 375 | dir := http.Dir(STATIC_DIR_PATH) 376 | server := http.FileServer(dir) 377 | handler := http.StripPrefix("/", server) 378 | 379 | return func(w http.ResponseWriter, r *http.Request) { 380 | if strings.HasSuffix(r.URL.Path, "/") { 381 | http.NotFound(w, r) 382 | return 383 | } 384 | 385 | handler.ServeHTTP(w, r) 386 | } 387 | } 388 | 389 | // Derived from Gorilla middleware https://github.com/gorilla/handlers/blob/v1.5.1/handlers.go#L134 390 | func methodOverrideHandler(h http.Handler) http.Handler { 391 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 392 | if r.Method == "POST" { 393 | om := r.FormValue("_method") 394 | if om == "PUT" || om == "PATCH" || om == "DELETE" { 395 | r.Method = om 396 | } 397 | } 398 | h.ServeHTTP(w, r) 399 | }) 400 | } 401 | 402 | func RequestLoggerHandler(h http.Handler) http.Handler { 403 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 404 | defer Log(INFO, r.Method, r.URL.Path)() 405 | h.ServeHTTP(w, r) 406 | }) 407 | } 408 | 409 | // HELPERS FUNCTIONS ====================== 410 | 411 | func atoi32(s string) int32 { 412 | i, _ := strconv.ParseInt(s, 10, 32) 413 | return int32(i) 414 | } 415 | 416 | func atoi64(s string) int64 { 417 | i, _ := strconv.ParseInt(s, 10, 64) 418 | return i 419 | } 420 | 421 | func NullString(s string) sql.NullString { 422 | return sql.NullString{ 423 | String: s, 424 | Valid: len(s) > 0, 425 | } 426 | } 427 | 428 | // VALIDATION ============================ 429 | 430 | type ValidationErrors map[string][]error 431 | 432 | func (v ValidationErrors) Add(field string, err error) { 433 | v[field] = append(v[field], err) 434 | } 435 | 436 | func ValidateStringPresent(val, key, label string, ve ValidationErrors) { 437 | if len(strings.TrimSpace(val)) == 0 { 438 | ve.Add(key, fmt.Errorf("%s can't be empty", label)) 439 | } 440 | } 441 | 442 | func ValidateStringLength(val, key, label string, ve ValidationErrors, min, max int) { 443 | l := len(strings.TrimSpace(val)) 444 | if l < min || l > max { 445 | ve.Add(key, fmt.Errorf("%s has to be between %d and %d characters, length is %d", label, min, max, l)) 446 | } 447 | } 448 | 449 | func ValidateStringNumeric(val, key, label string, ve ValidationErrors) { 450 | for _, c := range val { 451 | if !strings.ContainsRune("0123456789", c) { 452 | ve.Add(key, fmt.Errorf("%s has to consist of numbers", label)) 453 | return 454 | } 455 | } 456 | } 457 | 458 | func ValidateISBN13(val, key, label string, ve ValidationErrors) { 459 | if len(val) != 13 { 460 | ve.Add(key, fmt.Errorf("%s has to be 13 digits", label)) 461 | return 462 | } 463 | 464 | sum := 0 465 | for i, s := range val { 466 | digit, _ := strconv.Atoi(string(s)) 467 | if i%2 == 0 { 468 | sum += digit 469 | } else { 470 | sum += digit * 3 471 | } 472 | } 473 | 474 | if sum%10 != 0 { 475 | ve.Add(key, fmt.Errorf("%s is not a valid ISBN13 number", label)) 476 | } 477 | } 478 | 479 | func ValidateImage(val io.Reader, key, label string, ve ValidationErrors, maxw, maxh int) { 480 | if val == nil { 481 | return 482 | } 483 | 484 | image, _, err := image.Decode(val) 485 | if err != nil { 486 | ve.Add(key, fmt.Errorf("%s has an unsupported format supported formats are JPG, GIF, PNG", label)) 487 | return 488 | } 489 | 490 | sz := image.Bounds().Size() 491 | if sz.X > maxw { 492 | ve.Add(key, fmt.Errorf("%s width should be less than %d px, uploaded image width %d px", label, maxw, sz.X)) 493 | } 494 | if sz.Y > maxh { 495 | ve.Add(key, fmt.Errorf("%s height should be less than %d px, uploaded image height %d px", label, maxh, sz.Y)) 496 | } 497 | } 498 | 499 | func ValidateInt32Min(val int32, key, label string, ve ValidationErrors, min int32) { 500 | if val < min { 501 | ve.Add(key, fmt.Errorf("%s shouldn't be less than %d", label, min)) 502 | } 503 | } 504 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "database/sql" 8 | ) 9 | 10 | type DBTX interface { 11 | ExecContext(context.Context, string, ...interface{}) (sql.Result, error) 12 | PrepareContext(context.Context, string) (*sql.Stmt, error) 13 | QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) 14 | QueryRowContext(context.Context, string, ...interface{}) *sql.Row 15 | } 16 | 17 | func New(db DBTX) *Queries { 18 | return &Queries{db: db} 19 | } 20 | 21 | type Queries struct { 22 | db DBTX 23 | } 24 | 25 | func (q *Queries) WithTx(tx *sql.Tx) *Queries { 26 | return &Queries{ 27 | db: tx, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /db/migrate/20220218225900_add_read_column_to_books.sql: -------------------------------------------------------------------------------- 1 | -- up 2 | ALTER TABLE books 3 | ADD COLUMN page_read integer NOT NULL DEFAULT 0; 4 | 5 | -- down 6 | ALTER TABLE books 7 | DROP COLUMN page_read integer NOT NULL DEFAULT 0; 8 | -------------------------------------------------------------------------------- /db/query.sql: -------------------------------------------------------------------------------- 1 | -- name: User :one 2 | SELECT * FROM users WHERE id = $1 LIMIT 1; 3 | 4 | -- name: UserBySlug :one 5 | SELECT * FROM users WHERE slug = $1 LIMIT 1; 6 | 7 | -- name: Signup :one 8 | INSERT 9 | INTO users(name, image, slug, email) 10 | VALUES($1,$2,$3,$4) 11 | ON CONFLICT (email) 12 | DO UPDATE SET name = $1, image = $2, updated_at = CURRENT_TIMESTAMP 13 | RETURNING id; 14 | 15 | -- name: UserUnshelvedBooks :many 16 | SELECT books.id id, title, books.image image, google_books_id, slug, isbn, page_count, page_read 17 | FROM books, users 18 | WHERE users.id = books.user_id 19 | AND user_id = $1 20 | AND shelf_id IS NULL; 21 | 22 | -- name: Shelves :many 23 | SELECT * FROM shelves WHERE user_id = $1 ORDER BY position; 24 | 25 | -- name: ShelfBooks :many 26 | SELECT books.id id, title, books.image image, google_books_id, slug, isbn, page_read, page_count 27 | FROM books, users 28 | WHERE users.id = books.user_id 29 | AND shelf_id = $1 30 | ORDER BY books.created_at DESC; 31 | 32 | -- name: BookByIsbnAndUser :one 33 | SELECT books.*, slug, shelves.name shelf_name 34 | FROM users, books 35 | LEFT JOIN shelves 36 | ON shelves.id = books.shelf_id 37 | WHERE users.id = books.user_id 38 | AND books.user_id = $1 39 | AND isbn = $2 40 | LIMIT 1; 41 | 42 | -- name: Highlights :many 43 | SELECT * FROM highlights WHERE book_id = $1 ORDER BY page; 44 | 45 | -- name: NewBook :one 46 | INSERT INTO books (title, isbn, author, subtitle, description, publisher, page_count, google_books_id, user_id, page_read) 47 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 48 | RETURNING *; 49 | 50 | -- name: UpdateBook :exec 51 | UPDATE books 52 | SET title = $1, 53 | author = $2, 54 | subtitle = $3, 55 | description = $4, 56 | publisher = $5, 57 | page_count = $6, 58 | page_read = $7, 59 | updated_at = CURRENT_TIMESTAMP 60 | WHERE id = $8; 61 | 62 | -- name: UpdateBookImage :exec 63 | UPDATE books SET image = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2; 64 | 65 | -- name: CompleteBook :exec 66 | UPDATE books SET page_read = page_count WHERE id = $1; 67 | 68 | -- name: ShelfByIdAndUser :one 69 | SELECT * FROM shelves WHERE user_id = $1 AND id = $2 LIMIT 1; 70 | 71 | -- name: HighlightByIDAndBook :one 72 | SELECT * FROM highlights WHERE id = $1 AND book_id = $2 LIMIT 1; 73 | 74 | -- name: UpdateUser :exec 75 | UPDATE users 76 | SET description = $1, 77 | amazon_associates_id = $2, 78 | facebook = $3, 79 | twitter = $4, 80 | linkedin = $5, 81 | instagram = $6, 82 | phone = $7, 83 | whatsapp = $8, 84 | telegram = $9, 85 | updated_at = CURRENT_TIMESTAMP 86 | WHERE id = $10; 87 | 88 | -- name: NewHighlight :one 89 | INSERT INTO highlights (book_id, page, content) VALUES ($1, $2, $3) RETURNING *; 90 | 91 | -- name: UpdateHighlightImage :exec 92 | UPDATE highlights SET image = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2; 93 | 94 | -- name: UpdateHighlight :exec 95 | UPDATE highlights SET page = $1, content = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3; 96 | 97 | -- name: NewShelf :exec 98 | INSERT INTO shelves (name, user_id, position) 99 | VALUES ($1, $2, ( 100 | SELECT coalesce(MAX(position), 0) + 1 101 | FROM shelves 102 | WHERE user_id = $2) 103 | ); 104 | 105 | -- name: UpdateShelf :exec 106 | UPDATE shelves SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2; 107 | 108 | -- name: DeleteBook :exec 109 | DELETE FROM books WHERE id = $1; 110 | 111 | -- name: DeleteHighlight :exec 112 | DELETE FROM highlights WHERE id = $1; 113 | 114 | -- name: HighlightsWithImages :many 115 | SELECT image FROM highlights WHERE image IS NOT NULL AND length(image) > 0 AND book_id = $1; 116 | 117 | -- name: RemoveShelf :exec 118 | UPDATE shelves SET position = position - 1 119 | WHERE user_id = (SELECT user_id FROM shelves WHERE shelves.id = $1) 120 | AND position > (SELECT position FROM shelves WHERE shelves.id = $1); 121 | 122 | -- name: DeleteShelf :exec 123 | DELETE FROM shelves WHERE id = $1; 124 | 125 | -- name: MoveShelfUp :exec 126 | UPDATE shelves 127 | SET position = ( 128 | CASE 129 | WHEN position = (SELECT position -1 FROM shelves WHERE shelves.id = $1) THEN position + 1 130 | WHEN position = (SELECT position FROM shelves WHERE shelves.id = $1) THEN position - 1 131 | END 132 | ) 133 | WHERE user_id = (SELECT user_id FROM shelves WHERE shelves.id = $1) 134 | AND position IN ( 135 | (SELECT position -1 FROM shelves WHERE shelves.id = $1), 136 | (SELECT position FROM shelves WHERE shelves.id = $1) 137 | ); 138 | 139 | -- name: MoveShelfDown :exec 140 | UPDATE shelves 141 | SET position = ( 142 | CASE 143 | WHEN position = (SELECT position FROM shelves WHERE shelves.id = $1) THEN position + 1 144 | WHEN position = (SELECT position + 1 FROM shelves WHERE shelves.id = $1) THEN position - 1 145 | END 146 | ) 147 | WHERE user_id = (SELECT user_id FROM shelves WHERE shelves.id = $1) 148 | AND position IN ( 149 | (SELECT position FROM shelves WHERE shelves.id = $1), 150 | (SELECT position + 1 FROM shelves WHERE shelves.id = $1) 151 | ); 152 | 153 | -- name: MoveBookToShelf :exec 154 | UPDATE books SET shelf_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2; 155 | 156 | -- name: BooksCount :one 157 | SELECT count(*) FROM books WHERE user_id = $1; 158 | -------------------------------------------------------------------------------- /db/seeds.sql: -------------------------------------------------------------------------------- 1 | -- Add your seeding SQL here 2 | -------------------------------------------------------------------------------- /db/sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | packages: 3 | - path: ".." 4 | name: "main" 5 | engine: "postgresql" 6 | schema: "structure.sql" 7 | queries: "query.sql" 8 | -------------------------------------------------------------------------------- /db/structure.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- PostgreSQL database dump 3 | -- 4 | 5 | -- Dumped from database version 13.5 6 | -- Dumped by pg_dump version 13.5 7 | 8 | SET statement_timeout = 0; 9 | SET lock_timeout = 0; 10 | SET idle_in_transaction_session_timeout = 0; 11 | SET client_encoding = 'UTF8'; 12 | SET standard_conforming_strings = on; 13 | SELECT pg_catalog.set_config('search_path', '', false); 14 | SET check_function_bodies = false; 15 | SET xmloption = content; 16 | SET client_min_messages = warning; 17 | SET row_security = off; 18 | 19 | SET default_tablespace = ''; 20 | 21 | SET default_table_access_method = heap; 22 | 23 | -- 24 | -- Name: ar_internal_metadata; Type: TABLE; Schema: public; Owner: - 25 | -- 26 | 27 | CREATE TABLE public.ar_internal_metadata ( 28 | key character varying NOT NULL, 29 | value character varying, 30 | created_at timestamp(6) without time zone NOT NULL, 31 | updated_at timestamp(6) without time zone NOT NULL 32 | ); 33 | 34 | 35 | -- 36 | -- Name: books; Type: TABLE; Schema: public; Owner: - 37 | -- 38 | 39 | CREATE TABLE public.books ( 40 | id bigint NOT NULL, 41 | title character varying NOT NULL, 42 | author character varying NOT NULL, 43 | image character varying, 44 | isbn character varying(13) NOT NULL, 45 | created_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 46 | updated_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 47 | shelf_id bigint, 48 | user_id bigint NOT NULL, 49 | google_books_id character varying, 50 | subtitle character varying NOT NULL, 51 | description character varying NOT NULL, 52 | page_count integer NOT NULL, 53 | publisher character varying NOT NULL, 54 | page_read integer DEFAULT 0 NOT NULL 55 | ); 56 | 57 | 58 | -- 59 | -- Name: books_id_seq; Type: SEQUENCE; Schema: public; Owner: - 60 | -- 61 | 62 | CREATE SEQUENCE public.books_id_seq 63 | START WITH 1 64 | INCREMENT BY 1 65 | NO MINVALUE 66 | NO MAXVALUE 67 | CACHE 1; 68 | 69 | 70 | -- 71 | -- Name: books_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 72 | -- 73 | 74 | ALTER SEQUENCE public.books_id_seq OWNED BY public.books.id; 75 | 76 | 77 | -- 78 | -- Name: highlights; Type: TABLE; Schema: public; Owner: - 79 | -- 80 | 81 | CREATE TABLE public.highlights ( 82 | id bigint NOT NULL, 83 | book_id bigint NOT NULL, 84 | page integer NOT NULL, 85 | content character varying NOT NULL, 86 | image character varying, 87 | created_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 88 | updated_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL 89 | ); 90 | 91 | 92 | -- 93 | -- Name: highlights_id_seq; Type: SEQUENCE; Schema: public; Owner: - 94 | -- 95 | 96 | CREATE SEQUENCE public.highlights_id_seq 97 | START WITH 1 98 | INCREMENT BY 1 99 | NO MINVALUE 100 | NO MAXVALUE 101 | CACHE 1; 102 | 103 | 104 | -- 105 | -- Name: highlights_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 106 | -- 107 | 108 | ALTER SEQUENCE public.highlights_id_seq OWNED BY public.highlights.id; 109 | 110 | 111 | -- 112 | -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - 113 | -- 114 | 115 | CREATE TABLE public.schema_migrations ( 116 | version character varying NOT NULL 117 | ); 118 | 119 | 120 | -- 121 | -- Name: shelves; Type: TABLE; Schema: public; Owner: - 122 | -- 123 | 124 | CREATE TABLE public.shelves ( 125 | id bigint NOT NULL, 126 | name character varying NOT NULL, 127 | created_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 128 | updated_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 129 | user_id bigint NOT NULL, 130 | "position" integer NOT NULL 131 | ); 132 | 133 | 134 | -- 135 | -- Name: shelves_id_seq; Type: SEQUENCE; Schema: public; Owner: - 136 | -- 137 | 138 | CREATE SEQUENCE public.shelves_id_seq 139 | START WITH 1 140 | INCREMENT BY 1 141 | NO MINVALUE 142 | NO MAXVALUE 143 | CACHE 1; 144 | 145 | 146 | -- 147 | -- Name: shelves_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 148 | -- 149 | 150 | ALTER SEQUENCE public.shelves_id_seq OWNED BY public.shelves.id; 151 | 152 | 153 | -- 154 | -- Name: users; Type: TABLE; Schema: public; Owner: - 155 | -- 156 | 157 | CREATE TABLE public.users ( 158 | id bigint NOT NULL, 159 | name character varying, 160 | email character varying, 161 | image character varying, 162 | created_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 163 | updated_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 164 | slug character varying NOT NULL, 165 | description text, 166 | facebook character varying, 167 | twitter character varying, 168 | linkedin character varying, 169 | instagram character varying, 170 | phone character varying, 171 | whatsapp character varying, 172 | telegram character varying, 173 | amazon_associates_id character varying 174 | ); 175 | 176 | 177 | -- 178 | -- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - 179 | -- 180 | 181 | CREATE SEQUENCE public.users_id_seq 182 | START WITH 1 183 | INCREMENT BY 1 184 | NO MINVALUE 185 | NO MAXVALUE 186 | CACHE 1; 187 | 188 | 189 | -- 190 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 191 | -- 192 | 193 | ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; 194 | 195 | 196 | -- 197 | -- Name: books id; Type: DEFAULT; Schema: public; Owner: - 198 | -- 199 | 200 | ALTER TABLE ONLY public.books ALTER COLUMN id SET DEFAULT nextval('public.books_id_seq'::regclass); 201 | 202 | 203 | -- 204 | -- Name: highlights id; Type: DEFAULT; Schema: public; Owner: - 205 | -- 206 | 207 | ALTER TABLE ONLY public.highlights ALTER COLUMN id SET DEFAULT nextval('public.highlights_id_seq'::regclass); 208 | 209 | 210 | -- 211 | -- Name: shelves id; Type: DEFAULT; Schema: public; Owner: - 212 | -- 213 | 214 | ALTER TABLE ONLY public.shelves ALTER COLUMN id SET DEFAULT nextval('public.shelves_id_seq'::regclass); 215 | 216 | 217 | -- 218 | -- Name: users id; Type: DEFAULT; Schema: public; Owner: - 219 | -- 220 | 221 | ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); 222 | 223 | 224 | -- 225 | -- Name: ar_internal_metadata ar_internal_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: - 226 | -- 227 | 228 | ALTER TABLE ONLY public.ar_internal_metadata 229 | ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key); 230 | 231 | 232 | -- 233 | -- Name: books books_pkey; Type: CONSTRAINT; Schema: public; Owner: - 234 | -- 235 | 236 | ALTER TABLE ONLY public.books 237 | ADD CONSTRAINT books_pkey PRIMARY KEY (id); 238 | 239 | 240 | -- 241 | -- Name: highlights highlights_pkey; Type: CONSTRAINT; Schema: public; Owner: - 242 | -- 243 | 244 | ALTER TABLE ONLY public.highlights 245 | ADD CONSTRAINT highlights_pkey PRIMARY KEY (id); 246 | 247 | 248 | -- 249 | -- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - 250 | -- 251 | 252 | ALTER TABLE ONLY public.schema_migrations 253 | ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); 254 | 255 | 256 | -- 257 | -- Name: shelves shelves_pkey; Type: CONSTRAINT; Schema: public; Owner: - 258 | -- 259 | 260 | ALTER TABLE ONLY public.shelves 261 | ADD CONSTRAINT shelves_pkey PRIMARY KEY (id); 262 | 263 | 264 | -- 265 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - 266 | -- 267 | 268 | ALTER TABLE ONLY public.users 269 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 270 | 271 | 272 | -- 273 | -- Name: index_books_on_shelf_id; Type: INDEX; Schema: public; Owner: - 274 | -- 275 | 276 | CREATE INDEX index_books_on_shelf_id ON public.books USING btree (shelf_id); 277 | 278 | 279 | -- 280 | -- Name: index_books_on_user_id; Type: INDEX; Schema: public; Owner: - 281 | -- 282 | 283 | CREATE INDEX index_books_on_user_id ON public.books USING btree (user_id); 284 | 285 | 286 | -- 287 | -- Name: index_books_on_user_id_and_isbn; Type: INDEX; Schema: public; Owner: - 288 | -- 289 | 290 | CREATE UNIQUE INDEX index_books_on_user_id_and_isbn ON public.books USING btree (user_id, isbn); 291 | 292 | 293 | -- 294 | -- Name: index_highlights_on_book_id; Type: INDEX; Schema: public; Owner: - 295 | -- 296 | 297 | CREATE INDEX index_highlights_on_book_id ON public.highlights USING btree (book_id); 298 | 299 | 300 | -- 301 | -- Name: index_shelves_on_user_id; Type: INDEX; Schema: public; Owner: - 302 | -- 303 | 304 | CREATE INDEX index_shelves_on_user_id ON public.shelves USING btree (user_id); 305 | 306 | 307 | -- 308 | -- Name: index_users_on_email; Type: INDEX; Schema: public; Owner: - 309 | -- 310 | 311 | CREATE UNIQUE INDEX index_users_on_email ON public.users USING btree (email); 312 | 313 | 314 | -- 315 | -- Name: index_users_on_slug; Type: INDEX; Schema: public; Owner: - 316 | -- 317 | 318 | CREATE UNIQUE INDEX index_users_on_slug ON public.users USING btree (slug); 319 | 320 | 321 | -- 322 | -- Name: highlights fk_rails_198ee9796d; Type: FK CONSTRAINT; Schema: public; Owner: - 323 | -- 324 | 325 | ALTER TABLE ONLY public.highlights 326 | ADD CONSTRAINT fk_rails_198ee9796d FOREIGN KEY (book_id) REFERENCES public.books(id) ON DELETE CASCADE; 327 | 328 | 329 | -- 330 | -- Name: books fk_rails_5e29c313c6; Type: FK CONSTRAINT; Schema: public; Owner: - 331 | -- 332 | 333 | ALTER TABLE ONLY public.books 334 | ADD CONSTRAINT fk_rails_5e29c313c6 FOREIGN KEY (shelf_id) REFERENCES public.shelves(id) ON DELETE SET NULL; 335 | 336 | 337 | -- 338 | -- Name: shelves fk_rails_6b65d5b892; Type: FK CONSTRAINT; Schema: public; Owner: - 339 | -- 340 | 341 | ALTER TABLE ONLY public.shelves 342 | ADD CONSTRAINT fk_rails_6b65d5b892 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; 343 | 344 | 345 | -- 346 | -- Name: books fk_rails_bc582ddd02; Type: FK CONSTRAINT; Schema: public; Owner: - 347 | -- 348 | 349 | ALTER TABLE ONLY public.books 350 | ADD CONSTRAINT fk_rails_bc582ddd02 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; 351 | 352 | 353 | -- 354 | -- PostgreSQL database dump complete 355 | -- 356 | 357 | -- 358 | -- PostgreSQL database dump 359 | -- 360 | 361 | -- Dumped from database version 13.5 362 | -- Dumped by pg_dump version 13.5 363 | 364 | SET statement_timeout = 0; 365 | SET lock_timeout = 0; 366 | SET idle_in_transaction_session_timeout = 0; 367 | SET client_encoding = 'UTF8'; 368 | SET standard_conforming_strings = on; 369 | SELECT pg_catalog.set_config('search_path', '', false); 370 | SET check_function_bodies = false; 371 | SET xmloption = content; 372 | SET client_min_messages = warning; 373 | SET row_security = off; 374 | 375 | -- 376 | -- Data for Name: schema_migrations; Type: TABLE DATA; Schema: public; Owner: postgres 377 | -- 378 | 379 | INSERT INTO public.schema_migrations VALUES ('20210518144128'); 380 | INSERT INTO public.schema_migrations VALUES ('20210520081623'); 381 | INSERT INTO public.schema_migrations VALUES ('20210520122458'); 382 | INSERT INTO public.schema_migrations VALUES ('20210609123416'); 383 | INSERT INTO public.schema_migrations VALUES ('20210609195207'); 384 | INSERT INTO public.schema_migrations VALUES ('20210624200935'); 385 | INSERT INTO public.schema_migrations VALUES ('20211217153054'); 386 | INSERT INTO public.schema_migrations VALUES ('20220103113949'); 387 | INSERT INTO public.schema_migrations VALUES ('20220113204455'); 388 | INSERT INTO public.schema_migrations VALUES ('20220116114024'); 389 | INSERT INTO public.schema_migrations VALUES ('20220125191717'); 390 | INSERT INTO public.schema_migrations VALUES ('20220125193806'); 391 | INSERT INTO public.schema_migrations VALUES ('20220129201723'); 392 | INSERT INTO public.schema_migrations VALUES ('20220130120749'); 393 | INSERT INTO public.schema_migrations VALUES ('20220130212907'); 394 | INSERT INTO public.schema_migrations VALUES ('20220131112523'); 395 | INSERT INTO public.schema_migrations VALUES ('20220131115959'); 396 | INSERT INTO public.schema_migrations VALUES ('20220131124929'); 397 | INSERT INTO public.schema_migrations VALUES ('20220201201413'); 398 | INSERT INTO public.schema_migrations VALUES ('20220203060659'); 399 | INSERT INTO public.schema_migrations VALUES ('20220205001018'); 400 | INSERT INTO public.schema_migrations VALUES ('20220205192708'); 401 | INSERT INTO public.schema_migrations VALUES ('20220205194930'); 402 | INSERT INTO public.schema_migrations VALUES ('20220218225900'); 403 | 404 | 405 | -- 406 | -- PostgreSQL database dump complete 407 | -- 408 | 409 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: "postgres:13-alpine" 4 | restart: always 5 | env_file: 6 | - .env 7 | expose: 8 | - '5432' 9 | volumes: 10 | - /root/data/library/db_13_3:/var/lib/postgresql/data 11 | shm_size: '2gb' 12 | logging: 13 | driver: journald 14 | 15 | web: 16 | build: . 17 | depends_on: 18 | - db 19 | links: 20 | - db 21 | restart: always 22 | volumes: 23 | - /root/data/library/books:/app/public/books/image 24 | - /root/data/library/highlights:/app/public/highlights/image 25 | env_file: 26 | - .env 27 | logging: 28 | driver: journald 29 | labels: 30 | - "traefik.http.routers.library.rule=Host(`library.emadelsaid.com`)" 31 | 32 | backup: 33 | build: . 34 | command: bin/backup 35 | restart: always 36 | volumes: 37 | - /root/data/library/backups:/backups 38 | depends_on: 39 | - db 40 | links: 41 | - db 42 | env_file: 43 | - .env 44 | logging: 45 | driver: journald 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/emad-elsaid/library 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/google/uuid v1.3.0 7 | github.com/gorilla/csrf v1.7.1 8 | github.com/gorilla/sessions v1.2.1 9 | github.com/jmoiron/sqlx v1.3.4 10 | github.com/lib/pq v1.10.4 11 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b 12 | golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 13 | ) 14 | 15 | require ( 16 | github.com/golang/protobuf v1.4.2 // indirect 17 | github.com/gorilla/securecookie v1.1.1 // indirect 18 | github.com/pkg/errors v0.9.1 // indirect 19 | golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect 20 | google.golang.org/appengine v1.6.6 // indirect 21 | google.golang.org/protobuf v1.25.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 8 | cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= 9 | cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= 10 | cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= 11 | cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= 12 | cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= 13 | cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= 14 | cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= 15 | cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= 16 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 17 | cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= 18 | cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= 19 | cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= 20 | cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= 21 | cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= 22 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 23 | cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= 24 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 25 | cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= 26 | cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= 27 | cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= 28 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 29 | cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= 30 | cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= 31 | cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= 32 | cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= 33 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 34 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 35 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 36 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 37 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 38 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 39 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 40 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 41 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 42 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 43 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 44 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 45 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 46 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 47 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 48 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 49 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 50 | github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= 51 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 52 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 53 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 54 | github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 55 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 56 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 57 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 58 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 59 | github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 60 | github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 61 | github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 62 | github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 63 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 64 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 65 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 66 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 67 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 68 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 69 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 70 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 71 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 72 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 73 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 74 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 75 | github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= 76 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 77 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 78 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 79 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 80 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 81 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 82 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 83 | github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 84 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 85 | github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k= 86 | github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 87 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 88 | github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 89 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 90 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 91 | github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 92 | github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 93 | github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 94 | github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 95 | github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 96 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 97 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 98 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 99 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 100 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 101 | github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE= 102 | github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA= 103 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 104 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 105 | github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= 106 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 107 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 108 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 109 | github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 110 | github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w= 111 | github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= 112 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 113 | github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= 114 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 115 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 116 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 117 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 118 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 119 | github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= 120 | github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 121 | github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= 122 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 123 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 124 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 125 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 126 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 127 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 128 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 129 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 130 | github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 131 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 132 | github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 133 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 134 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 135 | go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 136 | go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 137 | go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 138 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 139 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 140 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 141 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 142 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 143 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 144 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 145 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 146 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 147 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 148 | golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 149 | golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 150 | golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 151 | golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= 152 | golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= 153 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 154 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= 155 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 156 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 157 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 158 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 159 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 160 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 161 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 162 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 163 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= 164 | golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 165 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 166 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 167 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 168 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 169 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 170 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 171 | golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 172 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 173 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 174 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 175 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 176 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 177 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 178 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 179 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 180 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 181 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 182 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 183 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 184 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 185 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 186 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 187 | golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 188 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 189 | golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 190 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 191 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 192 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 193 | golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 194 | golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 195 | golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 196 | golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 197 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 198 | golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 199 | golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA= 200 | golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 201 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 202 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 203 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 204 | golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 205 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 206 | golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= 207 | golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 208 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 209 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 210 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 211 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 212 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 213 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 214 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 215 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 216 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 217 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 218 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 219 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 220 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 221 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 222 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 223 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 224 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 225 | golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 226 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 227 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 228 | golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 229 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 230 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 231 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 232 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 233 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 235 | golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 236 | golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 237 | golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 238 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 239 | golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 240 | golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 241 | golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 242 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 243 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 244 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 245 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 246 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 247 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 248 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 249 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 250 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 251 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 252 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 253 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 254 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 255 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 256 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 257 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 258 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 259 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 260 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 261 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 262 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 263 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 264 | golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 265 | golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 266 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 267 | golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 268 | golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 269 | golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 270 | golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 271 | golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 272 | golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 273 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 274 | golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 275 | golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 276 | golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 277 | golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 278 | golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 279 | golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 280 | golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 281 | golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= 282 | golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 283 | golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 284 | golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 285 | golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 286 | golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 287 | golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 288 | golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 289 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 290 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 291 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 292 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 293 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 294 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 295 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 296 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 297 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 298 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 299 | google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 300 | google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 301 | google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 302 | google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 303 | google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 304 | google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 305 | google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 306 | google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 307 | google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 308 | google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= 309 | google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= 310 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 311 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 312 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 313 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 314 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 315 | google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= 316 | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 317 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 318 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 319 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 320 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 321 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 322 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 323 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 324 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 325 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 326 | google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 327 | google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 328 | google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 329 | google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 330 | google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 331 | google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= 332 | google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 333 | google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 334 | google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 335 | google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 336 | google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 337 | google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 338 | google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 339 | google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 340 | google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= 341 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 342 | google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= 343 | google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 344 | google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 345 | google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 346 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 347 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 348 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 349 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 350 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 351 | google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 352 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 353 | google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 354 | google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= 355 | google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= 356 | google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 357 | google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 358 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 359 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 360 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 361 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 362 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 363 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 364 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 365 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 366 | google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 367 | google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= 368 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 369 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 370 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 371 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 372 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 373 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 374 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 375 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 376 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 377 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 378 | honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 379 | honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 380 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 381 | rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= 382 | rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= 383 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "database/sql" 7 | "fmt" 8 | "html/template" 9 | "image" 10 | _ "image/gif" 11 | "image/jpeg" 12 | _ "image/png" 13 | "io" 14 | "log" 15 | "net/http" 16 | "os" 17 | "path" 18 | "reflect" 19 | "strings" 20 | 21 | "github.com/google/uuid" 22 | "golang.org/x/image/draw" 23 | ) 24 | 25 | func Helpers() { 26 | HELPER("partial", func(path string, data interface{}) (template.HTML, error) { 27 | return template.HTML(partial(path, data)), nil 28 | }) 29 | 30 | HELPER("meta_property", func(meta map[string]string, name string) template.HTML { 31 | if meta == nil { 32 | return "" 33 | } 34 | 35 | v, ok := meta[name] 36 | if !ok { 37 | return "" 38 | } 39 | 40 | tag := fmt.Sprintf(``, template.HTMLEscapeString(name), template.HTMLEscapeString(v)) 41 | return template.HTML(tag) 42 | }) 43 | 44 | HELPER("meta_name", func(meta map[string]string, name string) template.HTML { 45 | if meta == nil { 46 | return "" 47 | } 48 | 49 | v, ok := meta[name] 50 | if !ok { 51 | return "" 52 | } 53 | 54 | tag := fmt.Sprintf(``, template.HTMLEscapeString(name), template.HTMLEscapeString(v)) 55 | return template.HTML(tag) 56 | }) 57 | 58 | HELPER("can", can) 59 | 60 | HELPER("include", func(list []string, str string) bool { 61 | for _, i := range list { 62 | if i == str { 63 | return true 64 | } 65 | } 66 | 67 | return false 68 | }) 69 | 70 | HELPER("book_cover", book_cover) 71 | 72 | HELPER("simple_format", func(str string) (template.HTML, error) { 73 | return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(str), "\n", "
")), nil 74 | }) 75 | 76 | HELPER("shelf_books", func(shelfID int64) ([]ShelfBooksRow, error) { 77 | return Q.ShelfBooks(context.Background(), sql.NullInt64{Valid: true, Int64: shelfID}) 78 | }) 79 | 80 | HELPER("has_field", func(v interface{}, name string) bool { 81 | rv := reflect.ValueOf(v) 82 | if rv.Kind() == reflect.Ptr { 83 | rv = rv.Elem() 84 | } 85 | if rv.Kind() == reflect.Struct { 86 | return rv.FieldByName(name).IsValid() 87 | } 88 | if rv.Kind() == reflect.Map { 89 | val := rv.MapIndex(reflect.ValueOf(name)) 90 | return val.IsValid() 91 | } 92 | return false 93 | }) 94 | 95 | HELPER("last", func(v interface{}) interface{} { 96 | rv := reflect.ValueOf(v) 97 | if rv.Kind() == reflect.Ptr { 98 | rv = rv.Elem() 99 | } 100 | 101 | if rv.Kind() != reflect.Slice { 102 | return false 103 | } 104 | 105 | if rv.Len() == 0 { 106 | return false 107 | } 108 | 109 | return rv.Index(rv.Len() - 1) 110 | }) 111 | 112 | HELPER("books_count", func(u int64) int64 { 113 | c, _ := Q.BooksCount(context.Background(), u) 114 | return c 115 | }) 116 | 117 | HELPER("sha256", func() interface{} { 118 | cache := map[string]string{} 119 | return func(p string) (string, error) { 120 | if v, ok := cache[p]; ok { 121 | return v, nil 122 | } 123 | 124 | f, err := os.Open(p) 125 | if err != nil { 126 | return "", err 127 | } 128 | 129 | d, err := io.ReadAll(f) 130 | if err != nil { 131 | return "", err 132 | } 133 | 134 | cache[p] = fmt.Sprintf("%x", sha256.Sum256(d)) 135 | return cache[p], nil 136 | } 137 | }()) 138 | } 139 | 140 | func loggedin(r *http.Request) bool { 141 | _, ok := SESSION(r).Values["current_user"] 142 | return ok 143 | } 144 | 145 | func current_user(r *http.Request) *User { 146 | id, ok := SESSION(r).Values["current_user"] 147 | if !ok { 148 | return nil 149 | } 150 | 151 | user_id, ok := id.(int64) 152 | if !ok { 153 | return nil 154 | } 155 | 156 | user, err := Q.User(r.Context(), user_id) 157 | if err != nil { 158 | return nil 159 | } 160 | 161 | return &user 162 | } 163 | 164 | func book_cover(image, google_books_id string) string { 165 | if len(image) > 0 { 166 | return "/books/image/" + image 167 | } 168 | 169 | if len(google_books_id) > 0 { 170 | const googleBookURL = "https://books.google.com/books/content?id=%s&printsec=frontcover&img=1&zoom=1" 171 | return fmt.Sprintf(googleBookURL, google_books_id) 172 | } 173 | 174 | return "/default_book" 175 | } 176 | 177 | func can(who *User, do string, what interface{}) bool { 178 | err := fmt.Sprintf("Verb %s not handled for %#v", do, what) 179 | 180 | switch w := what.(type) { 181 | case nil: 182 | switch do { 183 | case "login": 184 | return who == nil 185 | case "logout": 186 | return who != nil 187 | default: 188 | log.Fatal(err) 189 | } 190 | 191 | case User: 192 | switch do { 193 | case "create_book", "list_shelves", "edit", "create_shelf", "show_shelves": 194 | return who != nil && who.ID == w.ID 195 | default: 196 | log.Fatal(err) 197 | } 198 | 199 | case *User: 200 | switch do { 201 | case "create_book", "list_shelves": 202 | return who != nil && who.ID == w.ID 203 | default: 204 | log.Fatal(err) 205 | } 206 | 207 | case BookByIsbnAndUserRow: 208 | switch do { 209 | case "edit", "highlight", "create_highlight", "edit_highlight", "delete", "delete_highlight": 210 | return who != nil && who.ID == w.UserID 211 | default: 212 | log.Fatal(err) 213 | } 214 | 215 | case Shelf: 216 | switch do { 217 | case "edit", "delete": 218 | return who != nil && who.ID == w.UserID 219 | case "up": 220 | return who != nil && who.ID == w.UserID && w.Position > 1 221 | case "down": 222 | return who != nil && who.ID == w.UserID 223 | default: 224 | log.Fatal(err) 225 | } 226 | 227 | default: 228 | log.Fatal(err) 229 | } 230 | return true 231 | } 232 | 233 | func ImageResize(in io.Reader, out io.Writer, w, h int) error { 234 | src, _, err := image.Decode(in) 235 | if err != nil { 236 | return err 237 | } 238 | 239 | dst := image.NewRGBA(image.Rect(0, 0, w, h)) 240 | draw.NearestNeighbor.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil) 241 | 242 | return jpeg.Encode(out, dst, &jpeg.Options{Quality: 90}) 243 | } 244 | 245 | func UploadImage(in io.Reader, p string, w, h int) (string, error) { 246 | name := uuid.New().String() 247 | 248 | out, err := os.Create(path.Join(p, name)) 249 | if err != nil { 250 | return "", err 251 | } 252 | 253 | err = ImageResize(in, out, w, h) 254 | if err != nil { 255 | return "", err 256 | } 257 | 258 | return name, nil 259 | } 260 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path" 11 | "strings" 12 | 13 | "github.com/google/uuid" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | const ( 18 | BOOK_COVER_PATH = "public/books/image" 19 | HIGHLIGHT_IMAGE_PATH = "public/highlights/image" 20 | ) 21 | 22 | func main() { 23 | google := &oauth2.Config{ 24 | ClientID: os.Getenv("GOOGLE_CLIENT_ID"), 25 | ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), 26 | RedirectURL: os.Getenv("DOMAIN") + "/auth/google/callback", 27 | Scopes: []string{"email", "profile"}, 28 | Endpoint: oauth2.Endpoint{ 29 | AuthURL: "https://accounts.google.com/o/oauth2/auth", 30 | TokenURL: "https://oauth2.googleapis.com/token", 31 | AuthStyle: oauth2.AuthStyleInParams, 32 | }, 33 | } 34 | 35 | GET("/", func(w Response, r Request) Output { 36 | user := current_user(r) 37 | if user != nil { 38 | return Redirect(fmt.Sprintf("/users/%s", user.Slug)) 39 | } 40 | 41 | return Render("wide_layout", "index", Locals{"csrf": CSRF(r)}) 42 | }) 43 | 44 | GET("/privacy", func(w Response, r Request) Output { 45 | return Render("layout", "privacy", Locals{ 46 | "current_user": current_user(r), 47 | "csrf": CSRF(r), 48 | }) 49 | }) 50 | 51 | POST("/auth/google", func(w Response, r Request) Output { 52 | origin := r.FormValue("origin") 53 | if !strings.HasPrefix(origin, "/") { 54 | origin = "/" 55 | } 56 | 57 | state := uuid.New().String() 58 | s := SESSION(r) 59 | s.Values["state"] = state 60 | s.Values["origin"] = origin 61 | if err := s.Save(r, w); err != nil { 62 | return InternalServerError(err) 63 | } 64 | 65 | return Redirect(google.AuthCodeURL(state)) 66 | }) 67 | 68 | GET("/auth/google/callback", func(w Response, r Request) Output { 69 | state := SESSION(r).Values["state"] 70 | param := r.FormValue("state") 71 | if state != param { 72 | return BadRequest 73 | } 74 | 75 | tok, err := google.Exchange(context.Background(), r.URL.Query().Get("code")) 76 | if err != nil { 77 | return BadRequest 78 | } 79 | 80 | client := google.Client(context.Background(), tok) 81 | resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") 82 | if err != nil { 83 | return Unauthorized 84 | } 85 | 86 | body, err := io.ReadAll(resp.Body) 87 | if err != nil { 88 | return InternalServerError(err) 89 | } 90 | 91 | user := struct { 92 | Name string `json:"name"` 93 | Email string `json:"email"` 94 | Picture string `json:"picture"` 95 | }{} 96 | 97 | if err = json.Unmarshal(body, &user); err != nil { 98 | return InternalServerError(err) 99 | } 100 | 101 | u, err := Q.Signup(r.Context(), SignupParams{ 102 | Name: NullString(user.Name), 103 | Image: NullString(user.Picture), 104 | Slug: uuid.New().String(), 105 | Email: NullString(user.Email), 106 | }) 107 | if err != nil { 108 | return InternalServerError(err) 109 | } 110 | 111 | s := SESSION(r) 112 | s.Values["current_user"] = u 113 | if err = s.Save(r, w); err != nil { 114 | return InternalServerError(err) 115 | } 116 | 117 | origin, ok := s.Values["origin"].(string) 118 | if !ok { 119 | origin = "/" 120 | } 121 | 122 | return Redirect(origin) 123 | }) 124 | 125 | GET("/logout", func(w Response, r Request) Output { 126 | s := SESSION(r) 127 | s.Values = map[interface{}]interface{}{} 128 | s.Save(r, w) 129 | return Redirect("/") 130 | }) 131 | 132 | GET("/users/{user}", func(w Response, r Request) Output { 133 | vars := VARS(r) 134 | 135 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 136 | if err != nil { 137 | return NotFound 138 | } 139 | 140 | data := Locals{ 141 | "title": user.Name.String, 142 | "csrf": CSRF(r), 143 | "current_user": current_user(r), 144 | "user": user, 145 | } 146 | 147 | unshelved_books, err := Q.UserUnshelvedBooks(r.Context(), user.ID) 148 | if err != nil { 149 | return InternalServerError(err) 150 | } 151 | if len(unshelved_books) > 0 { 152 | data["unshelved_books"] = unshelved_books 153 | } 154 | 155 | data["shelves"], err = Q.Shelves(r.Context(), user.ID) 156 | 157 | return Render("layout", "users/show", data) 158 | }) 159 | 160 | GET("/users/{user}/edit", func(w Response, r Request) Output { 161 | actor := current_user(r) 162 | vars := VARS(r) 163 | 164 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 165 | if err != nil { 166 | return NotFound 167 | } 168 | 169 | if !can(actor, "edit", user) { 170 | return Unauthorized 171 | } 172 | 173 | return Render("layout", "users/edit", Locals{ 174 | "current_user": actor, 175 | "user": user, 176 | "errors": ValidationErrors{}, 177 | "csrf": CSRF(r), 178 | }) 179 | }, loggedinMiddleware) 180 | 181 | POST("/users/{user}", func(w Response, r Request) Output { 182 | actor := current_user(r) 183 | vars := VARS(r) 184 | 185 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 186 | if err != nil { 187 | return NotFound 188 | } 189 | 190 | if !can(actor, "edit", user) { 191 | return Unauthorized 192 | } 193 | 194 | // TODO find a way to remove this duplication 195 | params := UpdateUserParams{ 196 | Description: NullString(r.FormValue("description")), 197 | AmazonAssociatesID: NullString(r.FormValue("amazon_associates_id")), 198 | Facebook: NullString(r.FormValue("facebook")), 199 | Twitter: NullString(r.FormValue("twitter")), 200 | Linkedin: NullString(r.FormValue("linkedin")), 201 | Instagram: NullString(r.FormValue("instagram")), 202 | Phone: NullString(r.FormValue("phone")), 203 | Whatsapp: NullString(r.FormValue("whatsapp")), 204 | Telegram: NullString(r.FormValue("telegram")), 205 | ID: user.ID, 206 | } 207 | errors := params.Validate() 208 | if len(errors) != 0 { 209 | user.Description = params.Description 210 | user.AmazonAssociatesID = params.AmazonAssociatesID 211 | user.Facebook = params.Facebook 212 | user.Twitter = params.Twitter 213 | user.Linkedin = params.Linkedin 214 | user.Instagram = params.Instagram 215 | user.Phone = params.Phone 216 | user.Whatsapp = params.Whatsapp 217 | user.Telegram = params.Telegram 218 | return Render("layout", "users/edit", Locals{ 219 | "current_user": actor, 220 | "user": user, 221 | "errors": errors, 222 | "csrf": CSRF(r), 223 | }) 224 | } 225 | 226 | if err = Q.UpdateUser(r.Context(), params); err != nil { 227 | return InternalServerError(err) 228 | } 229 | 230 | return Redirect(fmt.Sprintf("/users/%s", user.Slug)) 231 | }, loggedinMiddleware) 232 | 233 | GET("/users/{user}/books/new", func(w Response, r Request) Output { 234 | actor := current_user(r) 235 | vars := VARS(r) 236 | 237 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 238 | if err != nil { 239 | return NotFound 240 | } 241 | 242 | if !can(actor, "create_book", user) { 243 | return Unauthorized 244 | } 245 | 246 | return Render("layout", "books/new", Locals{ 247 | "current_user": actor, 248 | "user": user, 249 | "errors": ValidationErrors{}, 250 | "csrf": CSRF(r), 251 | }) 252 | }, loggedinMiddleware) 253 | 254 | POST("/users/{user}/books", func(w Response, r Request) Output { 255 | actor := current_user(r) 256 | vars := VARS(r) 257 | 258 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 259 | if err != nil { 260 | return NotFound 261 | } 262 | 263 | if !can(actor, "create_book", user) { 264 | return Unauthorized 265 | } 266 | 267 | r.ParseMultipartForm(MB * 10) 268 | params := NewBookParams{ 269 | Title: r.FormValue("title"), 270 | Isbn: r.FormValue("isbn"), 271 | Author: r.FormValue("author"), 272 | Subtitle: r.FormValue("subtitle"), 273 | Description: r.FormValue("description"), 274 | Publisher: r.FormValue("publisher"), 275 | PageCount: atoi32(r.FormValue("page_count")), 276 | PageRead: atoi32(r.FormValue("page_read")), 277 | GoogleBooksID: NullString(r.FormValue("google_books_id")), 278 | UserID: user.ID, 279 | } 280 | errors := params.Validate() 281 | 282 | file, _, _ := r.FormFile("image") 283 | if file != nil { 284 | ValidateImage(file, "image", "Image", errors, 3000, 4000) 285 | file.Seek(0, os.SEEK_SET) 286 | } 287 | 288 | if len(errors) != 0 { 289 | return Render("layout", "books/new", Locals{ 290 | "book": params, 291 | "current_user": actor, 292 | "user": user, 293 | "errors": errors, 294 | "csrf": CSRF(r), 295 | }) 296 | } 297 | 298 | book, err := Q.NewBook(r.Context(), params) 299 | if err != nil { 300 | return InternalServerError(err) 301 | } 302 | 303 | if file != nil { 304 | name, err := UploadImage(file, BOOK_COVER_PATH, 432, 576) 305 | if err != nil { 306 | return InternalServerError(err) 307 | } 308 | 309 | err = Q.UpdateBookImage(r.Context(), UpdateBookImageParams{ 310 | Image: NullString(name), 311 | ID: book.ID, 312 | }) 313 | if err != nil { 314 | return InternalServerError(err) 315 | } 316 | } 317 | 318 | return Redirect(fmt.Sprintf("/users/%s/books/%s", user.Slug, book.Isbn)) 319 | }) 320 | 321 | GET("/users/{user}/books/{isbn}", func(w Response, r Request) Output { 322 | vars := VARS(r) 323 | 324 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 325 | if err != nil { 326 | return InternalServerError(err) 327 | } 328 | 329 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 330 | UserID: user.ID, 331 | Isbn: vars["isbn"], 332 | }) 333 | if err != nil { 334 | return NotFound 335 | } 336 | 337 | highlights, err := Q.Highlights(r.Context(), book.ID) 338 | if err != nil { 339 | return InternalServerError(err) 340 | } 341 | 342 | shelves, err := Q.Shelves(r.Context(), user.ID) 343 | if err != nil { 344 | return InternalServerError(err) 345 | } 346 | 347 | return Render("layout", "books/show", Locals{ 348 | "current_user": current_user(r), 349 | "user": user, 350 | "title": book.Title, 351 | "book": book, 352 | "shelves": shelves, 353 | "highlights": highlights, 354 | "csrf": CSRF(r), 355 | "meta": map[string]string{ 356 | "og:title": book.Title, 357 | "author": book.Author, 358 | "description": book.Description, 359 | "og:description": book.Description, 360 | "og:type": "article", 361 | "og:image": book_cover(book.Image.String, book.GoogleBooksID.String), 362 | "twitter:image": book_cover(book.Image.String, book.GoogleBooksID.String), 363 | "twitter:card": "summary", 364 | "twitter:title": book.Title, 365 | }, 366 | }) 367 | }) 368 | 369 | GET("/users/{user}/books/{isbn}/edit", func(w Response, r Request) Output { 370 | actor := current_user(r) 371 | vars := VARS(r) 372 | 373 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 374 | if err != nil { 375 | return NotFound 376 | } 377 | 378 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 379 | UserID: user.ID, 380 | Isbn: vars["isbn"], 381 | }) 382 | if err != nil { 383 | return NotFound 384 | } 385 | 386 | if !can(actor, "edit", book) { 387 | return Unauthorized 388 | } 389 | 390 | return Render("layout", "books/new", Locals{ 391 | "current_user": actor, 392 | "user": user, 393 | "book": book, 394 | "csrf": CSRF(r), 395 | "errors": ValidationErrors{}, 396 | }) 397 | }, loggedinMiddleware) 398 | 399 | POST("/users/{user}/books/{isbn}", func(w Response, r Request) Output { 400 | actor := current_user(r) 401 | vars := VARS(r) 402 | 403 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 404 | if err != nil { 405 | return NotFound 406 | } 407 | 408 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 409 | UserID: user.ID, 410 | Isbn: vars["isbn"], 411 | }) 412 | if err != nil { 413 | return NotFound 414 | } 415 | 416 | if !can(actor, "edit", book) { 417 | return Unauthorized 418 | } 419 | 420 | r.ParseMultipartForm(MB * 10) 421 | 422 | params := UpdateBookParams{ 423 | Title: r.FormValue("title"), 424 | Author: r.FormValue("author"), 425 | Subtitle: r.FormValue("subtitle"), 426 | Description: r.FormValue("description"), 427 | Publisher: r.FormValue("publisher"), 428 | PageCount: atoi32(r.FormValue("page_count")), 429 | PageRead: atoi32(r.FormValue("page_read")), 430 | ID: book.ID, 431 | } 432 | 433 | errors := params.Validate() 434 | file, _, _ := r.FormFile("image") 435 | if file != nil { 436 | ValidateImage(file, "image", "Image", errors, 600, 600) 437 | file.Seek(0, os.SEEK_SET) 438 | } 439 | 440 | if len(errors) > 0 { 441 | book.Title = params.Title 442 | book.Author = params.Author 443 | book.Subtitle = params.Subtitle 444 | book.Description = params.Description 445 | book.Publisher = params.Publisher 446 | book.PageCount = params.PageCount 447 | book.PageRead = params.PageRead 448 | return Render("layout", "books/new", Locals{ 449 | "current_user": actor, 450 | "user": user, 451 | "book": book, 452 | "csrf": CSRF(r), 453 | "errors": errors, 454 | }) 455 | } 456 | 457 | if err = Q.UpdateBook(r.Context(), params); err != nil { 458 | return InternalServerError(err) 459 | } 460 | 461 | if file != nil { 462 | name, err := UploadImage(file, BOOK_COVER_PATH, 432, 576) 463 | if err != nil { 464 | return InternalServerError(err) 465 | } 466 | 467 | oldname := path.Join(BOOK_COVER_PATH, book.Image.String) 468 | if book.Image.Valid && len(book.Image.String) > 0 { // if image is set 469 | if _, err = os.Stat(oldname); err == nil { // and it exists 470 | os.Remove(oldname) // delete it 471 | } 472 | } 473 | 474 | err = Q.UpdateBookImage(r.Context(), UpdateBookImageParams{ 475 | Image: NullString(name), 476 | ID: book.ID, 477 | }) 478 | if err != nil { 479 | return InternalServerError(err) 480 | } 481 | } 482 | 483 | return Redirect(fmt.Sprintf("/users/%s/books/%s", user.Slug, vars["isbn"])) 484 | }, loggedinMiddleware) 485 | 486 | DELETE("/users/{user}/books/{isbn}", func(w Response, r Request) Output { 487 | actor := current_user(r) 488 | vars := VARS(r) 489 | 490 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 491 | if err != nil { 492 | return NotFound 493 | } 494 | 495 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 496 | UserID: user.ID, 497 | Isbn: vars["isbn"], 498 | }) 499 | if err != nil { 500 | return NotFound 501 | } 502 | 503 | if !can(actor, "delete", book) { 504 | return Unauthorized 505 | } 506 | 507 | images, err := Q.HighlightsWithImages(r.Context(), book.ID) 508 | if err != nil { 509 | return InternalServerError(err) 510 | } 511 | for _, v := range images { 512 | os.Remove(path.Join(HIGHLIGHT_IMAGE_PATH, v.String)) 513 | } 514 | 515 | if book.Image.Valid && len(book.Image.String) > 0 { 516 | os.Remove(path.Join(BOOK_COVER_PATH, book.Image.String)) 517 | } 518 | 519 | if err = Q.DeleteBook(r.Context(), book.ID); err != nil { 520 | return InternalServerError(err) 521 | } 522 | 523 | return Redirect(fmt.Sprintf("/users/%s", user.Slug)) 524 | }, loggedinMiddleware) 525 | 526 | POST("/users/{user}/books/{isbn}/shelf", func(w Response, r Request) Output { 527 | actor := current_user(r) 528 | vars := VARS(r) 529 | 530 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 531 | if err != nil { 532 | return NotFound 533 | } 534 | 535 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 536 | UserID: user.ID, 537 | Isbn: vars["isbn"], 538 | }) 539 | if err != nil { 540 | return NotFound 541 | } 542 | 543 | if !can(actor, "edit", book) { 544 | return Unauthorized 545 | } 546 | 547 | shelf, err := Q.ShelfByIdAndUser(r.Context(), ShelfByIdAndUserParams{ 548 | UserID: user.ID, 549 | ID: atoi64(r.FormValue("shelf_id")), 550 | }) 551 | if err == nil && !can(actor, "edit", shelf) { 552 | return Unauthorized 553 | } 554 | 555 | err = Q.MoveBookToShelf(r.Context(), MoveBookToShelfParams{ 556 | ShelfID: sql.NullInt64{Int64: shelf.ID, Valid: err == nil}, 557 | ID: book.ID, 558 | }) 559 | if err != nil { 560 | return InternalServerError(err) 561 | } 562 | 563 | return Redirect(fmt.Sprintf("/users/%s/books/%s", user.Slug, vars["isbn"])) 564 | }, loggedinMiddleware) 565 | 566 | POST("/users/{user}/books/{isbn}/complete", func(w Response, r Request) Output { 567 | actor := current_user(r) 568 | vars := VARS(r) 569 | 570 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 571 | if err != nil { 572 | return NotFound 573 | } 574 | 575 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 576 | UserID: user.ID, 577 | Isbn: vars["isbn"], 578 | }) 579 | if err != nil { 580 | return NotFound 581 | } 582 | 583 | if !can(actor, "edit", book) { 584 | return Unauthorized 585 | } 586 | 587 | err = Q.CompleteBook(r.Context(), book.ID) 588 | if err != nil { 589 | return InternalServerError(err) 590 | } 591 | 592 | return Redirect(fmt.Sprintf("/users/%s/books/%s", user.Slug, book.Isbn)) 593 | }, loggedinMiddleware) 594 | 595 | GET("/users/{user}/shelves", func(w Response, r Request) Output { 596 | actor := current_user(r) 597 | vars := VARS(r) 598 | 599 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 600 | if err != nil { 601 | return NotFound 602 | } 603 | 604 | if !can(actor, "show_shelves", user) { 605 | return Unauthorized 606 | } 607 | 608 | shelves, err := Q.Shelves(r.Context(), user.ID) 609 | if err != nil { 610 | return InternalServerError(err) 611 | } 612 | 613 | return Render("layout", "shelves/index", Locals{ 614 | "current_user": actor, 615 | "user": user, 616 | "shelves": shelves, 617 | "errors": ValidationErrors{}, 618 | "csrf": CSRF(r), 619 | }) 620 | }, loggedinMiddleware) 621 | 622 | POST("/users/{user}/shelves", func(w Response, r Request) Output { 623 | actor := current_user(r) 624 | vars := VARS(r) 625 | 626 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 627 | if err != nil { 628 | return NotFound 629 | } 630 | 631 | if !can(actor, "create_shelf", user) { 632 | return Unauthorized 633 | } 634 | 635 | params := NewShelfParams{ 636 | Name: r.FormValue("name"), 637 | UserID: user.ID, 638 | } 639 | 640 | errors := params.Validate() 641 | if len(errors) > 0 { 642 | shelves, err := Q.Shelves(r.Context(), user.ID) 643 | if err != nil { 644 | return InternalServerError(err) 645 | } 646 | 647 | return Render("layout", "shelves/index", Locals{ 648 | "current_user": actor, 649 | "user": user, 650 | "shelves": shelves, 651 | "shelf": params, 652 | "csrf": CSRF(r), 653 | "errors": errors, 654 | }) 655 | } 656 | 657 | if err = Q.NewShelf(r.Context(), params); err != nil { 658 | return InternalServerError(err) 659 | } 660 | 661 | return Redirect(fmt.Sprintf("/users/%s/shelves", user.Slug)) 662 | }, loggedinMiddleware) 663 | 664 | GET("/users/{user}/shelves/{shelf}/edit", func(w Response, r Request) Output { 665 | actor := current_user(r) 666 | vars := VARS(r) 667 | 668 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 669 | if err != nil { 670 | return NotFound 671 | } 672 | 673 | shelf, err := Q.ShelfByIdAndUser(r.Context(), ShelfByIdAndUserParams{ 674 | UserID: user.ID, 675 | ID: atoi64(vars["shelf"]), 676 | }) 677 | 678 | if !can(actor, "edit", shelf) { 679 | return Unauthorized 680 | } 681 | 682 | return Render("layout", "shelves/edit", Locals{ 683 | "current_user": actor, 684 | "user": user, 685 | "errors": ValidationErrors{}, 686 | "shelf": shelf, 687 | "csrf": CSRF(r), 688 | }) 689 | }, loggedinMiddleware) 690 | 691 | POST("/users/{user}/shelves/{shelf}", func(w Response, r Request) Output { 692 | actor := current_user(r) 693 | vars := VARS(r) 694 | 695 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 696 | if err != nil { 697 | return NotFound 698 | } 699 | 700 | shelf, err := Q.ShelfByIdAndUser(r.Context(), ShelfByIdAndUserParams{ 701 | UserID: user.ID, 702 | ID: atoi64(vars["shelf"]), 703 | }) 704 | if err != nil { 705 | return NotFound 706 | } 707 | 708 | if !can(actor, "edit", shelf) { 709 | return Unauthorized 710 | } 711 | 712 | params := UpdateShelfParams{ 713 | Name: r.FormValue("name"), 714 | ID: shelf.ID, 715 | } 716 | 717 | errors := params.Validate() 718 | if len(errors) > 0 { 719 | return Render("layout", "shelves/edit", Locals{ 720 | "current_user": actor, 721 | "user": user, 722 | "shelf": params, 723 | "csrf": CSRF(r), 724 | "errors": errors, 725 | }) 726 | } 727 | 728 | if err = Q.UpdateShelf(r.Context(), params); err != nil { 729 | return InternalServerError(err) 730 | } 731 | 732 | return Redirect(fmt.Sprintf("/users/%s/shelves", user.Slug)) 733 | }, loggedinMiddleware) 734 | 735 | POST("/users/{user}/shelves/{shelf}/up", func(w Response, r Request) Output { 736 | actor := current_user(r) 737 | vars := VARS(r) 738 | 739 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 740 | if err != nil { 741 | return NotFound 742 | } 743 | 744 | shelf, err := Q.ShelfByIdAndUser(r.Context(), ShelfByIdAndUserParams{ 745 | UserID: user.ID, 746 | ID: atoi64(vars["shelf"]), 747 | }) 748 | if err != nil { 749 | return NotFound 750 | } 751 | 752 | if !can(actor, "up", shelf) { 753 | return Unauthorized 754 | } 755 | 756 | if err = Q.MoveShelfUp(r.Context(), shelf.ID); err != nil { 757 | return InternalServerError(err) 758 | } 759 | 760 | return Redirect(fmt.Sprintf("/users/%s/shelves", user.Slug)) 761 | }, loggedinMiddleware) 762 | 763 | POST("/users/{user}/shelves/{shelf}/down", func(w Response, r Request) Output { 764 | actor := current_user(r) 765 | vars := VARS(r) 766 | 767 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 768 | if err != nil { 769 | return NotFound 770 | } 771 | 772 | shelf, err := Q.ShelfByIdAndUser(r.Context(), ShelfByIdAndUserParams{ 773 | UserID: user.ID, 774 | ID: atoi64(vars["shelf"]), 775 | }) 776 | if err != nil { 777 | return NotFound 778 | } 779 | 780 | if !can(actor, "down", shelf) { 781 | return Unauthorized 782 | } 783 | 784 | if err = Q.MoveShelfDown(r.Context(), shelf.ID); err != nil { 785 | return InternalServerError(err) 786 | } 787 | 788 | return Redirect(fmt.Sprintf("/users/%s/shelves", user.Slug)) 789 | }, loggedinMiddleware) 790 | 791 | DELETE("/users/{user}/shelves/{shelf}", func(w Response, r Request) Output { 792 | actor := current_user(r) 793 | vars := VARS(r) 794 | 795 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 796 | if err != nil { 797 | return NotFound 798 | } 799 | 800 | shelf, err := Q.ShelfByIdAndUser(r.Context(), ShelfByIdAndUserParams{ 801 | UserID: user.ID, 802 | ID: atoi64(vars["shelf"]), 803 | }) 804 | if err != nil { 805 | return NotFound 806 | } 807 | 808 | if !can(actor, "delete", shelf) { 809 | return Unauthorized 810 | } 811 | 812 | if err = Q.RemoveShelf(r.Context(), shelf.ID); err != nil { 813 | return InternalServerError(err) 814 | } 815 | 816 | if err = Q.DeleteShelf(r.Context(), shelf.ID); err != nil { 817 | return InternalServerError(err) 818 | } 819 | 820 | return Redirect(fmt.Sprintf("/users/%s/shelves", user.Slug)) 821 | }, loggedinMiddleware) 822 | 823 | GET("/users/{user}/books/{isbn}/highlights/new", func(w Response, r Request) Output { 824 | actor := current_user(r) 825 | vars := VARS(r) 826 | 827 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 828 | if err != nil { 829 | return NotFound 830 | } 831 | 832 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 833 | UserID: user.ID, 834 | Isbn: vars["isbn"], 835 | }) 836 | if err != nil { 837 | return NotFound 838 | } 839 | 840 | if !can(actor, "create_highlight", book) { 841 | return Unauthorized 842 | } 843 | 844 | return Render("layout", "highlights/new", Locals{ 845 | "current_user": actor, 846 | "book": book, 847 | "user": user, 848 | "errors": ValidationErrors{}, 849 | "csrf": CSRF(r), 850 | }) 851 | }, loggedinMiddleware) 852 | 853 | POST("/users/{user}/books/{isbn}/highlights", func(w Response, r Request) Output { 854 | actor := current_user(r) 855 | vars := VARS(r) 856 | 857 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 858 | if err != nil { 859 | return NotFound 860 | } 861 | 862 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 863 | UserID: user.ID, 864 | Isbn: vars["isbn"], 865 | }) 866 | if err != nil { 867 | return NotFound 868 | } 869 | 870 | if !can(actor, "create_highlight", book) { 871 | return Unauthorized 872 | } 873 | 874 | r.ParseMultipartForm(MB * 10) 875 | 876 | params := NewHighlightParams{ 877 | BookID: book.ID, 878 | Page: atoi32(r.FormValue("page")), 879 | Content: r.FormValue("content"), 880 | } 881 | errors := params.Validate() 882 | 883 | file, _, _ := r.FormFile("image") 884 | if file != nil { 885 | ValidateImage(file, "image", "Image", errors, 1000, 1000) 886 | file.Seek(0, os.SEEK_SET) 887 | } 888 | 889 | if len(errors) > 0 { 890 | return Render("layout", "highlights/new", Locals{ 891 | "current_user": actor, 892 | "book": book, 893 | "user": user, 894 | "highlight": params, 895 | "errors": errors, 896 | "csrf": CSRF(r), 897 | }) 898 | } 899 | 900 | highlight, err := Q.NewHighlight(r.Context(), params) 901 | if err != nil { 902 | return InternalServerError(err) 903 | } 904 | 905 | if file != nil { 906 | name, err := UploadImage(file, HIGHLIGHT_IMAGE_PATH, 600, 600) 907 | if err != nil { 908 | return InternalServerError(err) 909 | } 910 | 911 | err = Q.UpdateHighlightImage(r.Context(), UpdateHighlightImageParams{ 912 | Image: NullString(name), 913 | ID: highlight.ID, 914 | }) 915 | if err != nil { 916 | return InternalServerError(err) 917 | } 918 | } 919 | 920 | return Redirect(fmt.Sprintf("/users/%s/books/%s", user.Slug, book.Isbn)) 921 | }, loggedinMiddleware) 922 | 923 | GET("/users/{user}/books/{isbn}/highlights/{id}/edit", func(w Response, r Request) Output { 924 | actor := current_user(r) 925 | vars := VARS(r) 926 | 927 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 928 | if err != nil { 929 | return NotFound 930 | } 931 | 932 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 933 | UserID: user.ID, 934 | Isbn: vars["isbn"], 935 | }) 936 | if err != nil { 937 | return NotFound 938 | } 939 | 940 | highlight, err := Q.HighlightByIDAndBook(r.Context(), HighlightByIDAndBookParams{ 941 | ID: atoi64(vars["id"]), 942 | BookID: book.ID, 943 | }) 944 | if err != nil { 945 | return NotFound 946 | } 947 | 948 | if !can(actor, "edit_highlight", book) { 949 | return Unauthorized 950 | } 951 | 952 | return Render("layout", "highlights/new", Locals{ 953 | "current_user": actor, 954 | "book": book, 955 | "user": user, 956 | "highlight": highlight, 957 | "errors": ValidationErrors{}, 958 | "csrf": CSRF(r), 959 | }) 960 | }, loggedinMiddleware) 961 | 962 | POST("/users/{user}/books/{isbn}/highlights/{id}", func(w Response, r Request) Output { 963 | actor := current_user(r) 964 | vars := VARS(r) 965 | 966 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 967 | if err != nil { 968 | return NotFound 969 | } 970 | 971 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 972 | UserID: user.ID, 973 | Isbn: vars["isbn"], 974 | }) 975 | if err != nil { 976 | return NotFound 977 | } 978 | 979 | highlight, err := Q.HighlightByIDAndBook(r.Context(), HighlightByIDAndBookParams{ 980 | ID: atoi64(vars["id"]), 981 | BookID: book.ID, 982 | }) 983 | if err != nil { 984 | return NotFound 985 | } 986 | 987 | if !can(actor, "edit_highlight", book) { 988 | return Unauthorized 989 | } 990 | 991 | params := UpdateHighlightParams{ 992 | Page: atoi32(r.FormValue("page")), 993 | Content: r.FormValue("content"), 994 | ID: highlight.ID, 995 | } 996 | errors := params.Validate() 997 | 998 | file, _, _ := r.FormFile("image") 999 | if file != nil { 1000 | ValidateImage(file, "image", "Image", errors, 1000, 1000) 1001 | file.Seek(0, os.SEEK_SET) 1002 | } 1003 | 1004 | if len(errors) > 0 { 1005 | highlight.Content = params.Content 1006 | highlight.Page = params.Page 1007 | return Render("layout", "highlights/new", Locals{ 1008 | "current_user": actor, 1009 | "user": user, 1010 | "book": book, 1011 | "highlight": highlight, 1012 | "errors": errors, 1013 | "csrf": CSRF(r), 1014 | }) 1015 | } 1016 | 1017 | if err = Q.UpdateHighlight(r.Context(), params); err != nil { 1018 | return InternalServerError(err) 1019 | } 1020 | 1021 | if file != nil { 1022 | name, err := UploadImage(file, HIGHLIGHT_IMAGE_PATH, 1000, 1000) 1023 | if err != nil { 1024 | return InternalServerError(err) 1025 | } 1026 | 1027 | err = Q.UpdateHighlightImage(r.Context(), UpdateHighlightImageParams{ 1028 | Image: NullString(name), 1029 | ID: highlight.ID, 1030 | }) 1031 | if err != nil { 1032 | return InternalServerError(err) 1033 | } 1034 | } 1035 | 1036 | return Redirect(fmt.Sprintf("/users/%s/books/%s", user.Slug, book.Isbn)) 1037 | }, loggedinMiddleware) 1038 | 1039 | DELETE("/users/{user}/books/{isbn}/highlights/{id}", func(w Response, r Request) Output { 1040 | actor := current_user(r) 1041 | vars := VARS(r) 1042 | 1043 | user, err := Q.UserBySlug(r.Context(), vars["user"]) 1044 | if err != nil { 1045 | return NotFound 1046 | } 1047 | 1048 | book, err := Q.BookByIsbnAndUser(r.Context(), BookByIsbnAndUserParams{ 1049 | UserID: user.ID, 1050 | Isbn: vars["isbn"], 1051 | }) 1052 | if err != nil { 1053 | return NotFound 1054 | } 1055 | 1056 | highlight, err := Q.HighlightByIDAndBook(r.Context(), HighlightByIDAndBookParams{ 1057 | ID: atoi64(vars["id"]), 1058 | BookID: book.ID, 1059 | }) 1060 | if err != nil { 1061 | return NotFound 1062 | } 1063 | 1064 | if !can(actor, "delete_highlight", book) { 1065 | return Unauthorized 1066 | } 1067 | 1068 | if highlight.Image.Valid && len(highlight.Image.String) > 0 { 1069 | os.Remove(path.Join(HIGHLIGHT_IMAGE_PATH, highlight.Image.String)) 1070 | } 1071 | 1072 | if err = Q.DeleteHighlight(r.Context(), highlight.ID); err != nil { 1073 | return InternalServerError(err) 1074 | } 1075 | 1076 | return Redirect(fmt.Sprintf("/users/%s/books/%s", user.Slug, vars["isbn"])) 1077 | }, loggedinMiddleware) 1078 | 1079 | Helpers() 1080 | Start() 1081 | } 1082 | -------------------------------------------------------------------------------- /middlewares.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "net/http" 4 | 5 | func loggedinMiddleware(next http.HandlerFunc) http.HandlerFunc { 6 | return func(w http.ResponseWriter, r *http.Request) { 7 | if !loggedin(r) { 8 | http.Redirect(w, r, "/", http.StatusFound) 9 | return 10 | } 11 | 12 | next(w, r) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | 3 | package main 4 | 5 | import ( 6 | "database/sql" 7 | "time" 8 | ) 9 | 10 | type ArInternalMetadatum struct { 11 | Key string 12 | Value sql.NullString 13 | CreatedAt time.Time 14 | UpdatedAt time.Time 15 | } 16 | 17 | type Book struct { 18 | ID int64 19 | Title string 20 | Author string 21 | Image sql.NullString 22 | Isbn string 23 | CreatedAt time.Time 24 | UpdatedAt time.Time 25 | ShelfID sql.NullInt64 26 | UserID int64 27 | GoogleBooksID sql.NullString 28 | Subtitle string 29 | Description string 30 | PageCount int32 31 | Publisher string 32 | PageRead int32 33 | } 34 | 35 | type Highlight struct { 36 | ID int64 37 | BookID int64 38 | Page int32 39 | Content string 40 | Image sql.NullString 41 | CreatedAt time.Time 42 | UpdatedAt time.Time 43 | } 44 | 45 | type SchemaMigration struct { 46 | Version string 47 | } 48 | 49 | type Shelf struct { 50 | ID int64 51 | Name string 52 | CreatedAt time.Time 53 | UpdatedAt time.Time 54 | UserID int64 55 | Position int32 56 | } 57 | 58 | type User struct { 59 | ID int64 60 | Name sql.NullString 61 | Email sql.NullString 62 | Image sql.NullString 63 | CreatedAt time.Time 64 | UpdatedAt time.Time 65 | Slug string 66 | Description sql.NullString 67 | Facebook sql.NullString 68 | Twitter sql.NullString 69 | Linkedin sql.NullString 70 | Instagram sql.NullString 71 | Phone sql.NullString 72 | Whatsapp sql.NullString 73 | Telegram sql.NullString 74 | AmazonAssociatesID sql.NullString 75 | } 76 | -------------------------------------------------------------------------------- /public/books/image/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/books/image/.keep -------------------------------------------------------------------------------- /public/default_book: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/default_book -------------------------------------------------------------------------------- /public/default_user: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/default_user -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/fonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /public/fonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/fonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /public/fonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/fonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /public/fonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/fonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /public/fonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/fonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /public/fonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/fonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /public/fonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/fonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /public/fonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/fonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /public/google_books.js: -------------------------------------------------------------------------------- 1 | function createElementWithAttrs(tagName, attributes) { 2 | let tag = document.createElement(tagName); 3 | Object.entries(attributes).forEach(([attr, value]) => tag.setAttribute(attr, value)); 4 | return tag; 5 | } 6 | 7 | customElements.define('google-books', class GoogleBooks extends HTMLElement { 8 | static get observedAttributes() { 9 | return ['keyword']; 10 | } 11 | 12 | connectedCallback() { 13 | let keyword = this.getAttribute("keyword"); 14 | if(keyword) this.fetchBooks(keyword); 15 | } 16 | 17 | attributeChangedCallback(name, oldValue, newValue) { 18 | if( name == 'keyword' ) this.fetchBooks(newValue); 19 | } 20 | 21 | fetchBooks(keyword) { 22 | fetch('https://www.googleapis.com/books/v1/volumes?q='+keyword) 23 | .then(response => response.json()) 24 | .then(data => this.update(data)); 25 | } 26 | 27 | update(data) { 28 | this.innerHTML = ''; 29 | 30 | if(!data.items) return; 31 | 32 | let columns = createElementWithAttrs('div', { class: 'columns is-multiline' }); 33 | this.appendChild(columns); 34 | 35 | for( let i=0; i < data.items.length; i++ ) { 36 | let column = createElementWithAttrs('div', { class: 'column is-2' }); 37 | columns.appendChild(column); 38 | 39 | let book = createElementWithAttrs("google-book", { 40 | book: JSON.stringify(data.items[i]), 41 | class: 'is-clickable' 42 | }); 43 | column.appendChild(book); 44 | } 45 | } 46 | }); 47 | 48 | customElements.define('google-book', class GoogleBook extends HTMLElement { 49 | constructor() { 50 | super(); 51 | this.addEventListener('click', this.clickCallback); 52 | } 53 | connectedCallback() { 54 | let book = this.getAttribute('book'); 55 | book = JSON.parse(book); 56 | 57 | let figure = createElementWithAttrs('figure', { 58 | class: 'image is-3by4' 59 | }); 60 | this.appendChild(figure) 61 | 62 | let img = createElementWithAttrs('img', { 63 | title: book.volumeInfo.title, 64 | src: `https://books.google.com/books/content?id=${book.id}&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api` 65 | }); 66 | figure.appendChild(img) 67 | } 68 | 69 | clickCallback() { 70 | let book = JSON.parse(this.getAttribute('book')); 71 | let isbn13 = book.volumeInfo.industryIdentifiers ? book.volumeInfo.industryIdentifiers.find( i => i.type == 'ISBN_13') : null; 72 | if ( isbn13 ) this.setValue('isbn', isbn13.identifier); 73 | this.setValue('google_books_id', book.id); 74 | this.setValue('title', book.volumeInfo.title); 75 | this.setValue('author', book.volumeInfo.authors.join(', ')); 76 | this.setValue('subtitle', book.volumeInfo.subtitle); 77 | this.setValue('description', book.volumeInfo.description); 78 | this.setValue('page_count', book.volumeInfo.pageCount); 79 | this.setValue('publisher', book.volumeInfo.publisher); 80 | } 81 | 82 | setValue(name, value) { 83 | if ( !value ) return; 84 | 85 | document.getElementsByName(name).forEach( i => i.value = value ); 86 | } 87 | }); 88 | -------------------------------------------------------------------------------- /public/highlights/image/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/library/dce856fda0bf52b3597f0b6c858658ca521dadde/public/highlights/image/.keep -------------------------------------------------------------------------------- /query.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // source: query.sql 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "database/sql" 9 | "time" 10 | ) 11 | 12 | const bookByIsbnAndUser = `-- name: BookByIsbnAndUser :one 13 | SELECT books.id, books.title, books.author, books.image, books.isbn, books.created_at, books.updated_at, books.shelf_id, books.user_id, books.google_books_id, books.subtitle, books.description, books.page_count, books.publisher, books.page_read, slug, shelves.name shelf_name 14 | FROM users, books 15 | LEFT JOIN shelves 16 | ON shelves.id = books.shelf_id 17 | WHERE users.id = books.user_id 18 | AND books.user_id = $1 19 | AND isbn = $2 20 | LIMIT 1 21 | ` 22 | 23 | type BookByIsbnAndUserParams struct { 24 | UserID int64 25 | Isbn string 26 | } 27 | 28 | type BookByIsbnAndUserRow struct { 29 | ID int64 30 | Title string 31 | Author string 32 | Image sql.NullString 33 | Isbn string 34 | CreatedAt time.Time 35 | UpdatedAt time.Time 36 | ShelfID sql.NullInt64 37 | UserID int64 38 | GoogleBooksID sql.NullString 39 | Subtitle string 40 | Description string 41 | PageCount int32 42 | Publisher string 43 | PageRead int32 44 | Slug string 45 | ShelfName sql.NullString 46 | } 47 | 48 | func (q *Queries) BookByIsbnAndUser(ctx context.Context, arg BookByIsbnAndUserParams) (BookByIsbnAndUserRow, error) { 49 | row := q.db.QueryRowContext(ctx, bookByIsbnAndUser, arg.UserID, arg.Isbn) 50 | var i BookByIsbnAndUserRow 51 | err := row.Scan( 52 | &i.ID, 53 | &i.Title, 54 | &i.Author, 55 | &i.Image, 56 | &i.Isbn, 57 | &i.CreatedAt, 58 | &i.UpdatedAt, 59 | &i.ShelfID, 60 | &i.UserID, 61 | &i.GoogleBooksID, 62 | &i.Subtitle, 63 | &i.Description, 64 | &i.PageCount, 65 | &i.Publisher, 66 | &i.PageRead, 67 | &i.Slug, 68 | &i.ShelfName, 69 | ) 70 | return i, err 71 | } 72 | 73 | const booksCount = `-- name: BooksCount :one 74 | SELECT count(*) FROM books WHERE user_id = $1 75 | ` 76 | 77 | func (q *Queries) BooksCount(ctx context.Context, userID int64) (int64, error) { 78 | row := q.db.QueryRowContext(ctx, booksCount, userID) 79 | var count int64 80 | err := row.Scan(&count) 81 | return count, err 82 | } 83 | 84 | const completeBook = `-- name: CompleteBook :exec 85 | UPDATE books SET page_read = page_count WHERE id = $1 86 | ` 87 | 88 | func (q *Queries) CompleteBook(ctx context.Context, id int64) error { 89 | _, err := q.db.ExecContext(ctx, completeBook, id) 90 | return err 91 | } 92 | 93 | const deleteBook = `-- name: DeleteBook :exec 94 | DELETE FROM books WHERE id = $1 95 | ` 96 | 97 | func (q *Queries) DeleteBook(ctx context.Context, id int64) error { 98 | _, err := q.db.ExecContext(ctx, deleteBook, id) 99 | return err 100 | } 101 | 102 | const deleteHighlight = `-- name: DeleteHighlight :exec 103 | DELETE FROM highlights WHERE id = $1 104 | ` 105 | 106 | func (q *Queries) DeleteHighlight(ctx context.Context, id int64) error { 107 | _, err := q.db.ExecContext(ctx, deleteHighlight, id) 108 | return err 109 | } 110 | 111 | const deleteShelf = `-- name: DeleteShelf :exec 112 | DELETE FROM shelves WHERE id = $1 113 | ` 114 | 115 | func (q *Queries) DeleteShelf(ctx context.Context, id int64) error { 116 | _, err := q.db.ExecContext(ctx, deleteShelf, id) 117 | return err 118 | } 119 | 120 | const highlightByIDAndBook = `-- name: HighlightByIDAndBook :one 121 | SELECT id, book_id, page, content, image, created_at, updated_at FROM highlights WHERE id = $1 AND book_id = $2 LIMIT 1 122 | ` 123 | 124 | type HighlightByIDAndBookParams struct { 125 | ID int64 126 | BookID int64 127 | } 128 | 129 | func (q *Queries) HighlightByIDAndBook(ctx context.Context, arg HighlightByIDAndBookParams) (Highlight, error) { 130 | row := q.db.QueryRowContext(ctx, highlightByIDAndBook, arg.ID, arg.BookID) 131 | var i Highlight 132 | err := row.Scan( 133 | &i.ID, 134 | &i.BookID, 135 | &i.Page, 136 | &i.Content, 137 | &i.Image, 138 | &i.CreatedAt, 139 | &i.UpdatedAt, 140 | ) 141 | return i, err 142 | } 143 | 144 | const highlights = `-- name: Highlights :many 145 | SELECT id, book_id, page, content, image, created_at, updated_at FROM highlights WHERE book_id = $1 ORDER BY page 146 | ` 147 | 148 | func (q *Queries) Highlights(ctx context.Context, bookID int64) ([]Highlight, error) { 149 | rows, err := q.db.QueryContext(ctx, highlights, bookID) 150 | if err != nil { 151 | return nil, err 152 | } 153 | defer rows.Close() 154 | var items []Highlight 155 | for rows.Next() { 156 | var i Highlight 157 | if err := rows.Scan( 158 | &i.ID, 159 | &i.BookID, 160 | &i.Page, 161 | &i.Content, 162 | &i.Image, 163 | &i.CreatedAt, 164 | &i.UpdatedAt, 165 | ); err != nil { 166 | return nil, err 167 | } 168 | items = append(items, i) 169 | } 170 | if err := rows.Close(); err != nil { 171 | return nil, err 172 | } 173 | if err := rows.Err(); err != nil { 174 | return nil, err 175 | } 176 | return items, nil 177 | } 178 | 179 | const highlightsWithImages = `-- name: HighlightsWithImages :many 180 | SELECT image FROM highlights WHERE image IS NOT NULL AND length(image) > 0 AND book_id = $1 181 | ` 182 | 183 | func (q *Queries) HighlightsWithImages(ctx context.Context, bookID int64) ([]sql.NullString, error) { 184 | rows, err := q.db.QueryContext(ctx, highlightsWithImages, bookID) 185 | if err != nil { 186 | return nil, err 187 | } 188 | defer rows.Close() 189 | var items []sql.NullString 190 | for rows.Next() { 191 | var image sql.NullString 192 | if err := rows.Scan(&image); err != nil { 193 | return nil, err 194 | } 195 | items = append(items, image) 196 | } 197 | if err := rows.Close(); err != nil { 198 | return nil, err 199 | } 200 | if err := rows.Err(); err != nil { 201 | return nil, err 202 | } 203 | return items, nil 204 | } 205 | 206 | const moveBookToShelf = `-- name: MoveBookToShelf :exec 207 | UPDATE books SET shelf_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 208 | ` 209 | 210 | type MoveBookToShelfParams struct { 211 | ShelfID sql.NullInt64 212 | ID int64 213 | } 214 | 215 | func (q *Queries) MoveBookToShelf(ctx context.Context, arg MoveBookToShelfParams) error { 216 | _, err := q.db.ExecContext(ctx, moveBookToShelf, arg.ShelfID, arg.ID) 217 | return err 218 | } 219 | 220 | const moveShelfDown = `-- name: MoveShelfDown :exec 221 | UPDATE shelves 222 | SET position = ( 223 | CASE 224 | WHEN position = (SELECT position FROM shelves WHERE shelves.id = $1) THEN position + 1 225 | WHEN position = (SELECT position + 1 FROM shelves WHERE shelves.id = $1) THEN position - 1 226 | END 227 | ) 228 | WHERE user_id = (SELECT user_id FROM shelves WHERE shelves.id = $1) 229 | AND position IN ( 230 | (SELECT position FROM shelves WHERE shelves.id = $1), 231 | (SELECT position + 1 FROM shelves WHERE shelves.id = $1) 232 | ) 233 | ` 234 | 235 | func (q *Queries) MoveShelfDown(ctx context.Context, id int64) error { 236 | _, err := q.db.ExecContext(ctx, moveShelfDown, id) 237 | return err 238 | } 239 | 240 | const moveShelfUp = `-- name: MoveShelfUp :exec 241 | UPDATE shelves 242 | SET position = ( 243 | CASE 244 | WHEN position = (SELECT position -1 FROM shelves WHERE shelves.id = $1) THEN position + 1 245 | WHEN position = (SELECT position FROM shelves WHERE shelves.id = $1) THEN position - 1 246 | END 247 | ) 248 | WHERE user_id = (SELECT user_id FROM shelves WHERE shelves.id = $1) 249 | AND position IN ( 250 | (SELECT position -1 FROM shelves WHERE shelves.id = $1), 251 | (SELECT position FROM shelves WHERE shelves.id = $1) 252 | ) 253 | ` 254 | 255 | func (q *Queries) MoveShelfUp(ctx context.Context, id int64) error { 256 | _, err := q.db.ExecContext(ctx, moveShelfUp, id) 257 | return err 258 | } 259 | 260 | const newBook = `-- name: NewBook :one 261 | INSERT INTO books (title, isbn, author, subtitle, description, publisher, page_count, google_books_id, user_id, page_read) 262 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 263 | RETURNING id, title, author, image, isbn, created_at, updated_at, shelf_id, user_id, google_books_id, subtitle, description, page_count, publisher, page_read 264 | ` 265 | 266 | type NewBookParams struct { 267 | Title string 268 | Isbn string 269 | Author string 270 | Subtitle string 271 | Description string 272 | Publisher string 273 | PageCount int32 274 | GoogleBooksID sql.NullString 275 | UserID int64 276 | PageRead int32 277 | } 278 | 279 | func (q *Queries) NewBook(ctx context.Context, arg NewBookParams) (Book, error) { 280 | row := q.db.QueryRowContext(ctx, newBook, 281 | arg.Title, 282 | arg.Isbn, 283 | arg.Author, 284 | arg.Subtitle, 285 | arg.Description, 286 | arg.Publisher, 287 | arg.PageCount, 288 | arg.GoogleBooksID, 289 | arg.UserID, 290 | arg.PageRead, 291 | ) 292 | var i Book 293 | err := row.Scan( 294 | &i.ID, 295 | &i.Title, 296 | &i.Author, 297 | &i.Image, 298 | &i.Isbn, 299 | &i.CreatedAt, 300 | &i.UpdatedAt, 301 | &i.ShelfID, 302 | &i.UserID, 303 | &i.GoogleBooksID, 304 | &i.Subtitle, 305 | &i.Description, 306 | &i.PageCount, 307 | &i.Publisher, 308 | &i.PageRead, 309 | ) 310 | return i, err 311 | } 312 | 313 | const newHighlight = `-- name: NewHighlight :one 314 | INSERT INTO highlights (book_id, page, content) VALUES ($1, $2, $3) RETURNING id, book_id, page, content, image, created_at, updated_at 315 | ` 316 | 317 | type NewHighlightParams struct { 318 | BookID int64 319 | Page int32 320 | Content string 321 | } 322 | 323 | func (q *Queries) NewHighlight(ctx context.Context, arg NewHighlightParams) (Highlight, error) { 324 | row := q.db.QueryRowContext(ctx, newHighlight, arg.BookID, arg.Page, arg.Content) 325 | var i Highlight 326 | err := row.Scan( 327 | &i.ID, 328 | &i.BookID, 329 | &i.Page, 330 | &i.Content, 331 | &i.Image, 332 | &i.CreatedAt, 333 | &i.UpdatedAt, 334 | ) 335 | return i, err 336 | } 337 | 338 | const newShelf = `-- name: NewShelf :exec 339 | INSERT INTO shelves (name, user_id, position) 340 | VALUES ($1, $2, ( 341 | SELECT coalesce(MAX(position), 0) + 1 342 | FROM shelves 343 | WHERE user_id = $2) 344 | ) 345 | ` 346 | 347 | type NewShelfParams struct { 348 | Name string 349 | UserID int64 350 | } 351 | 352 | func (q *Queries) NewShelf(ctx context.Context, arg NewShelfParams) error { 353 | _, err := q.db.ExecContext(ctx, newShelf, arg.Name, arg.UserID) 354 | return err 355 | } 356 | 357 | const removeShelf = `-- name: RemoveShelf :exec 358 | UPDATE shelves SET position = position - 1 359 | WHERE user_id = (SELECT user_id FROM shelves WHERE shelves.id = $1) 360 | AND position > (SELECT position FROM shelves WHERE shelves.id = $1) 361 | ` 362 | 363 | func (q *Queries) RemoveShelf(ctx context.Context, id int64) error { 364 | _, err := q.db.ExecContext(ctx, removeShelf, id) 365 | return err 366 | } 367 | 368 | const shelfBooks = `-- name: ShelfBooks :many 369 | SELECT books.id id, title, books.image image, google_books_id, slug, isbn, page_read, page_count 370 | FROM books, users 371 | WHERE users.id = books.user_id 372 | AND shelf_id = $1 373 | ORDER BY books.created_at DESC 374 | ` 375 | 376 | type ShelfBooksRow struct { 377 | ID int64 378 | Title string 379 | Image sql.NullString 380 | GoogleBooksID sql.NullString 381 | Slug string 382 | Isbn string 383 | PageRead int32 384 | PageCount int32 385 | } 386 | 387 | func (q *Queries) ShelfBooks(ctx context.Context, shelfID sql.NullInt64) ([]ShelfBooksRow, error) { 388 | rows, err := q.db.QueryContext(ctx, shelfBooks, shelfID) 389 | if err != nil { 390 | return nil, err 391 | } 392 | defer rows.Close() 393 | var items []ShelfBooksRow 394 | for rows.Next() { 395 | var i ShelfBooksRow 396 | if err := rows.Scan( 397 | &i.ID, 398 | &i.Title, 399 | &i.Image, 400 | &i.GoogleBooksID, 401 | &i.Slug, 402 | &i.Isbn, 403 | &i.PageRead, 404 | &i.PageCount, 405 | ); err != nil { 406 | return nil, err 407 | } 408 | items = append(items, i) 409 | } 410 | if err := rows.Close(); err != nil { 411 | return nil, err 412 | } 413 | if err := rows.Err(); err != nil { 414 | return nil, err 415 | } 416 | return items, nil 417 | } 418 | 419 | const shelfByIdAndUser = `-- name: ShelfByIdAndUser :one 420 | SELECT id, name, created_at, updated_at, user_id, position FROM shelves WHERE user_id = $1 AND id = $2 LIMIT 1 421 | ` 422 | 423 | type ShelfByIdAndUserParams struct { 424 | UserID int64 425 | ID int64 426 | } 427 | 428 | func (q *Queries) ShelfByIdAndUser(ctx context.Context, arg ShelfByIdAndUserParams) (Shelf, error) { 429 | row := q.db.QueryRowContext(ctx, shelfByIdAndUser, arg.UserID, arg.ID) 430 | var i Shelf 431 | err := row.Scan( 432 | &i.ID, 433 | &i.Name, 434 | &i.CreatedAt, 435 | &i.UpdatedAt, 436 | &i.UserID, 437 | &i.Position, 438 | ) 439 | return i, err 440 | } 441 | 442 | const shelves = `-- name: Shelves :many 443 | SELECT id, name, created_at, updated_at, user_id, position FROM shelves WHERE user_id = $1 ORDER BY position 444 | ` 445 | 446 | func (q *Queries) Shelves(ctx context.Context, userID int64) ([]Shelf, error) { 447 | rows, err := q.db.QueryContext(ctx, shelves, userID) 448 | if err != nil { 449 | return nil, err 450 | } 451 | defer rows.Close() 452 | var items []Shelf 453 | for rows.Next() { 454 | var i Shelf 455 | if err := rows.Scan( 456 | &i.ID, 457 | &i.Name, 458 | &i.CreatedAt, 459 | &i.UpdatedAt, 460 | &i.UserID, 461 | &i.Position, 462 | ); err != nil { 463 | return nil, err 464 | } 465 | items = append(items, i) 466 | } 467 | if err := rows.Close(); err != nil { 468 | return nil, err 469 | } 470 | if err := rows.Err(); err != nil { 471 | return nil, err 472 | } 473 | return items, nil 474 | } 475 | 476 | const signup = `-- name: Signup :one 477 | INSERT 478 | INTO users(name, image, slug, email) 479 | VALUES($1,$2,$3,$4) 480 | ON CONFLICT (email) 481 | DO UPDATE SET name = $1, image = $2, updated_at = CURRENT_TIMESTAMP 482 | RETURNING id 483 | ` 484 | 485 | type SignupParams struct { 486 | Name sql.NullString 487 | Image sql.NullString 488 | Slug string 489 | Email sql.NullString 490 | } 491 | 492 | func (q *Queries) Signup(ctx context.Context, arg SignupParams) (int64, error) { 493 | row := q.db.QueryRowContext(ctx, signup, 494 | arg.Name, 495 | arg.Image, 496 | arg.Slug, 497 | arg.Email, 498 | ) 499 | var id int64 500 | err := row.Scan(&id) 501 | return id, err 502 | } 503 | 504 | const updateBook = `-- name: UpdateBook :exec 505 | UPDATE books 506 | SET title = $1, 507 | author = $2, 508 | subtitle = $3, 509 | description = $4, 510 | publisher = $5, 511 | page_count = $6, 512 | page_read = $7, 513 | updated_at = CURRENT_TIMESTAMP 514 | WHERE id = $8 515 | ` 516 | 517 | type UpdateBookParams struct { 518 | Title string 519 | Author string 520 | Subtitle string 521 | Description string 522 | Publisher string 523 | PageCount int32 524 | PageRead int32 525 | ID int64 526 | } 527 | 528 | func (q *Queries) UpdateBook(ctx context.Context, arg UpdateBookParams) error { 529 | _, err := q.db.ExecContext(ctx, updateBook, 530 | arg.Title, 531 | arg.Author, 532 | arg.Subtitle, 533 | arg.Description, 534 | arg.Publisher, 535 | arg.PageCount, 536 | arg.PageRead, 537 | arg.ID, 538 | ) 539 | return err 540 | } 541 | 542 | const updateBookImage = `-- name: UpdateBookImage :exec 543 | UPDATE books SET image = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 544 | ` 545 | 546 | type UpdateBookImageParams struct { 547 | Image sql.NullString 548 | ID int64 549 | } 550 | 551 | func (q *Queries) UpdateBookImage(ctx context.Context, arg UpdateBookImageParams) error { 552 | _, err := q.db.ExecContext(ctx, updateBookImage, arg.Image, arg.ID) 553 | return err 554 | } 555 | 556 | const updateHighlight = `-- name: UpdateHighlight :exec 557 | UPDATE highlights SET page = $1, content = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 558 | ` 559 | 560 | type UpdateHighlightParams struct { 561 | Page int32 562 | Content string 563 | ID int64 564 | } 565 | 566 | func (q *Queries) UpdateHighlight(ctx context.Context, arg UpdateHighlightParams) error { 567 | _, err := q.db.ExecContext(ctx, updateHighlight, arg.Page, arg.Content, arg.ID) 568 | return err 569 | } 570 | 571 | const updateHighlightImage = `-- name: UpdateHighlightImage :exec 572 | UPDATE highlights SET image = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 573 | ` 574 | 575 | type UpdateHighlightImageParams struct { 576 | Image sql.NullString 577 | ID int64 578 | } 579 | 580 | func (q *Queries) UpdateHighlightImage(ctx context.Context, arg UpdateHighlightImageParams) error { 581 | _, err := q.db.ExecContext(ctx, updateHighlightImage, arg.Image, arg.ID) 582 | return err 583 | } 584 | 585 | const updateShelf = `-- name: UpdateShelf :exec 586 | UPDATE shelves SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 587 | ` 588 | 589 | type UpdateShelfParams struct { 590 | Name string 591 | ID int64 592 | } 593 | 594 | func (q *Queries) UpdateShelf(ctx context.Context, arg UpdateShelfParams) error { 595 | _, err := q.db.ExecContext(ctx, updateShelf, arg.Name, arg.ID) 596 | return err 597 | } 598 | 599 | const updateUser = `-- name: UpdateUser :exec 600 | UPDATE users 601 | SET description = $1, 602 | amazon_associates_id = $2, 603 | facebook = $3, 604 | twitter = $4, 605 | linkedin = $5, 606 | instagram = $6, 607 | phone = $7, 608 | whatsapp = $8, 609 | telegram = $9, 610 | updated_at = CURRENT_TIMESTAMP 611 | WHERE id = $10 612 | ` 613 | 614 | type UpdateUserParams struct { 615 | Description sql.NullString 616 | AmazonAssociatesID sql.NullString 617 | Facebook sql.NullString 618 | Twitter sql.NullString 619 | Linkedin sql.NullString 620 | Instagram sql.NullString 621 | Phone sql.NullString 622 | Whatsapp sql.NullString 623 | Telegram sql.NullString 624 | ID int64 625 | } 626 | 627 | func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { 628 | _, err := q.db.ExecContext(ctx, updateUser, 629 | arg.Description, 630 | arg.AmazonAssociatesID, 631 | arg.Facebook, 632 | arg.Twitter, 633 | arg.Linkedin, 634 | arg.Instagram, 635 | arg.Phone, 636 | arg.Whatsapp, 637 | arg.Telegram, 638 | arg.ID, 639 | ) 640 | return err 641 | } 642 | 643 | const user = `-- name: User :one 644 | SELECT id, name, email, image, created_at, updated_at, slug, description, facebook, twitter, linkedin, instagram, phone, whatsapp, telegram, amazon_associates_id FROM users WHERE id = $1 LIMIT 1 645 | ` 646 | 647 | func (q *Queries) User(ctx context.Context, id int64) (User, error) { 648 | row := q.db.QueryRowContext(ctx, user, id) 649 | var i User 650 | err := row.Scan( 651 | &i.ID, 652 | &i.Name, 653 | &i.Email, 654 | &i.Image, 655 | &i.CreatedAt, 656 | &i.UpdatedAt, 657 | &i.Slug, 658 | &i.Description, 659 | &i.Facebook, 660 | &i.Twitter, 661 | &i.Linkedin, 662 | &i.Instagram, 663 | &i.Phone, 664 | &i.Whatsapp, 665 | &i.Telegram, 666 | &i.AmazonAssociatesID, 667 | ) 668 | return i, err 669 | } 670 | 671 | const userBySlug = `-- name: UserBySlug :one 672 | SELECT id, name, email, image, created_at, updated_at, slug, description, facebook, twitter, linkedin, instagram, phone, whatsapp, telegram, amazon_associates_id FROM users WHERE slug = $1 LIMIT 1 673 | ` 674 | 675 | func (q *Queries) UserBySlug(ctx context.Context, slug string) (User, error) { 676 | row := q.db.QueryRowContext(ctx, userBySlug, slug) 677 | var i User 678 | err := row.Scan( 679 | &i.ID, 680 | &i.Name, 681 | &i.Email, 682 | &i.Image, 683 | &i.CreatedAt, 684 | &i.UpdatedAt, 685 | &i.Slug, 686 | &i.Description, 687 | &i.Facebook, 688 | &i.Twitter, 689 | &i.Linkedin, 690 | &i.Instagram, 691 | &i.Phone, 692 | &i.Whatsapp, 693 | &i.Telegram, 694 | &i.AmazonAssociatesID, 695 | ) 696 | return i, err 697 | } 698 | 699 | const userUnshelvedBooks = `-- name: UserUnshelvedBooks :many 700 | SELECT books.id id, title, books.image image, google_books_id, slug, isbn, page_count, page_read 701 | FROM books, users 702 | WHERE users.id = books.user_id 703 | AND user_id = $1 704 | AND shelf_id IS NULL 705 | ` 706 | 707 | type UserUnshelvedBooksRow struct { 708 | ID int64 709 | Title string 710 | Image sql.NullString 711 | GoogleBooksID sql.NullString 712 | Slug string 713 | Isbn string 714 | PageCount int32 715 | PageRead int32 716 | } 717 | 718 | func (q *Queries) UserUnshelvedBooks(ctx context.Context, userID int64) ([]UserUnshelvedBooksRow, error) { 719 | rows, err := q.db.QueryContext(ctx, userUnshelvedBooks, userID) 720 | if err != nil { 721 | return nil, err 722 | } 723 | defer rows.Close() 724 | var items []UserUnshelvedBooksRow 725 | for rows.Next() { 726 | var i UserUnshelvedBooksRow 727 | if err := rows.Scan( 728 | &i.ID, 729 | &i.Title, 730 | &i.Image, 731 | &i.GoogleBooksID, 732 | &i.Slug, 733 | &i.Isbn, 734 | &i.PageCount, 735 | &i.PageRead, 736 | ); err != nil { 737 | return nil, err 738 | } 739 | items = append(items, i) 740 | } 741 | if err := rows.Close(); err != nil { 742 | return nil, err 743 | } 744 | if err := rows.Err(); err != nil { 745 | return nil, err 746 | } 747 | return items, nil 748 | } 749 | -------------------------------------------------------------------------------- /validation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func (n NewBookParams) Validate() ValidationErrors { 4 | ve := ValidationErrors{} 5 | ValidateStringPresent(n.Title, "title", "Title", ve) 6 | ValidateStringLength(n.Title, "title", "Title", ve, 0, 100) 7 | 8 | ValidateStringLength(n.Subtitle, "subtitle", "Subtitle", ve, 0, 100) 9 | 10 | ValidateStringPresent(n.Author, "author", "Author", ve) 11 | ValidateStringLength(n.Author, "author", "Author", ve, 0, 100) 12 | 13 | ValidateStringNumeric(n.Isbn, "isbn", "ISBN", ve) 14 | ValidateISBN13(n.Isbn, "isbn", "ISBN", ve) 15 | 16 | ValidateStringLength(n.GoogleBooksID.String, "google_books_id", "Google Books ID", ve, 0, 30) 17 | ValidateStringLength(n.Description, "description", "Description", ve, 0, 5000) 18 | ValidateStringLength(n.Publisher, "publisher", "Publisher", ve, 0, 50) 19 | 20 | return ve 21 | } 22 | 23 | func (n UpdateBookParams) Validate() ValidationErrors { 24 | ve := ValidationErrors{} 25 | ValidateStringPresent(n.Title, "title", "Title", ve) 26 | ValidateStringLength(n.Title, "title", "Title", ve, 0, 100) 27 | 28 | ValidateStringLength(n.Subtitle, "subtitle", "Subtitle", ve, 0, 100) 29 | 30 | ValidateStringPresent(n.Author, "author", "Author", ve) 31 | ValidateStringLength(n.Author, "author", "Author", ve, 0, 100) 32 | 33 | ValidateStringLength(n.Description, "description", "Description", ve, 0, 5000) 34 | ValidateStringLength(n.Publisher, "publisher", "Publisher", ve, 0, 50) 35 | return ve 36 | } 37 | 38 | func (u UpdateUserParams) Validate() ValidationErrors { 39 | ve := ValidationErrors{} 40 | ValidateStringLength(u.Description.String, "description", "Description", ve, 0, 500) 41 | ValidateStringLength(u.AmazonAssociatesID.String, "amazon_associates_id", "Amazon Associates ID", ve, 0, 50) 42 | ValidateStringLength(u.Facebook.String, "facebook", "Facebook", ve, 0, 50) 43 | ValidateStringLength(u.Twitter.String, "twitter", "Twitter", ve, 0, 50) 44 | ValidateStringLength(u.Linkedin.String, "linkedin", "Linkedin", ve, 0, 50) 45 | ValidateStringLength(u.Instagram.String, "instagram", "Instagram", ve, 0, 50) 46 | ValidateStringLength(u.Phone.String, "phone", "Phone", ve, 0, 50) 47 | ValidateStringLength(u.Whatsapp.String, "whatsapp", "Whatsapp", ve, 0, 50) 48 | ValidateStringLength(u.Telegram.String, "telegram", "Telegram", ve, 0, 50) 49 | return ve 50 | } 51 | 52 | func (n NewHighlightParams) Validate() ValidationErrors { 53 | ve := ValidationErrors{} 54 | ValidateStringLength(n.Content, "content", "Content", ve, 10, 500) 55 | ValidateInt32Min(n.Page, "page", "Page", ve, 0) 56 | return ve 57 | } 58 | 59 | func (n UpdateHighlightParams) Validate() ValidationErrors { 60 | ve := ValidationErrors{} 61 | ValidateStringLength(n.Content, "content", "Content", ve, 10, 500) 62 | ValidateInt32Min(n.Page, "page", "Page", ve, 0) 63 | return ve 64 | } 65 | 66 | func (n NewShelfParams) Validate() ValidationErrors { 67 | ve := ValidationErrors{} 68 | ValidateStringPresent(n.Name, "name", "Name", ve) 69 | ValidateStringLength(n.Name, "name", "Name", ve, 3, 100) 70 | return ve 71 | } 72 | 73 | func (n UpdateShelfParams) Validate() ValidationErrors { 74 | ve := ValidationErrors{} 75 | ValidateStringPresent(n.Name, "name", "Name", ve) 76 | ValidateStringLength(n.Name, "name", "Name", ve, 3, 100) 77 | return ve 78 | } 79 | -------------------------------------------------------------------------------- /views/books/book.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | 7 | 12 | {{ .PageRead }}/{{ .PageCount }} 13 | 14 | -------------------------------------------------------------------------------- /views/books/new.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 |

6 | 7 |
8 | {{ .csrf }} 9 | 10 | 11 | {{ if has_field .book "ID" | not }} 12 |
13 | 14 |
15 | 24 | {{ template "common/errors" index .errors "isbn" }} 25 |
26 |
27 | {{ end }} 28 | 29 |
30 | 31 |
32 | 42 | {{ template "common/errors" index .errors "title" }} 43 |
44 |
45 | 46 |
47 | 48 |
49 | 54 | {{ template "common/errors" index .errors "subtitle" }} 55 |
56 |
57 | 58 |
59 | 60 |
61 | 70 | {{ template "common/errors" index .errors "author" }} 71 |
72 |
73 | 74 |
75 | When accessing this page from your phone it'll prompt you to capture a photo using your phone camera.
76 | Use 3:4 aspect ratio when taking your picture for best result. 77 |
78 | 79 |
80 |
81 | 90 |
91 | {{ template "common/errors" index .errors "image" }} 92 |
93 | 94 |
95 | 96 |
97 | 100 | {{ template "common/errors" index .errors "description" }} 101 |
102 |
103 | 104 |
105 | 106 |
107 | 112 | {{ template "common/errors" index .errors "publisher" }} 113 |
114 |
115 | 116 |
117 | 118 |
119 | 124 | {{ template "common/errors" index .errors "page_count" }} 125 |
126 |
127 | 128 |
129 | 130 |
131 | 136 | {{ template "common/errors" index .errors "page_read" }} 137 |
138 |
139 | 140 |
141 |
142 | 143 |
144 |
145 |
146 | 147 | {{ if has_field .book "ID" }} 148 |
149 | 150 | {{ .csrf }} 151 | 152 |
153 | {{ end }} 154 | -------------------------------------------------------------------------------- /views/books/show.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ if can .current_user "edit" .book }} 4 | 5 | 6 | Edit 7 | 8 |
9 | {{ end }} 10 | 11 | {{ template "books/book" .book }} 12 | 13 | {{ if can .current_user "edit" .book }} 14 | {{ if lt .book.PageRead .book.PageCount }} 15 |
16 | {{ .csrf }} 17 |
18 |
19 | 23 |
24 |
25 |
26 | {{ end }} 27 | {{ end }} 28 | 29 | {{ if can .current_user "highlight" .book }} 30 | 31 | 32 | Create Highlight 33 | 34 | {{ end }} 35 | 36 |
37 | 38 | 48 | 49 |
50 | 51 |
52 | 53 | {{ if can .current_user "edit" .book }} 54 |
55 | {{ .csrf }} 56 |
57 |
58 | 59 | 65 | 66 |
67 | 68 |
69 |
70 | 71 |
72 | 73 |
74 |
75 |
76 | {{ else }} 77 | {{ if .book.ShelfID.Valid }} 78 | {{ .book.ShelfName.String }} 79 | {{ end }} 80 | {{ end }} 81 | 82 |

83 | {{ .book.Title }} 84 |

85 | 86 | {{ if .book.Subtitle }} 87 |

88 | {{ .book.Subtitle }} 89 |

90 | {{ end }} 91 | 92 |

93 | 94 | {{ .book.Author }} 95 |

96 | 97 | {{ if .book.Description }} 98 |

99 | {{ .book.Description }} 100 |

101 | {{ end }} 102 | 103 | {{ template "common/separator" }} 104 | 105 | {{ range .highlights }} 106 |

107 | « PAGE {{ .Page }} » 108 |

109 | 110 |

111 | {{ if can $.current_user "highlight" $.book }} 112 | 113 | 114 | 115 | {{ end }} 116 | {{ simple_format .Content }} 117 |

118 | 119 | {{ if .Image.Valid }} 120 |
121 |
122 |
123 |
124 | 125 |
126 |
127 |
128 |
129 | {{ end }} 130 | 131 | {{ end }} 132 | 133 |
134 |
135 | 136 | {{ $books:=shelf_books .book.ShelfID.Int64 }} 137 | {{ if $books }} 138 |
139 |
140 |

141 | On the same shelf 142 |

143 | 144 |
145 | {{ range $books }} 146 |
147 | {{ template "books/book" . }} 148 |
149 | {{ end }} 150 |
151 |
152 | {{ end }} 153 | -------------------------------------------------------------------------------- /views/common/errors.html: -------------------------------------------------------------------------------- 1 | {{ if . }} 2 |

{{ range . }} {{ . }} {{ end }}

3 | {{ end }} 4 | -------------------------------------------------------------------------------- /views/common/google_login.html: -------------------------------------------------------------------------------- 1 |
2 | {{ .csrf }} 3 | 4 | 8 |
9 | -------------------------------------------------------------------------------- /views/common/separator.html: -------------------------------------------------------------------------------- 1 |
* * *
2 | -------------------------------------------------------------------------------- /views/highlights/new.html: -------------------------------------------------------------------------------- 1 |

2 | 3 | {{ .book.Title }} 4 | 5 |

6 | 7 | 8 |
9 | {{ .csrf }} 10 | 11 |
12 | 13 |
14 | 15 | {{ template "common/errors" index .errors "page" }} 16 |
17 |
18 | 19 |
20 | 21 |
22 | 25 | {{ template "common/errors" index .errors "content" }} 26 |
27 |
28 | 29 |
30 | When accessing this page from your phone it'll prompt you to capture a photo using your phone camera.
31 | Use square images when taking your picture for best result. 32 |
33 | 34 |
35 |
36 | 45 |
46 | {{ template "common/errors" index .errors "image" }} 47 |
48 | 49 |
50 |
51 | 52 |
53 |
54 |
55 | 56 | {{ if has_field .highlight "ID" }} 57 |
58 | 59 | {{ .csrf }} 60 | 61 |
62 | {{ end }} 63 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

LIBRARY

5 |

Simply take control of your library

6 |

7 | {{ if can .current_user "login" nil }} 8 | {{ template "common/google_login" . }} 9 | {{ end }} 10 |

11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /views/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{ meta_property .meta "og:title" }} 10 | {{ meta_name .meta "author" }} 11 | {{ meta_name .meta "description" }} 12 | {{ meta_property .meta "og:description" }} 13 | {{ meta_property .meta "og:type" }} 14 | {{ meta_property .meta "og:image" }} 15 | {{ meta_name .meta "twitter:card" }} 16 | {{ meta_property .meta "twitter:image" }} 17 | {{ meta_property .meta "twitter:title" }} 18 | 19 | {{ if .title }} {{ .title }} | {{end }} LIBRARY 20 | 21 | 22 | 23 | {{ if .current_user }} {{ template "partials/user_nav" . }} {{ end }} 24 | 25 |
26 |
27 | {{ template "partials/header" . }} 28 |
29 |
30 | 31 |
32 |
33 | {{ partial .view . }} 34 |
35 |
36 | 37 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /views/partials/header.html: -------------------------------------------------------------------------------- 1 | {{ if can .current_user "login" nil }} 2 |
3 |
4 | {{ template "common/google_login" . }} 5 |
6 |
7 | {{ end }} 8 | 9 | {{ if .user }} 10 |
11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 |
19 |

20 | {{ if .user }} {{ .user.Name.String }}'s Library 21 | {{ else }} Library 22 | {{ end }} 23 |

24 | {{ if .user.Description.String }} 25 |

{{ simple_format .user.Description.String }}

26 | {{ end }} 27 |
28 |
29 |

Books

30 |

{{ books_count .user.ID }}

31 |
32 |
33 | {{ end }} 34 | -------------------------------------------------------------------------------- /views/partials/user_nav.html: -------------------------------------------------------------------------------- 1 | 54 | -------------------------------------------------------------------------------- /views/privacy.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Privacy Policy

4 | 5 |

6 | Information Collected 7 |

8 | 9 |

10 | By using google login Library will be able to collect the following personal information 11 | 12 |

    13 |
  • Name
  • 14 |
  • Email
  • 15 |
  • Account Image
  • 16 |
17 | 18 | This information is not shared with any third-party company and revealed only to users of Library through the usual features 19 |

20 | 21 |

22 | Other optional information you can fill it in the settings page of your account 23 | 24 |

    25 |
  • Facebook url
  • 26 |
  • Twitter url
  • 27 |
  • Other social media profiles urls
  • 28 |
29 | 30 | Will be also shared with other users in certain features. or when they visit your library...etc. 31 |

32 | 33 |

34 | Use of Cookies 35 |

36 | 37 |

38 | Library uses cookies only for essential features. like identifying your login status. And it doesn't use any cookies for tracking or for any other analytical purposes. 39 |

40 | 41 |

42 | Use of collected information 43 |

44 | 45 |

46 | The collected aforementioned information is used only to deliver Library features with no other uses or shares with other third-party entities other than Library users. 47 |

48 | 49 |
50 | -------------------------------------------------------------------------------- /views/shelves/edit.html: -------------------------------------------------------------------------------- 1 |
2 | {{ .csrf }} 3 | 4 |
5 | 6 |
7 | 15 | {{ template "common/errors" index .errors "name" }} 16 |
17 |
18 | 19 |
20 |
21 | 22 |
23 |
24 |
25 | 26 |
27 | 28 | {{ .csrf }} 29 | 30 |
31 | -------------------------------------------------------------------------------- /views/shelves/index.html: -------------------------------------------------------------------------------- 1 | {{ if not .shelves }} 2 |
3 | You don't have any shelves in your library yet. 4 |
5 | {{ else }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{ $lastitem := last .shelves }} 14 | {{ range .shelves }} 15 | 16 | 19 | 31 | 41 | 47 | 48 | {{ end }} 49 | 50 |
Shelf
17 | {{ .Name }} 18 | 20 | {{ if can $.current_user "down" . }} 21 | {{ if ne $lastitem . }} 22 |
23 | {{ $.csrf }} 24 | 27 |
28 | {{ end }} 29 | {{ end }} 30 |
32 | {{ if can $.current_user "up" . }} 33 |
34 | {{ $.csrf }} 35 | 38 |
39 | {{ end }} 40 |
42 | 43 | 44 | Edit 45 | 46 |
51 | {{ end }} 52 | 53 | {{ if can .current_user "create_shelf" .user }} 54 |
55 |
56 | {{ .csrf }} 57 | 58 |
59 |
60 | 61 | {{ template "common/errors" index .errors "name" }} 62 |
63 |
64 | 68 |
69 |
70 |
71 |
72 | {{ end }} 73 | -------------------------------------------------------------------------------- /views/users/edit.html: -------------------------------------------------------------------------------- 1 |
2 | {{ .csrf }} 3 | 4 |
5 | 6 |
7 | 10 |

Maximum 500 character.

11 | {{ template "common/errors" index .errors "description" }} 12 |
13 |
14 | 15 |
16 | 17 |
18 | 21 | {{ template "common/errors" index .errors "amazon_associates_id" }} 22 |
23 |
24 | 25 |
26 | Social Profiles 27 |
28 | 29 |
30 | 31 |
32 | 33 | {{ template "common/errors" index .errors "facebook" }} 34 |
35 |
36 | 37 |
38 | 39 |
40 | 41 | {{ template "common/errors" index .errors "twitter" }} 42 |
43 |
44 | 45 |
46 | 47 |
48 | 49 | {{ template "common/errors" index .errors "linkedin" }} 50 |
51 |
52 | 53 |
54 | 55 |
56 | 57 | {{ template "common/errors" index .errors "instagram" }} 58 |
59 |
60 | 61 |
62 | 63 |
64 | 65 | {{ template "common/errors" index .errors "phone" }} 66 |
67 |
68 | 69 |
70 | 71 |
72 | 73 | {{ template "common/errors" index .errors "whatsapp" }} 74 |
75 |
76 | 77 |
78 | 79 |
80 | 81 | {{ template "common/errors" index .errors "telegram" }} 82 |
83 |
84 | 85 |
86 |
87 | 88 |
89 |
90 |
91 | -------------------------------------------------------------------------------- /views/users/show.html: -------------------------------------------------------------------------------- 1 | {{ if .unshelved_books }} 2 |

Books lying around

3 | 4 |
5 | {{ range .unshelved_books }} 6 |
7 | {{ template "books/book" . }} 8 |
9 | {{ end }} 10 |
11 | 12 | {{ template "common/separator" }} 13 | {{ end }} 14 | 15 | {{ range .shelves }} 16 |

17 | {{ .Name }} 18 |

19 | {{ $b:=shelf_books .ID }} 20 | {{ if $b }} 21 |
22 | {{ range $b }} 23 |
24 | {{ template "books/book" . }} 25 |
26 | {{ end }} 27 |
28 | {{ else }} 29 |
30 |   31 |
32 | {{ end }} 33 | 34 | {{ template "common/separator" }} 35 | {{ end }} 36 | -------------------------------------------------------------------------------- /views/wide_layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | LIBRARY 9 | 10 | 11 | 12 | {{ partial .view . }} 13 | 14 | 15 | --------------------------------------------------------------------------------