This is a companion repository for the Build a Fullstack App in Vanilla JS & Go course on Frontend Masters.
The code snippets below are referenced throughout the course so you can either code along with Max Firtman or copy/paste. In the assets folders, you will find a copy of the slides and the final project.
Run go mod init frontendmasters.com/reelingit
to create the module.
Install the dependencies
go get github.com/joho/godotenv
go get github.com/lib/pq
Create the main.go file.
package main
func main() {
// Serve static files
http.Handle("/", http.FileServer(http.Dir("public")))
// Start server
const addr = ":8080"
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
Create a test index.html.
Create a logger package with a logger.go file
package logger
import (
"log"
"os"
)
type Logger struct {
infoLogger *log.Logger
errorLogger *log.Logger
file *os.File
}
// NewLogger creates a new logger with output to both file and stdout
func NewLogger(logFilePath string) (*Logger, error) {
file, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
return &Logger{
infoLogger: log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile),
errorLogger: log.New(file, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile),
file: file,
}, nil
}
// Info logs informational messages to stdout
func (l *Logger) Info(msg string) {
l.infoLogger.Printf("%s", msg)
}
// Error logs error messages to file
func (l *Logger) Error(msg string, err error) {
l.errorLogger.Printf("%s: %v", msg, err)
}
// Close closes the log file
func (l *Logger) Close() {
l.file.Close()
}
Now, change main.go with:
func main() {
// Initialize logger
logInstance := initializeLogger()
http.Handle("/", http.FileServer(http.Dir("public")))
// Start server
const addr = ":8080"
logInstance.Info("Server starting on " + addr)
if err := http.ListenAndServe(addr, nil); err != nil {
logInstance.Error("Server failed to start", err)
log.Fatalf("Server failed: %v", err)
}
}
func initializeLogger() *logger.Logger {
logInstance, err := logger.NewLogger("movie-service.log")
if err != nil {
log.Fatalf("Failed to initialize logger: %v", err)
}
return logInstance
}
Create the models package with the following files:
genre.go
package models
type Genre struct {
ID int
Name string
}
actor.go
package models
type Actor struct {
ID int
FirstName string
LastName string
ImageURL *string
}
movie.go
package models
type Movie struct {
ID int
TMDB_ID int
Title string
Tagline *string
ReleaseYear int
Genres []Genre
Overview *string
Score *float32
Popularity *float32
Keywords []string
Language *string
PosterURL *string
TrailerURL *string
Casting []Actor
}
Create the handlers package with a movies_handlers.go file.
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
)
type MovieHandler struct {
}
// Utility functions
func (h *MovieHandler) writeJSONResponse(w http.ResponseWriter, data interface{}) error {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
h.logger.Error("Failed to encode response", err)
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return err
}
return nil
}
func (h *MovieHandler) GetTopMovies(w http.ResponseWriter, r *http.Request) {
movies := []models.Movie{
{
ID: 1,
TMDB_ID: 101,
Title: "The Hacker",
ReleaseYear: 2022,
Genres: []models.Genre{{ID: 1, Name: "Thriller"}},
Keywords: []string{"hacking", "cybercrime"},
Casting: []models.Actor{{ID: 1, Name: "Jane Doe"}},
},
{
ID: 2,
TMDB_ID: 102,
Title: "Space Dreams",
ReleaseYear: 2020,
Genres: []models.Genre{{ID: 2, Name: "Sci-Fi"}},
Keywords: []string{"space", "exploration"},
Casting: []models.Actor{{ID: 2, Name: "John Star"}},
},
{
ID: 3,
TMDB_ID: 103,
Title: "The Lost City",
ReleaseYear: 2019,
Genres: []models.Genre{{ID: 3, Name: "Adventure"}},
Keywords: []string{"jungle", "treasure"},
Casting: []models.Actor{{ID: 3, Name: "Lara Hunt"}},
},
}
if h.writeJSONResponse(w, movies) == nil {
h.logger.Info("Successfully served top movies")
}
}
Now setup the handler in main.go
// ...
movieHandler := handlers.NewMovieHandler {}
// Set up routes
http.HandleFunc("/api/movies/top", movieHandler.GetTopMovies)
// ...
Check instructions at https://github.com/air-verse/air, such as executing
go install github.com/cosmtrek/air@latest
To customize it, create a .air.toml file. On Linux and macOS, you can use
# .air.toml
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ./main.go"
bin = "./tmp/main"
include_ext = ["go"] # Only watch .go files
exclude_dir = ["tmp", "vendor", "node_modules"]
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = true
On Windows, cmd
& bin
needs .exe
after main script name, so it should be:
# .air.toml
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main.exe ./main.go"
bin = "./tmp/main.exe"
include_ext = ["go"] # Only watch .go files
exclude_dir = ["tmp", "vendor", "node_modules"]
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = true
Set up a Postgres database and get a connection string, then, go to import/install.go and insert the string there.
Get into the import folder, and run go run install.go
. That should populate your database with all the data.
Create the data package and the interfaces.go file
package data
import "frontendmasters.com/movies/models"
type MovieStorage interface {
GetTopMovies() ([]models.Movie, error)
GetRandomMovies() ([]models.Movie, error)
GetMovieByID(id int) (models.Movie, error)
SearchMoviesByName(name string, order string, genre *int) ([]models.Movie, error)
GetAllGenres() ([]models.Genre, error)
}
Create a .env file in the root folder and add the connection string
DATABASE_URL=""
Open main.go and add this in the main function
// Load .env file
if err := godotenv.Load(); err != nil {
log.Printf("No .env file found or failed to load: %v", err)
}
// Initialize logger
logInstance := initializeLogger()
// Database connection
dbConnStr := os.Getenv("DATABASE_URL")
if dbConnStr == "" {
log.Fatalf("DATABASE_URL not set in environment")
}
db, err := sql.Open("postgres", dbConnStr)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
Modify the models to add metadata such as with models/movie.go
package models
type Movie struct {
ID int `json:"id"`
TMDB_ID int `json:"tmdb_id,omitempty"`
Title string `json:"title"`
Tagline *string `json:"tagline,omitempty"`
ReleaseYear int `json:"release_year"`
Genres []Genre `json:"genres"`
Overview *string `json:"overview,omitempty"`
Score *float32 `json:"score,omitempty"`
Popularity *float32 `json:"popularity,omitempty"`
Keywords []string `json:"keywords"`
Language *string `json:"language,omitempty"`
PosterURL *string `json:"poster_url,omitempty"`
TrailerURL *string `json:"trailer_url,omitempty"`
Casting []Actor `json:"casting"`
}
Create *data/movie_repository.go
package data
import (
"database/sql"
"errors"
"strconv"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
_ "github.com/lib/pq"
)
type MovieRepository struct {
db *sql.DB
logger *logger.Logger
}
func NewMovieRepository(db *sql.DB, log *logger.Logger) (*MovieRepository, error) {
return &MovieRepository{
db: db,
logger: log,
}, nil
}
const defaultLimit = 20
func (r *MovieRepository) GetTopMovies() ([]models.Movie, error) {
// Fetch movies
query := `
SELECT id, tmdb_id, title, tagline, release_year, overview, score,
popularity, language, poster_url, trailer_url
FROM movies
ORDER BY popularity DESC
LIMIT $1
`
return r.getMovies(query)
}
func (r *MovieRepository) getMovies(query string) ([]models.Movie, error) {
rows, err := r.db.Query(query, defaultLimit)
if err != nil {
r.logger.Error("Failed to query movies", err)
return nil, err
}
defer rows.Close()
var movies []models.Movie
for rows.Next() {
var m models.Movie
if err := rows.Scan(
&m.ID, &m.TMDB_ID, &m.Title, &m.Tagline, &m.ReleaseYear,
&m.Overview, &m.Score, &m.Popularity, &m.Language,
&m.PosterURL, &m.TrailerURL,
); err != nil {
r.logger.Error("Failed to scan movie row", err)
return nil, err
}
movies = append(movies, m)
}
return movies, nil
}
var (
ErrMovieNotFound = errors.New("movie not found")
)
Back in main.go, initialize the repository after the database creation
// Initialize repositories
movieRepo, err := data.NewMovieRepository(db, logInstance)
if err != nil {
log.Fatalf("Failed to initialize movie repository: %v", err)
}
Update handlers
type MovieHandler struct {
storage data.MovieStorage
logger *logger.Logger
}
func (h *MovieHandler) handleStorageError(w http.ResponseWriter, err error, context string) bool {
if err != nil {
if err == data.ErrMovieNotFound {
http.Error(w, context, http.StatusNotFound)
return true
}
h.logger.Error(context, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return true
}
return false
}
Update handler instance in main.go to use the new structure:
movieHandler := handlers.NewMovieHandler(movieRepo, logInstance)
The final movie_repository.go should look like
package data
import (
"database/sql"
"errors"
"strconv"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
_ "github.com/lib/pq"
)
type MovieRepository struct {
db *sql.DB
logger *logger.Logger
}
func NewMovieRepository(db *sql.DB, log *logger.Logger) (*MovieRepository, error) {
return &MovieRepository{
db: db,
logger: log,
}, nil
}
const defaultLimit = 20
func (r *MovieRepository) GetTopMovies() ([]models.Movie, error) {
// Fetch movies
query := `
SELECT id, tmdb_id, title, tagline, release_year, overview, score,
popularity, language, poster_url, trailer_url
FROM movies
ORDER BY popularity DESC
LIMIT $1
`
return r.getMovies(query)
}
func (r *MovieRepository) GetRandomMovies() ([]models.Movie, error) {
// Fetch movies
randomQuery := `
SELECT id, tmdb_id, title, tagline, release_year, overview, score,
popularity, language, poster_url, trailer_url
FROM movies
ORDER BY random()
LIMIT $1
`
return r.getMovies(randomQuery)
}
func (r *MovieRepository) getMovies(query string) ([]models.Movie, error) {
rows, err := r.db.Query(query, defaultLimit)
if err != nil {
r.logger.Error("Failed to query movies", err)
return nil, err
}
defer rows.Close()
var movies []models.Movie
for rows.Next() {
var m models.Movie
if err := rows.Scan(
&m.ID, &m.TMDB_ID, &m.Title, &m.Tagline, &m.ReleaseYear,
&m.Overview, &m.Score, &m.Popularity, &m.Language,
&m.PosterURL, &m.TrailerURL,
); err != nil {
r.logger.Error("Failed to scan movie row", err)
return nil, err
}
movies = append(movies, m)
}
return movies, nil
}
func (r *MovieRepository) GetMovieByID(id int) (models.Movie, error) {
// Fetch movie
query := `
SELECT id, tmdb_id, title, tagline, release_year, overview, score,
popularity, language, poster_url, trailer_url
FROM movies
WHERE id = $1
`
row := r.db.QueryRow(query, id)
var m models.Movie
err := row.Scan(
&m.ID, &m.TMDB_ID, &m.Title, &m.Tagline, &m.ReleaseYear,
&m.Overview, &m.Score, &m.Popularity, &m.Language,
&m.PosterURL, &m.TrailerURL,
)
if err == sql.ErrNoRows {
r.logger.Error("Movie not found", ErrMovieNotFound)
return models.Movie{}, ErrMovieNotFound
}
if err != nil {
r.logger.Error("Failed to query movie by ID", err)
return models.Movie{}, err
}
// Fetch related data
if err := r.fetchMovieRelations(&m); err != nil {
return models.Movie{}, err
}
return m, nil
}
func (r *MovieRepository) SearchMoviesByName(name string, order string, genre *int) ([]models.Movie, error) {
orderBy := "popularity DESC"
switch order {
case "score":
orderBy = "score DESC"
case "name":
orderBy = "title"
case "date":
orderBy = "release_year DESC"
}
genreFilter := ""
if genre != nil {
genreFilter = ` AND ((SELECT COUNT(*) FROM movie_genres
WHERE movie_id=movies.id
AND genre_id=` + strconv.Itoa(*genre) + `) = 1) `
}
// Fetch movies by name
query := `
SELECT id, tmdb_id, title, tagline, release_year, overview, score,
popularity, language, poster_url, trailer_url
FROM movies
WHERE (title ILIKE $1 OR overview ILIKE $1) ` + genreFilter + `
ORDER BY ` + orderBy + `
LIMIT $2
`
rows, err := r.db.Query(query, "%"+name+"%", defaultLimit)
if err != nil {
r.logger.Error("Failed to search movies by name", err)
return nil, err
}
defer rows.Close()
var movies []models.Movie
for rows.Next() {
var m models.Movie
if err := rows.Scan(
&m.ID, &m.TMDB_ID, &m.Title, &m.Tagline, &m.ReleaseYear,
&m.Overview, &m.Score, &m.Popularity, &m.Language,
&m.PosterURL, &m.TrailerURL,
); err != nil {
r.logger.Error("Failed to scan movie row", err)
return nil, err
}
movies = append(movies, m)
}
return movies, nil
}
func (r *MovieRepository) GetAllGenres() ([]models.Genre, error) {
query := `SELECT id, name FROM genres ORDER BY id`
rows, err := r.db.Query(query)
if err != nil {
r.logger.Error("Failed to query all genres", err)
return nil, err
}
defer rows.Close()
var genres []models.Genre
for rows.Next() {
var g models.Genre
if err := rows.Scan(&g.ID, &g.Name); err != nil {
r.logger.Error("Failed to scan genre row", err)
return nil, err
}
genres = append(genres, g)
}
return genres, nil
}
// fetchMovieRelations fetches genres, actors, and keywords for a movie
func (r *MovieRepository) fetchMovieRelations(m *models.Movie) error {
// Fetch genres
genreQuery := `
SELECT g.id, g.name
FROM genres g
JOIN movie_genres mg ON g.id = mg.genre_id
WHERE mg.movie_id = $1
`
genreRows, err := r.db.Query(genreQuery, m.ID)
if err != nil {
r.logger.Error("Failed to query genres for movie "+strconv.Itoa(m.ID), err)
return err
}
defer genreRows.Close()
for genreRows.Next() {
var g models.Genre
if err := genreRows.Scan(&g.ID, &g.Name); err != nil {
r.logger.Error("Failed to scan genre row", err)
return err
}
m.Genres = append(m.Genres, g)
}
// Fetch actors
actorQuery := `
SELECT a.id, a.first_name, a.last_name, a.image_url
FROM actors a
JOIN movie_cast mc ON a.id = mc.actor_id
WHERE mc.movie_id = $1
`
actorRows, err := r.db.Query(actorQuery, m.ID)
if err != nil {
r.logger.Error("Failed to query actors for movie "+strconv.Itoa(m.ID), err)
return err
}
defer actorRows.Close()
for actorRows.Next() {
var a models.Actor
if err := actorRows.Scan(&a.ID, &a.FirstName, &a.LastName, &a.ImageURL); err != nil {
r.logger.Error("Failed to scan actor row", err)
return err
}
m.Casting = append(m.Casting, a)
}
// Fetch keywords
keywordQuery := `
SELECT k.word
FROM keywords k
JOIN movie_keywords mk ON k.id = mk.keyword_id
WHERE mk.movie_id = $1
`
keywordRows, err := r.db.Query(keywordQuery, m.ID)
if err != nil {
r.logger.Error("Failed to query keywords for movie "+strconv.Itoa(m.ID), err)
return err
}
defer keywordRows.Close()
for keywordRows.Next() {
var k string
if err := keywordRows.Scan(&k); err != nil {
r.logger.Error("Failed to scan keyword row", err)
return err
}
m.Keywords = append(m.Keywords, k)
}
return nil
}
var (
ErrMovieNotFound = errors.New("movie not found")
)
The final movies_handler.go file should look like
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"frontendmasters.com/movies/data"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
)
type MovieHandler struct {
storage data.MovieStorage
logger *logger.Logger
}
// Utility functions
func (h *MovieHandler) writeJSONResponse(w http.ResponseWriter, data interface{}) error {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
h.logger.Error("Failed to encode response", err)
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return err
}
return nil
}
func (h *MovieHandler) handleStorageError(w http.ResponseWriter, err error, context string) bool {
if err != nil {
if err == data.ErrMovieNotFound {
http.Error(w, context, http.StatusNotFound)
return true
}
h.logger.Error(context, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return true
}
return false
}
func (h *MovieHandler) parseID(w http.ResponseWriter, idStr string) (int, bool) {
id, err := strconv.Atoi(idStr)
if err != nil {
h.logger.Error("Invalid ID format", err)
http.Error(w, "Invalid ID", http.StatusBadRequest)
return 0, false
}
return id, true
}
func (h *MovieHandler) GetTopMovies(w http.ResponseWriter, r *http.Request) {
movies, err := h.storage.GetTopMovies()
if h.handleStorageError(w, err, "Failed to get movies") {
return
}
if h.writeJSONResponse(w, movies) == nil {
h.logger.Info("Successfully served top movies")
}
}
func (h *MovieHandler) GetRandomMovies(w http.ResponseWriter, r *http.Request) {
movies, err := h.storage.GetRandomMovies()
if h.handleStorageError(w, err, "Failed to get movies") {
return
}
if h.writeJSONResponse(w, movies) == nil {
h.logger.Info("Successfully served random movies")
}
}
func (h *MovieHandler) SearchMovies(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
order := r.URL.Query().Get("order")
genreStr := r.URL.Query().Get("genre")
var genre *int
if genreStr != "" {
genreInt, ok := h.parseID(w, genreStr)
if !ok {
return
}
genre = &genreInt
}
var movies []models.Movie
var err error
if query != "" {
movies, err = h.storage.SearchMoviesByName(query, order, genre)
}
if h.handleStorageError(w, err, "Failed to get movies") {
return
}
if h.writeJSONResponse(w, movies) == nil {
h.logger.Info("Successfully served movies")
}
}
func (h *MovieHandler) GetMovie(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Path[len("/api/movies/"):]
id, ok := h.parseID(w, idStr)
if !ok {
return
}
movie, err := h.storage.GetMovieByID(id)
if h.handleStorageError(w, err, "Failed to get movie by ID") {
return
}
if h.writeJSONResponse(w, movie) == nil {
h.logger.Info("Successfully served movie with ID: " + idStr)
}
}
func (h *MovieHandler) GetGenres(w http.ResponseWriter, r *http.Request) {
genres, err := h.storage.GetAllGenres()
if h.handleStorageError(w, err, "Failed to get genres") {
return
}
if h.writeJSONResponse(w, genres) == nil {
h.logger.Info("Successfully served genres")
}
}
func NewMovieHandler(storage data.MovieStorage, log *logger.Logger) *MovieHandler {
return &MovieHandler{
storage: storage,
logger: log,
}
}
In main.go all the handlers for the API should look like:
// Initialize handlers
movieHandler := handlers.NewMovieHandler(movieRepo, logInstance)
// authHandler := handlers.NewAuthHandler(userStorage, jwt, logInstance)
// Set up routes
http.HandleFunc("/api/movies/random", movieHandler.GetRandomMovies)
http.HandleFunc("/api/movies/top", movieHandler.GetTopMovies)
http.HandleFunc("/api/movies/search", movieHandler.SearchMovies)
http.HandleFunc("/api/movies/", movieHandler.GetMovie)
http.HandleFunc("/api/genres", movieHandler.GetGenres)
http.HandleFunc("/api/account/register", movieHandler.GetGenres)
http.HandleFunc("/api/account/authenticate", movieHandler.GetGenres)
Create public.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ReelingIt - Movies</title>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@48,400,0,0" />
<link rel="stylesheet" href="/styles.css">
<meta name="theme-color" content="#56bce8">
<link rel="manifest" href="app.webmanifest">
<link rel="icon" href="/images/icon.png" type="image/png">
<script src="/app.js" type="module" defer></script>
<base href="/">
</head>
<body>
<header>
<h1>
<a href="/" class="navlink"><img src="/images/logo.png" height="35" alt="ReelingIt"></a>
</h1>
<nav>
<ul>
<li><a href="/" class="navlink">Movies</a></li>
<li><a href="/account/favorites" class="navlink">Favorites</a></li>
<li><a href="/account/watchlist" class="navlink">Watchlist</a></li>
<li><a href="/account/" class="navlink">My Account</a></li>
</ul>
</nav>
<div>
<form onsubmit="app.search(event)">
<input type="search" placeholder="Search movies">
</form>
</div>
</header>
<main>
</main>
<footer>
<p>© ReelingIt - FrontendMasters.com</p>
</footer>
</body>
</html>
Create app.js
window.app = {
search: (event) => {
event.preventDefault();
const keywords = document.querySelector("input[type=search]").value;
},
}
window.addEventListener("DOMContentLoaded", () => {
})
Add the app.webmanifest file to the project
{
"name": "ReelingIt",
"short_name": "ReelingIt",
"theme_color": "#43281C",
"display": "browser",
"background_color": "#56bce8",
"description": "The ultimate app for movie lovers: discover trailers, reviews, showtimes, and more. Experience cinema like never before!", "icons": [
{
"src": "images/icon.png",
"sizes": "1024x1024",
"type": "image/png"
}
]
}
Create services/API.js file:
export const API = {
baseURL: '/api/',
getTopMovies: async () => {
return await API.fetch("movies/top");
},
getRandomMovies: async () => {
return await API.fetch("movies/random");
},
getMovieById: async (id) => {
return await API.fetch(`/movies/${id}`);
},
searchMovies: async (q, order, genre) => {
return await API.fetch(`/movies/search`, {q, order, genre})
},
getGenres: async () => {
return await API.fetch("genres");
},
fetch: async (service, args) => {
try {
const queryString = args ? new URLSearchParams(args).toString() : "";
const response = await fetch(API.baseURL + service + '?' + queryString);
const result = await response.json();
return result;
} catch (e) {
console.error(e);
app.showError();
}
}
}
export default API;
Create a template in the index.html
<template id="template-home">
<section class="vertical-scroll" id="top-10">
<h2>This Week's Top 10</h2>
<ul>
<animated-loading data-elements="5"
data-width="150px" data-height="220px">
</animated-loading>
</ul>
</section>
<section class="vertical-scroll" id="random">
<h2>Something to watch today</h2>
<ul>
<animated-loading data-elements="5"
data-width="150px" data-height="220px">
</animated-loading>
</ul>
</section>
</template>
Create the components folder and MovieItem.js file
export class MovieItemComponent extends HTMLElement {
constructor(movie) {
super();
this.movie = movie;
}
connectedCallback() {
this.innerHTML = `
<a href="#">
<article>
<img src="${this.movie.poster_url}" alt="${this.movie.title} Poster">
<p>${this.movie.title} (${this.movie.release_year})</p>
</article>
</a>
`
}
}
customElements.define("movie-item", MovieItemComponent);
Create components/HomePage.js
import API from "../services/API.js";
import { MovieItemComponent } from "./MovieItem.js";
export default class HomePage extends HTMLElement {
async render() {
const topMovies = await API.getTopMovies();
renderMoviesInList(topMovies, this.querySelector("#top-10 ul"));
const randomMovies = await API.getRandomMovies();
renderMoviesInList(randomMovies, this.querySelector("#random ul"));
function renderMoviesInList(movies, ul) {
ul.innerHTML = "";
movies.forEach(movie => {
const li = document.createElement("li");
li.appendChild(new MovieItemComponent(movie));
ul.appendChild(li);
});
}
}
connectedCallback() {
const template = document.getElementById("template-home");
const content = template.content.cloneNode(true);
this.appendChild(content);
this.render();
}
}
customElements.define("home-page", HomePage);
Create the components/AnimatedLoading.js file:
class AnimatedLoading extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
let qty = this.dataset.elements ?? 1;
let width = this.dataset.width ?? "100px";
let height = this.dataset.height ?? "10px";
for (let i=0; i<qty; i++) {
const wrapper = document.createElement('div');
wrapper.setAttribute('class', 'loading-wave');
wrapper.style.width = width;
wrapper.style.height = height;
wrapper.style.margin = "10px";
wrapper.style.display = "inline-block";
this.appendChild(wrapper);
}
}
}
customElements.define('animated-loading', AnimatedLoading);
Add a new template to index.html
<template id="template-movie-details">
<article id="movie">
<h2><animated-loading elements="2"></animated-loading></h2>
<h3></h3>
<header>
<img src="" alt="Poster">
<youtube-embed id="trailer" data-url=""></youtube-embed>
<section id="actions">
<dl id="metadata">
</dl>
<button>Add to Favorites</button>
<button>Add to Watchlist</button>
</section>
</header>
<ul id="genres"></ul>
<p id="overview"></p>
<ul id="cast"></ul>
</article>
</template>
Create the components/MovieDetailsPage.js file:
import API from "../services/API.js";
export default class MovieDetailsPage extends HTMLElement {
movie = null;
async render(id) {
try {
this.movie = await API.getMovieById(id);
} catch (e) {
app.showError();
return;
}
const template = document.getElementById("template-movie-details");
const content = template.content.cloneNode(true);
this.appendChild(content);
this.querySelector("h2").textContent = this.movie.title;
this.querySelector("h3").textContent = this.movie.tagline;
this.querySelector("img").src = this.movie.poster_url;
this.querySelector("#trailer").dataset.url = this.movie.trailer_url;
this.querySelector("#overview").textContent = this.movie.overview;
this.querySelector("#metadata").innerHTML = `
<dt>Release Date</dt>
<dd>${this.movie.release_year}</dd>
<dt>Score</dt>
<dd>${this.movie.score} / 10</dd>
<dt>Original languae</dt>
<dd>${this.movie.language}</dd>
`;
const ulGenres = this.querySelector("#genres");
ulGenres.innerHTML = "";
this.movie.genres.forEach(genre => {
const li = document.createElement("li");
li.textContent = genre.name;
ulGenres.appendChild(li);
});
const ulCast = this.querySelector("#cast");
ulCast.innerHTML = "";
this.movie.casting.forEach(actor => {
const li = document.createElement("li");
li.innerHTML = `
<img src="${actor.image_url ?? '/images/generic_actor.jpg'}" alt="Picture of ${actor.last_name}">
<p>${actor.first_name} ${actor.last_name}</p>
`;
ulCast.appendChild(li);
});
}
connectedCallback() {
const id = this.params[0];
this.render(id);
}
}
customElements.define("movie-details-page", MovieDetailsPage);
Create the components/YouTubeEmbed.js file:
export class YouTubeEmbed extends HTMLElement {
static get observedAttributes() {
return ['data-url'];
}
attributeChangedCallback(prop, value) {
if (prop === 'data-url') {
const url = this.dataset.url;
const videoId = url.substring(url.indexOf("?v")+3);
console.log(videoId);
this.innerHTML = `
<iframe width="100%" height="300" src="https://www.youtube.com/embed/${videoId}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
`;
}
}
}
customElements.define("youtube-embed", YouTubeEmbed);
Let's create our routes for the client in /services/Routes.js and boilerplate code for all the Web Components.
export const routes = [
{
path: "/",
component: HomePage
},
{
path: "/movies",
component: MoviesPage
},
{
path: /\/movies\/(\d+)/,
component: MovieDetailsPage
},
{
path: "/account/register",
component: RegisterPage
},
{
path: "/account/login",
component: LoginPage
},
{
path: "/account/",
component: AccountPage
},
{
path: "/account/favorites",
component: FavoritesPage
},
{
path: "/account/watchlist",
component: WatchlistPage
},
]
Now let's make the router in services/Router.js
import { routes } from "./Routes.js";
const Router = {
init: () => {
document.querySelectorAll("a.navlink").forEach(a => {
a.addEventListener("click", event => {
event.preventDefault();
const href = a.getAttribute("href");
Router.go(href);
});
});
window.addEventListener("popstate", () => {
Router.go(location.pathname, false);
});
// Process initial URL
Router.go(location.pathname + location.search);
},
go: (route, addToHistory=true) => {
if (addToHistory) {
history.pushState(null, "", route);
}
const routePath = route.includes('?') ? route.split('?')[0] : route;
let pageElement = null;
for (const r of routes) {
if (typeof r.path === "string" && r.path === routePath) {
pageElement = new r.component();
break;
} else if (r.path instanceof RegExp) {
const match = r.path.exec(route);
if (match) {
const params = match.slice(1);
pageElement = new r.component();
pageElement.params = params;
break;
}
}
}
if (pageElement==null) {
pageElement = document.createElement("h1");
pageElement.textContent = "Page not found";
}
document.querySelector("main").innerHTML = "";
document.querySelector("main").appendChild(pageElement);
}
}
export default Router;
Now let's add the Router to app in app.js
and call init to enhance our links
window.app = {
API,
Router,
}
window.addEventListener("DOMContentLoaded", () => {
app.Router.init();
})
When we refresh the page on dynamic routes, we get a 404, to solve the problem, let's add some new Handlers in our backend, adding them before the file serving at main.go:
// Handler catch-all
catchAllHandler := func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./public/index.html")
}
http.HandleFunc("/movies", catchAllHandler)
http.HandleFunc("/movies/", catchAllHandler)
http.HandleFunc("/account/", catchAllHandler)
At Router.js we can remove the last two code lines with this:
function updatePage() {
document.querySelector("main").innerHTML = "";
document.querySelector("main").appendChild(pageElement);
}
if (!document.startViewTransition) {
updatePage();
} else {
const oldPage = document.querySelector("main").firstElementChild;
if (oldPage) oldPage.style.viewTransitionName = "old";
pageElement.style.viewTransitionName = "new";
document.startViewTransition( () => updatePage() );
}
Add in index.html
<!-- Alert Modal -->
<dialog id="alert-modal">
<h3>Error</h3>
<p>There was an error loading the page</p>
<button class="action-btn" onclick="app.closeError()">OK</button>
</dialog>
Then, in app.js
window.app = {
// ...
showError: (message = 'There was an error loading the page', goToHome=true) => {
document.querySelector("#alert-modal").showModal()
document.querySelector("#alert-modal p").textContents = message;
if (goToHome) app.Router.go("/");
return;
},
closeError: () => {
document.getElementById('alert-modal').close()
},
// ...
}
Add the template in index.html
<template id="template-movies">
<section>
<div id="search-header">
<h2></h2>
<section id="filters">
<select id="filter" onchange="app.searchFilterChange(this.value)">
<option>Filter by Genre</option>
</select>
<select id="order" onchange="app.searchOrderChange(this.value)">
<option value="popularity">Sort by Popularity</option>
<option value="score">Sort by Score</option>
<option value="date">Sort by Release Date</option>
<option value="name">Sort by Name</option>
</select>
</section>
</div>
<ul id="movies-result">
<animated-loading data-elements="5"
data-width="150px" data-height="220px">
</animated-loading>
</ul>
</section>
</template>
And then for the Web Component MoviesPage
import API from "../services/API.js";
import { MovieItemComponent } from "./MovieItem.js";
export default class MoviesPage extends HTMLElement {
async render(query) {
const urlParams = new URLSearchParams(window.location.search);
const order = urlParams.get("order") ?? "";
const genre = urlParams.get("genre") ?? "";
const movies = await API.searchMovies(query, order, genre);
const ulMovies = this.querySelector("ul");
ulMovies.innerHTML = "";
if (movies && movies.length>0) {
movies.forEach(movie => {
const li = document.createElement("li");
li.appendChild(new MovieItemComponent(movie));
ulMovies.appendChild(li);
});
} else {
ulMovies.innerHTML = "<h3>There are no movies with your search</h3>";
}
//await this.loadGenres();
if (order) this.querySelector("#order").value = order;
if (genre) this.querySelector("#filter").value = genre;
}
connectedCallback() {
const template = document.getElementById("template-movies");
const content = template.content.cloneNode(true);
this.appendChild(content);
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q');
if (query) {
this.querySelector("h2").textContent = `'${query}' movies`;
this.render(query);
} else {
app.showError();
}
}
}
customElements.define("movies-page", MoviesPage);
At app.js add the following function to app
// ...
search: (event) => {
event.preventDefault();
const keywords = document.querySelector("input[type=search]").value;
if (keywords.length>1) {
app.Router.go(`/movies?q=${keywords}`)
}
},
// ...
Add in MoviesComponent.js
async loadGenres() {
const genres = await API.getGenres();
const select = this.querySelector("#filter");
select.innerHTML = `
<option value=''>Filter by Genre</option>
`;
genres.forEach(genre => {
var option = document.createElement("option");
option.value = genre.id;
option.textContent = genre.name;
select.appendChild(option);
})
}
Call it from connectedCallback
In app.js add the new functions to the app object:
// ...
searchOrderChange: (order) => {
const urlParams = new URLSearchParams(window.location.search);
const q = urlParams.get("q");
const genre = urlParams.get("genre") ?? "";
app.Router.go(`/movies?q=${q}&order=${order}&genre=${genre}`);
},
searchFilterChange: (genre) => {
const urlParams = new URLSearchParams(window.location.search);
const q = urlParams.get("q");
const order = urlParams.get("order") ?? "";
app.Router.go(`/movies?q=${q}&order=${order}&genre=${genre}`);
// ...
}
Let's add a new dependency, executing:
go get "golang.org/x/crypto/bcrypt"
Let's add a new interface in data/interfaces.go
type AccountStorage interface {
Authenticate(string, string) (bool, error)
Register(string, string, string) (bool, error)
GetAccountDetails(string) (models.User, error)
SaveCollection(models.User, int, string) (bool, error)
}
And we create a new implementation at data/account_repository.go
package data
import (
"database/sql"
"errors"
"time"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
type AccountRepository struct {
db *sql.DB
logger *logger.Logger
}
func NewAccountRepository(db *sql.DB, log *logger.Logger) (*AccountRepository, error) {
return &AccountRepository{
db: db,
logger: log,
}, nil
}
func (r *AccountRepository) Register(name, email, password string) (bool, error) {
// Validate basic requirements
if name == "" || email == "" || password == "" {
r.logger.Error("Registration validation failed: missing required fields", nil)
return false, ErrRegistrationValidation
}
// Check if user already exists
var exists bool
err := r.db.QueryRow(`
SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)
`, email).Scan(&exists)
if err != nil {
r.logger.Error("Failed to check existing user", err)
return false, err
}
if exists {
r.logger.Error("User already exists with email: "+email, ErrUserAlreadyExists)
return false, ErrUserAlreadyExists
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
r.logger.Error("Failed to hash password", err)
return false, err
}
// Insert new user
query := `
INSERT INTO users (name, email, password_hashed, time_created)
VALUES ($1, $2, $3, $4)
RETURNING id
`
var userID int
err = r.db.QueryRow(
query,
name,
email,
string(hashedPassword),
time.Now(),
).Scan(&userID)
if err != nil {
r.logger.Error("Failed to register user", err)
return false, err
}
return true, nil
}
func (r *AccountRepository) Authenticate(email string, password string) (bool, error) {
if email == "" || password == "" {
r.logger.Error("Authentication validation failed: missing credentials", nil)
return false, ErrAuthenticationValidation
}
// Fetch user by email
var user models.User
query := `
SELECT id, name, email, password_hashed
FROM users
WHERE email = $1 AND time_deleted IS NULL
`
err := r.db.QueryRow(query, email).Scan(
&user.ID,
&user.Name,
&user.Email,
&user.PasswordHashed,
)
if err == sql.ErrNoRows {
r.logger.Error("User not found for email: "+email, nil)
return false, ErrAuthenticationValidation
}
if err != nil {
r.logger.Error("Failed to query user for authentication", err)
return false, err
}
// Verify password
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHashed), []byte(password))
if err != nil {
r.logger.Error("Password mismatch for email: "+email, nil)
return false, ErrAuthenticationValidation
}
// Update last login time
updateQuery := `
UPDATE users
SET last_login = $1
WHERE id = $2
`
_, err = r.db.Exec(updateQuery, time.Now(), user.ID)
if err != nil {
r.logger.Error("Failed to update last login", err)
// Don't fail authentication just because last login update failed
}
return true, nil
}
func (r *AccountRepository) GetAccountDetails(email string) (models.User, error) {
var user models.User
query := `
SELECT id, name, email
FROM users
WHERE email = $1 AND time_deleted IS NULL
`
err := r.db.QueryRow(query, email).Scan(
&user.ID,
&user.Name,
&user.Email,
)
if err == sql.ErrNoRows {
r.logger.Error("User not found for email: "+email, nil)
return models.User{}, ErrUserNotFound
}
if err != nil {
r.logger.Error("Failed to query user by email", err)
return models.User{}, err
}
// Fetch favorites
favoritesQuery := `
SELECT m.id, m.tmdb_id, m.title, m.tagline, m.release_year,
m.overview, m.score, m.popularity, m.language,
m.poster_url, m.trailer_url
FROM movies m
JOIN user_movies um ON m.id = um.movie_id
WHERE um.user_id = $1 AND um.relation_type = 'favorite'
`
favoriteRows, err := r.db.Query(favoritesQuery, user.ID)
if err != nil {
r.logger.Error("Failed to query user favorites", err)
return user, err
}
defer favoriteRows.Close()
for favoriteRows.Next() {
var m models.Movie
if err := favoriteRows.Scan(
&m.ID, &m.TMDB_ID, &m.Title, &m.Tagline, &m.ReleaseYear,
&m.Overview, &m.Score, &m.Popularity, &m.Language,
&m.PosterURL, &m.TrailerURL,
); err != nil {
r.logger.Error("Failed to scan favorite movie row", err)
return user, err
}
user.Favorites = append(user.Favorites, m)
}
// Fetch watchlist
watchlistQuery := `
SELECT m.id, m.tmdb_id, m.title, m.tagline, m.release_year,
m.overview, m.score, m.popularity, m.language,
m.poster_url, m.trailer_url
FROM movies m
JOIN user_movies um ON m.id = um.movie_id
WHERE um.user_id = $1 AND um.relation_type = 'watchlist'
`
watchlistRows, err := r.db.Query(watchlistQuery, user.ID)
if err != nil {
r.logger.Error("Failed to query user watchlist", err)
return user, err
}
defer watchlistRows.Close()
for watchlistRows.Next() {
var m models.Movie
if err := watchlistRows.Scan(
&m.ID, &m.TMDB_ID, &m.Title, &m.Tagline, &m.ReleaseYear,
&m.Overview, &m.Score, &m.Popularity, &m.Language,
&m.PosterURL, &m.TrailerURL,
); err != nil {
r.logger.Error("Failed to scan watchlist movie row", err)
return user, err
}
user.Watchlist = append(user.Watchlist, m)
}
return user, nil
}
func (r *AccountRepository) SaveCollection(user models.User, movieID int, collection string) (bool, error) {
// Validate inputs
if movieID <= 0 {
r.logger.Error("SaveCollection failed: invalid movie ID", nil)
return false, errors.New("invalid movie ID")
}
if collection != "favorite" && collection != "watchlist" {
r.logger.Error("SaveCollection failed: invalid collection type", nil)
return false, errors.New("collection must be 'favorite' or 'watchlist'")
}
// Get user ID from email
var userID int
err := r.db.QueryRow(`
SELECT id
FROM users
WHERE email = $1 AND time_deleted IS NULL
`, user.Email).Scan(&userID)
if err == sql.ErrNoRows {
r.logger.Error("User not found", nil)
return false, ErrUserNotFound
}
if err != nil {
r.logger.Error("Failed to query user ID", err)
return false, err
}
// Check if the relationship already exists
var exists bool
err = r.db.QueryRow(`
SELECT EXISTS(
SELECT 1
FROM user_movies
WHERE user_id = $1
AND movie_id = $2
AND relation_type = $3
)
`, userID, movieID, collection).Scan(&exists)
if err != nil {
r.logger.Error("Failed to check existing collection entry", err)
return false, err
}
if exists {
r.logger.Info("Movie already in " + collection + " for user")
return true, nil // Return true since the movie is already in the collection
}
// Insert the new relationship
query := `
INSERT INTO user_movies (user_id, movie_id, relation_type, time_added)
VALUES ($1, $2, $3, $4)
`
_, err = r.db.Exec(query, userID, movieID, collection, time.Now())
if err != nil {
r.logger.Error("Failed to save movie to "+collection, err)
return false, err
}
r.logger.Info("Successfully added movie " + string(movieID) + " to " + collection + " for user")
return true, nil
}
var (
ErrRegistrationValidation = errors.New("registration failed")
ErrAuthenticationValidation = errors.New("authentication failed")
ErrUserAlreadyExists = errors.New("user already exists")
ErrUserNotFound = errors.New("user not found")
)
Now we create handlers/account_handlers.go
package handlers
import (
"context"
"encoding/json"
"net/http"
"strings"
"frontendmasters.com/movies/data"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
"frontendmasters.com/movies/token"
)
// Define request structure
type RegisterRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
// Define request structure
type AuthRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type AuthResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type AccountHandler struct {
storage data.AccountStorage
logger *logger.Logger
}
// Utility functions
func (h *AccountHandler) writeJSONResponse(w http.ResponseWriter, data interface{}) error {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
h.logger.Error("Failed to encode response", err)
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return err
}
return nil
}
func (h *AccountHandler) handleStorageError(w http.ResponseWriter, err error, context string) bool {
if err != nil {
switch err {
case data.ErrAuthenticationValidation, data.ErrUserAlreadyExists, data.ErrRegistrationValidation:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(AuthResponse{Success: false, Message: err.Error()})
return true
case data.ErrUserNotFound:
http.Error(w, "User not found", http.StatusNotFound)
return true
default:
h.logger.Error(context, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return true
}
}
return false
}
func (h *AccountHandler) Register(w http.ResponseWriter, r *http.Request) {
// Parse request body
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode registration request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Register the user
success, err := h.storage.Register(req.Name, req.Email, req.Password)
if h.handleStorageError(w, err, "Failed to register user") {
return
}
// Return success response
response := AuthResponse{
Success: success,
Message: "User registered successfully",
}
if err := h.writeJSONResponse(w, response); err == nil {
h.logger.Info("Successfully registered user with email: " + req.Email)
}
}
func (h *AccountHandler) Authenticate(w http.ResponseWriter, r *http.Request) {
// Parse request body
var req AuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode authentication request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Authenticate the user
success, err := h.storage.Authenticate(req.Email, req.Password)
if h.handleStorageError(w, err, "Failed to authenticate user") {
return
}
// Return success response
response := AuthResponse{
Success: success,
Message: "User registered successfully",
}
if err := h.writeJSONResponse(w, response); err == nil {
h.logger.Info("Successfully authenticated user with email: " + req.Email)
}
}
func NewAccountHandler(storage data.AccountStorage, log *logger.Logger) *AccountHandler {
return &AccountHandler{
storage: storage,
logger: log,
}
}
Finally, we register the handlers in main.go
accountRepo, err := data.NewAccountRepository(db, logInstance)
if err != nil {
log.Fatalf("Failed to initialize account repository: %v", err)
}
// ...
accountHandler := handlers.NewAccountHandler(accountRepo, logInstance)
http.HandleFunc("/api/account/register/", accountHandler.Register)
http.HandleFunc("/api/account/authenticate/", accountHandler.Authenticate)
Add the new templates and populate the Web Component objects for LoginPage and RegisterPage
<template id="template-register">
<section>
<h2>Register a New Account</h2>
<form onsubmit="app.register(event)">
<label for="register-name">Name</label>
<input type="text" id="register-name" placeholder="Name" required autocomplete="name">
<label for="register-email">Email</label>
<input type="email" id="register-email" placeholder="Email" required autocomplete="email">
<label for="register-password">Password</label>
<input type="password" id="register-password" placeholder="Password" required autocomplete="new-password">
<label for="register-password-confirm">Confirm Password</label>
<input type="password" id="register-password-confirm" placeholder="Confirm Password" required autocomplete="new-password">
<button>Register</button>
<p>If you already have an account, please <a href="/account/login">login</a>.</p>
</form>
</section>
</template>
<template id="template-login">
<section>
<h2>Login into Your Account</h2>
<form onsubmit="app.login(event)">
<label for="login-email">Email</label>
<input type="email" id="login-email" placeholder="Email" required autocomplete="email">
<label for="login-password">Password</label>
<input type="password" id="login-password" placeholder="Password" required autocomplete="current-password">
<button>Log In</button>
<p>If you don't have an account, please <a href="/account/register">register</a>.</p>
</form>
</section>
</template>
Add to API.js
register: async (name, email, password) => {
return await API.send("account/register/", {name, email, password})
},
authenticate: async (email, password) => {
return await API.send("account/authenticate/", {email, password})
},
send: async (service, args) => {
try {
const response = await fetch(API.baseURL + service, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(args)
});
const result = await response.json();
return result;
} catch (e) {
console.error(e);
app.showError();
}
},
In app.js we will now add:
register: async (event) => {
event.preventDefault();
let errors = [];
const name = document.getElementById("register-name").value;
const email = document.getElementById("register-email").value;
const password = document.getElementById("register-password").value;
const passwordConfirm = document.getElementById("register-password-confirm").value;
if (name.length < 4) errors.push("Enter your complete name");
if (email.length < 8) errors.push("Enter your complete email");
if (password.length < 6) errors.push("Enter a password with 6 characters");
if (password != passwordConfirm) errors.push("Passwords don't match");
if (errors.length==0) {
const response = await API.register(name, email, password);
if (response.success) {
app.Router.go("/account/")
} else {
app.showError(response.message, false);
}
} else {
app.showError(errors.join(". "), false);
}
},
login: async (event) => {
event.preventDefault();
let errors = [];
const email = document.getElementById("login-email").value;
const password = document.getElementById("login-password").value;
if (email.length < 8) errors.push("Enter your complete email");
if (password.length < 6) errors.push("Enter a password with 6 characters");
if (errors.length==0) {
const response = await API.authenticate(email, password);
if (response.success) {
app.Router.go("/account/")
} else {
app.showError(response.message, false);
}
} else {
app.showError(errors.join(". "), false);
}
},
To implement JWT, we need to install a package
go get -u github.com/golang-jwt/jwt/v5
Then, we create the token package and inside two file. Starting with getsecret.go
package token
import (
"os"
"frontendmasters.com/movies/logger"
)
func GetJWTSecret(logger logger.Logger) string {
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "default-secret-for-dev"
logger.Info("JWT_SECRET not set, using default development secret")
} else {
logger.Info("Using JWT_SECRET from environment")
}
return jwtSecret
}
Then, creation.go
package token
import (
"time"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
"github.com/golang-jwt/jwt/v5"
)
func CreateJWT(user models.User, logger logger.Logger) string {
jwtSecret := GetJWTSecret(logger)
// Create a JWT token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"exp": time.Now().Add(time.Hour * 72).Unix(), // Token expires in 72 hours
})
// Sign the token with the secret
tokenString, err := token.SignedString([]byte(jwtSecret))
if err != nil {
logger.Error("Failed to sign JWT", err)
return ""
}
return tokenString
}
And finally, validation.go
package token
import (
"frontendmasters.com/movies/logger"
"github.com/golang-jwt/jwt/v5"
)
func ValidateJWT(tokenString string, logger logger.Logger) (*jwt.Token, error) {
jwtSecret := GetJWTSecret(logger)
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Ensure the token's signing method is HMAC
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
logger.Error("Unexpected signing method", nil)
return nil, jwt.ErrTokenSignatureInvalid
}
return []byte(jwtSecret), nil
})
if err != nil {
logger.Error("Failed to validate JWT", err)
return nil, err
}
if !token.Valid {
logger.Error("Invalid JWT token", nil)
return nil, jwt.ErrTokenInvalidId
}
return token, nil
}
Finally, we add it to our AuthResponse struct in account_handlers.go and return it after a successful registration or authentication.
type AuthResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
JWT string `json:"jwt"`
}
// ...
// in register
response := AuthResponse{
Success: success,
Message: "User registered successfully",
JWT: token.CreateJWT(models.User{Email: req.Email, Name: req.Name}, *h.logger),
}
// ...
// in authenticate
response := AuthResponse{
Success: success,
Message: "User registered successfully",
JWT: token.CreateJWT(models.User{Email: req.Email}, *h.logger),
}
We first create a services/Store.js
const Store = {
jwt: null,
get loggedIn() {
return this.jwt !== null;
}
}
if (localStorage.getItem("jwt")) {
Store.jwt = localStorage.getItem("jwt");
}
const proxiedStore = new Proxy(Store, {
set: (target, prop, value) => {
switch (prop) {
case "jwt":
target[prop] = value;
localStorage.setItem("jwt", value)
break;
}
return true;
}
});
export default proxiedStore;
Then, in app.js we save the jwt after a successful login or registration
// ...
if (response.success) {
app.Store.jwt = response.jwt;
app.Router.go("/account/")
} else {
app.showError(response.message, false);
}
// ...
Finally, we update our router, so it can detect and work with URLs needing authentication
// ...
for (const r of routes) {
if (typeof r.path === "string" && r.path === routePath) {
pageElement = new r.component();
pageElement.loggedIn = r.loggedIn;
} else if (r.path instanceof RegExp) {
const match = r.path.exec(route);
if (match) {
const params = match.slice(1);
pageElement = new r.component();
pageElement.loggedIn = r.loggedIn;
pageElement.params = params;
}
}
if (pageElement) {
// A page was found, we checked if we have access to it.
if (pageElement.loggedIn && app.Store.loggedIn==false) {
app.Router.go("/account/login");
return;
}
break;
}
}
// ...
We start by adding a template to our index.html
<template id="template-account">
<section id="account">
<h2>You are Logged In</h2>
<button onclick="app.logout()">Log out</button>
<button onclick="app.Router.go('/account/favorites')">Your Favorites</button>
<button onclick="app.Router.go('/account/watchlist')">Your Watchlist</button>
</section>
</template>
We create the component AccountPage.js
import API from "../services/API.js";
import { MovieItemComponent } from "./MovieItem.js";
export default class AccountPage extends HTMLElement {
connectedCallback() {
const template = document.getElementById("template-account");
const content = template.content.cloneNode(true);
this.appendChild(content);
}
}
customElements.define("account-page", AccountPage);
We finally define the route in Router.js and implement app.logout in main.js
// ...
{
path: "/account/",
component: AccountPage,
loggedIn: true
},
// ...
At AccountHandlers.go add the following code:
func (h *AccountHandler) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := r.Header.Get("Authorization")
if tokenStr == "" {
http.Error(w, "Missing authorization token", http.StatusUnauthorized)
return
}
// Remove "Bearer " prefix if present
tokenStr = strings.TrimPrefix(tokenStr, "Bearer ")
// Parse and validate the token
token, err := jwt.Parse(tokenStr,
func(t *jwt.Token) (interface{}, error) {
// Ensure the signing method is HMAC
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(token.GetJWTSecret(*h.logger)), nil
},
)
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// Extract claims from the token
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
// Get the email from claims
email, ok := claims["email"].(string)
if !ok {
http.Error(w, "Email not found in token", http.StatusUnauthorized)
return
}
// Inject email into the request context
ctx := context.WithValue(r.Context(), "email", email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
In AccountHandlers.go add the new handlers
func (h *AccountHandler) SaveToCollection(w http.ResponseWriter, r *http.Request) {
type CollectionRequest struct {
MovieID int `json:"movie_id"`
Collection string `json:"collection"`
}
var req CollectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode collection request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
email, ok := r.Context().Value("email").(string)
if !ok {
http.Error(w, "Unable to retrieve email", http.StatusInternalServerError)
return
}
success, err := h.storage.SaveCollection(models.User{Email: email},
req.MovieID, req.Collection)
if h.handleStorageError(w, err, "Failed to save to collection") {
return
}
response := AuthResponse{
Success: success,
Message: "Movie added to " + req.Collection + " successfully",
}
if err := h.writeJSONResponse(w, response); err == nil {
h.logger.Info("Successfully saved movie to " + req.Collection)
}
}
func (h *AccountHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
email, ok := r.Context().Value("email").(string)
if !ok {
http.Error(w, "Unable to retrieve email", http.StatusInternalServerError)
return
}
details, err := h.storage.GetAccountDetails(email)
if err != nil {
http.Error(w, "Unable to retrieve collections", http.StatusInternalServerError)
return
}
if err := h.writeJSONResponse(w, details.Favorites); err == nil {
h.logger.Info("Successfully sent favorites")
}
}
func (h *AccountHandler) GetWatchlist(w http.ResponseWriter, r *http.Request) {
email, ok := r.Context().Value("email").(string)
if !ok {
http.Error(w, "Unable to retrieve email", http.StatusInternalServerError)
return
}
details, err := h.storage.GetAccountDetails(email)
if err != nil {
http.Error(w, "Unable to retrieve collections", http.StatusInternalServerError)
return
}
if err := h.writeJSONResponse(w, details.Watchlist); err == nil {
h.logger.Info("Successfully sent favorites")
}
}
Then register them in main.go
http.Handle("/api/account/favorites/",
accountHandler.AuthMiddleware(http.HandlerFunc(accountHandler.GetFavorites)))
http.Handle("/api/account/watchlist/",
accountHandler.AuthMiddleware(http.HandlerFunc(accountHandler.GetWatchlist)))
http.Handle("/api/account/save-to-collection/",
accountHandler.AuthMiddleware(http.HandlerFunc(accountHandler.SaveToCollection)))
We need to change API.js to send the token if it's available
send: async (service, args) => {
const response = await fetch(API.baseURL + service, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": app.Store.jwt ? `Bearer ${app.Store.jwt}` : null
},
body: JSON.stringify(args)
});
const result = await response.json();
return result;
},
fetch: async (service, args) => {
const queryString = args ? new URLSearchParams(args).toString() : "";
const response = await fetch(API.baseURL + service + '?' + queryString, {
headers: {
"Authorization": app.Store.jwt ? `Bearer ${app.Store.jwt}` : null
}
});
const result = await response.json();
return result;
}
Then, on the same file we will add the new services:
getFavorites: async () => {
try {
return await API.fetch("account/favorites");
} catch (e) {
app.Router.go("/account/")
}
},
getWatchlist: async () => {
try {
return await API.fetch("account/watchlist");
} catch (e) {
app.Router.go("/account/")
}
},
saveToCollection: async (movie_id, collection) => {
return await API.send("account/save-to-collection/", {
movie_id, collection
});
},
Add this template to index.html
<template id="template-collection">
<section>
<ul id="movies-result">
<animated-loading data-elements="5"
data-width="150px" data-height="220px">
</animated-loading>
</ul>
</section>
</template>
Then, create CollectionPage.js
import { MovieItemComponent } from "./MovieItem.js";
export class CollectionPage extends HTMLElement {
constructor(endpoint, title) {
super();
this.endpoint = endpoint;
this.title = title;
}
async render() {
const movies = await this.endpoint()
const ulMovies = this.querySelector("ul");
ulMovies.innerHTML = "";
if (movies && movies.length>0) {
movies.forEach(movie => {
const li = document.createElement("li");
li.appendChild(new MovieItemComponent(movie));
ulMovies.appendChild(li);
});
} else {
ulMovies.innerHTML = "<h3>There are no movies</h3>";
}
;
}
connectedCallback() {
const template = document.getElementById("template-collection");
const content = template.content.cloneNode(true);
this.appendChild(content);
this.render();
}
}
And two other components, FavoritePage.js
import API from "../services/API.js";
import { CollectionPage } from "./CollectionPage.js";
export default class FavoritePage extends CollectionPage {
constructor() {
super(API.getFavorites, "Favorite Movies")
}
}
customElements.define("favorite-page", FavoritePage);
And then WatchlistPage.js
import API from "../services/API.js";
import { CollectionPage } from "./CollectionPage.js";
export default class WatchlistPage extends CollectionPage {
constructor() {
super(API.getWatchlist, "Movie Watchlist")
}
}
customElements.define("watchlist-page", WatchlistPage);
And finally define the routes in Routes.js
// ...
{
path: "/account/favorites",
component: FavoritePage,
loggedIn: true
},
{
path: "/account/watchlist",
component: WatchlistPage,
loggedIn: true
},
In app.js lets add the new two features:
saveToCollection: async (movie_id, collection) => {
if (app.Store.loggedIn) {
try {
const response = await API.saveToCollection(movie_id, collection);
if (response.success) {
switch(collection) {
case "favorite":
app.Router.go("/account/favorites")
break;
case "watchlist":
app.Router.go("/account/watchlist")
}
} else {
app.showError("We couldn't save the movie.")
}
} catch (e) {
console.log(e)
}
} else {
app.Router.go("/account/");
}
}
Back in MovieDetailsPage.js, add the following calls
this.querySelector("#btnFavorites").addEventListener("click", () => {
app.saveToCollection(this.movie.id, "favorite")
})
this.querySelector("#btnWatchlist").addEventListener("click", () => {
app.saveToCollection(this.movie.id, "watchlist")
})
Run in the console
go get "github.com/go-webauthn/webauthn/webauthn"
Run in the database the following query:
CREATE SEQUENCE IF NOT EXISTS passkeys_id_seq;
CREATE TABLE "public"."passkeys" (
"id" int4 NOT NULL DEFAULT nextval('passkeys_id_seq'::regclass),
"user_id" int4,
"keys" text,
PRIMARY KEY ("id")
);
Then let's create a new model as passkeyuser.go
package models
import "github.com/go-webauthn/webauthn/webauthn"
type PasskeyUser struct {
ID []byte
DisplayName string
Name string
Credentials []webauthn.Credential
}
func (u *PasskeyUser) WebAuthnID() []byte {
return u.ID
}
func (u *PasskeyUser) WebAuthnName() string {
return u.Name
}
func (u *PasskeyUser) WebAuthnDisplayName() string {
return u.DisplayName
}
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential {
return u.Credentials
}
func (u PasskeyUser) WebAuthnIcon() string {
return ""
}
func (u *PasskeyUser) PutCredential(credential webauthn.Credential) {
u.Credentials = append(u.Credentials, credential)
}
func (u *PasskeyUser) AddCredential(credential *webauthn.Credential) {
u.Credentials = append(u.Credentials, *credential)
}
func (u *PasskeyUser) UpdateCredential(credential *webauthn.Credential) {
for i, c := range u.Credentials {
if string(c.ID) == string(credential.ID) {
u.Credentials[i] = *credential
}
}
}
Let's update our interfaces.go
type PasskeyStore interface {
GetUserByEmail(userName string) (*models.PasskeyUser, error)
GetUserByID(ID int) (*models.PasskeyUser, error)
SaveUser(models.PasskeyUser)
GenSessionID() (string, error)
GetSession(token string) (webauthn.SessionData, bool)
SaveSession(token string, data webauthn.SessionData)
DeleteSession(token string)
}
Now let's create the data/passkey_repository.go
package data
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"strconv"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
"github.com/go-webauthn/webauthn/webauthn"
)
// PasskeyRepository manages WebAuthn passkey data using a database.
type PasskeyRepository struct {
db *sql.DB // Database connection
sessions map[string]webauthn.SessionData // In-memory session storage
log logger.Logger // Logger for debugging and errors
}
// NewPasskeyRepository initializes a new PasskeyRepository with a database connection.
func NewPasskeyRepository(db *sql.DB, log logger.Logger) *PasskeyRepository {
return &PasskeyRepository{
db: db,
sessions: make(map[string]webauthn.SessionData),
log: log,
}
}
// GenSessionID generates a random session ID.
func (r *PasskeyRepository) GenSessionID() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// GetSession retrieves session data from the in-memory map.
func (r *PasskeyRepository) GetSession(token string) (webauthn.SessionData, bool) {
r.log.Info(fmt.Sprintf("GetSession: %v", r.sessions[token]))
val, ok := r.sessions[token]
return val, ok
}
// SaveSession stores session data in the in-memory map.
func (r *PasskeyRepository) SaveSession(token string, data webauthn.SessionData) {
r.log.Info(fmt.Sprintf("SaveSession: %s - %v", token, data))
r.sessions[token] = data
}
// DeleteSession removes session data from the in-memory map.
func (r *PasskeyRepository) DeleteSession(token string) {
r.log.Info(fmt.Sprintf("DeleteSession: %v", token))
delete(r.sessions, token)
}
func (r *PasskeyRepository) GetUserByEmail(email string) (*models.PasskeyUser, error) {
r.log.Info(fmt.Sprintf("Get User: %v", email))
// Check if user exists by email
var userID int
err := r.db.QueryRow("SELECT id FROM users WHERE email = $1", email).Scan(&userID)
if err == sql.ErrNoRows {
r.log.Error("Failed to find new user", err)
return nil, err
} else if err != nil {
r.log.Error("Failed to query user", err)
return nil, err
}
// Fetch user credentials from passkeys table
rows, err := r.db.Query("SELECT keys FROM passkeys WHERE user_id = $1", userID)
if err != nil {
r.log.Error("Failed to query passkeys", err)
return nil, err
}
defer rows.Close()
var credentials []webauthn.Credential
for rows.Next() {
var keys string
if err := rows.Scan(&keys); err != nil {
r.log.Error("Failed to scan passkey row", err)
return nil, err
}
cred, err := deserializeCredential(keys)
if err != nil {
r.log.Error("Failed to deserialize credential", err)
continue // Skip invalid credentials
}
credentials = append(credentials, cred)
}
// Construct and return PasskeyUser
user := models.PasskeyUser{
ID: []byte(strconv.Itoa(userID)), // Convert int ID to byte slice
Name: email,
DisplayName: email,
Credentials: credentials,
}
return &user, nil
}
func (r *PasskeyRepository) GetUserByID(id int) (*models.PasskeyUser, error) {
r.log.Info(fmt.Sprintf("Get User: %v", id))
// Check if user exists by id
var userID int
err := r.db.QueryRow("SELECT id FROM users WHERE id = $1", id).Scan(&userID)
if err == sql.ErrNoRows {
r.log.Error("Failed to find new user", err)
return nil, err
} else if err != nil {
r.log.Error("Failed to query user", err)
return nil, err
}
// Fetch user credentials from passkeys table
rows, err := r.db.Query("SELECT keys FROM passkeys WHERE user_id = $1", userID)
if err != nil {
r.log.Error("Failed to query passkeys", err)
return nil, err
}
defer rows.Close()
// Fetch the email of the user
var email string
err = r.db.QueryRow("SELECT email FROM users WHERE id = $1", userID).Scan(&email)
if err != nil {
if err == sql.ErrNoRows {
r.log.Error("Failed to find user email", err)
return nil, err
}
r.log.Error("Failed to query user email", err)
return nil, err
}
var credentials []webauthn.Credential
for rows.Next() {
var keys string
if err := rows.Scan(&keys); err != nil {
r.log.Error("Failed to scan passkey row", err)
return nil, err
}
cred, err := deserializeCredential(keys)
if err != nil {
r.log.Error("Failed to deserialize credential", err)
continue // Skip invalid credentials
}
credentials = append(credentials, cred)
}
// Construct and return PasskeyUser
user := models.PasskeyUser{
ID: []byte(strconv.Itoa(userID)), // Convert int ID to byte slice
Name: email,
DisplayName: email,
Credentials: credentials,
}
return &user, nil
}
// SaveUser updates the user's credentials in the database.
func (r *PasskeyRepository) SaveUser(user models.PasskeyUser) {
r.log.Info(fmt.Sprintf("SaveUser: %v", user.WebAuthnName()))
// Convert user ID from byte slice to integer
userID, err := strconv.Atoi(string(user.ID))
if err != nil {
r.log.Error("Invalid user ID", err)
return
}
// Insert new credentials
for _, cred := range user.Credentials {
keys, err := serializeCredential(cred)
if err != nil {
r.log.Error("Failed to serialize credential", err)
continue
}
// Check if the key already exists in the database
var exists bool
err = r.db.QueryRow("SELECT EXISTS(SELECT 1 FROM passkeys WHERE user_id = $1 AND keys = $2)", userID, keys).Scan(&exists)
if err != nil {
r.log.Error("Failed to check if passkey exists", err)
continue
}
// Insert the key only if it does not already exist
if !exists {
_, err = r.db.Exec("INSERT INTO passkeys (user_id, keys) VALUES ($1, $2)", userID, keys)
if err != nil {
r.log.Error("Failed to insert passkey", err)
}
} else {
r.log.Info(fmt.Sprintf("Passkey already exists for user_id: %d", userID))
}
}
}
// serializeCredential converts a WebAuthn credential to a JSON string.
func serializeCredential(cred webauthn.Credential) (string, error) {
data, err := json.Marshal(cred)
if err != nil {
return "", fmt.Errorf("failed to marshal credential: %w", err)
}
return string(data), nil
}
// deserializeCredential converts a JSON string back to a WebAuthn credential.
func deserializeCredential(data string) (webauthn.Credential, error) {
var cred webauthn.Credential
err := json.Unmarshal([]byte(data), &cred)
if err != nil {
return webauthn.Credential{}, fmt.Errorf("failed to unmarshal credential: %w", err)
}
return cred, nil
}
Create handlers/passkey_handler.go
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"frontendmasters.com/movies/data"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
"frontendmasters.com/movies/token"
"github.com/go-webauthn/webauthn/webauthn"
)
type WebAuthnHandler struct {
storage data.PasskeyStore
logger *logger.Logger
webauthn *webauthn.WebAuthn
}
func NewWebAuthnHandler(storage data.PasskeyStore, logger *logger.Logger, webauthn *webauthn.WebAuthn) *WebAuthnHandler {
return &WebAuthnHandler{
storage: storage,
logger: logger,
webauthn: webauthn,
}
}
func (h *WebAuthnHandler) writeJSONResponse(w http.ResponseWriter, data interface{}) error {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
h.logger.Error("Failed to encode response", err)
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return err
}
return nil
}
func (h *WebAuthnHandler) WebAuthnRegistrationBeginHandler(w http.ResponseWriter, r *http.Request) {
email, ok := r.Context().Value("email").(string)
if !ok {
h.logger.Error("Unable to retrieve email", nil)
http.Error(w, "Unable to retrieve email", http.StatusInternalServerError)
return
}
user, err := h.storage.GetUserByEmail(email)
if err != nil {
h.logger.Error("Failed to find user", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
options, session, err := h.webauthn.BeginRegistration(user)
if err != nil {
h.logger.Error("Unable to retrieve email", err)
http.Error(w, "Can't begin WebAuthn Registration", http.StatusInternalServerError)
return
}
// Make a session key and store the sessionData values
t, err := h.storage.GenSessionID()
if err != nil {
h.logger.Error("Can't generate session id: %s", err)
}
h.storage.SaveSession(t, *session)
http.SetCookie(w, &http.Cookie{
Name: "sid",
Value: t,
Path: "api/passkey/registerStart",
MaxAge: 3600,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
h.writeJSONResponse(w, options)
}
func (h *WebAuthnHandler) WebAuthnRegistrationEndHandler(w http.ResponseWriter, r *http.Request) {
email, ok := r.Context().Value("email").(string)
if !ok {
h.logger.Error("Unable to retrieve email", nil)
http.Error(w, "Unable to retrieve email", http.StatusInternalServerError)
return
}
// Get the session key from cookie
sid, err := r.Cookie("sid")
if err != nil {
h.logger.Error("Couldn't get the cookie for the session", err)
}
// Get the session data
session, _ := h.storage.GetSession(sid.Value)
user, err := h.storage.GetUserByEmail(email)
if err != nil {
h.logger.Error("Failed to find user", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
credential, err := h.webauthn.FinishRegistration(user, session, r)
if err != nil {
h.logger.Error("Coudln't finish the WebAuthn Registration", err)
// clean up sid cookie
http.SetCookie(w, &http.Cookie{
Name: "sid",
Value: "",
})
http.Error(w, "Couldn't finish registration", http.StatusBadRequest)
return
}
// Store the credential object
user.AddCredential(credential)
h.storage.SaveUser(*user)
// Delete the session data
h.storage.DeleteSession(sid.Value)
http.SetCookie(w, &http.Cookie{
Name: "sid",
Value: "",
})
h.writeJSONResponse(w, "{'success': true}")
}
func (h *WebAuthnHandler) WebAuthnAuthenticationBeginHandler(w http.ResponseWriter, r *http.Request) {
type CollectionRequest struct {
Email string `json:"email"`
}
var req CollectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Error("Failed to decode collection request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
email := req.Email
h.logger.Info("Finding user " + email)
user, err := h.storage.GetUserByEmail(email) // Find the user
if err != nil {
h.logger.Error("Failed to find user", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
options, session, err := h.webauthn.BeginLogin(user)
if err != nil {
h.logger.Error("Coudln't start a login", err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
// Make a session key and store the sessionData values
t, err := h.storage.GenSessionID()
if err != nil {
h.logger.Error("Coudln't create a session ID", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
h.storage.SaveSession(t, *session)
http.SetCookie(w, &http.Cookie{
Name: "sid",
Value: t,
Path: "api/passkey/loginStart",
MaxAge: 3600,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // TODO: SameSiteStrictMode maybe?
})
h.writeJSONResponse(w, options)
}
func (h *WebAuthnHandler) WebAuthnAuthenticationEndHandler(w http.ResponseWriter, r *http.Request) {
// Get the session key from cookie
sid, err := r.Cookie("sid")
if err != nil {
h.logger.Error("Coudln't get a session", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Get the session data stored from the function above
session, _ := h.storage.GetSession(sid.Value)
userID, err := strconv.Atoi(string(session.UserID)) // Convert []byte to int
if err != nil {
h.logger.Error("Failed to convert UserID to int", err)
http.Error(w, "Invalid session data", http.StatusBadRequest)
return
}
user, err := h.storage.GetUserByID(userID) // Get the user
if err != nil {
h.logger.Error("Failed to find user", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
credential, err := h.webauthn.FinishLogin(user, session, r)
if err != nil {
h.logger.Error("Coudln't finish login", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Handle credential.Authenticator.CloneWarning
if credential.Authenticator.CloneWarning {
h.logger.Error("Couldn't finish login", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// If login was successful
user.UpdateCredential(credential)
h.storage.SaveUser(*user)
// Delete the session data
h.storage.DeleteSession(sid.Value)
http.SetCookie(w, &http.Cookie{
Name: "sid",
Value: "",
})
// Add the new session cookie
t, err := h.storage.GenSessionID()
if err != nil {
h.logger.Error("Couldn't generate session", err)
http.Error(w, "Bad request", http.StatusBadRequest)
}
h.storage.SaveSession(t, webauthn.SessionData{
Expires: time.Now().Add(time.Hour),
})
http.SetCookie(w, &http.Cookie{
Name: "sid",
Value: t,
Path: "/",
MaxAge: 3600,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // TODO: SameSiteStrictMode maybe?
})
type PasskeyResponse struct {
Success bool `json:"success"`
JWT string `json:"jwt"`
}
h.logger.Info("Sending JWT for " + user.Name)
// Return success response
response := PasskeyResponse{
Success: true,
JWT: token.CreateJWT(models.User{Email: user.Name}, *h.logger),
}
h.writeJSONResponse(w, response)
}
Update main.go
to support our new handlers
// WebAuthn Handlers
wconfig := &webauthn.Config{
RPDisplayName: "ReelingIt",
RPID: "localhost",
RPOrigins: []string{"http://localhost:8080"},
}
var webAuthnManager *webauthn.WebAuthn
if webAuthnManager, err = webauthn.New(wconfig); err != nil {
logInstance.Error("Error creating WebAuthn", err)
}
if err != nil {
logInstance.Error("Error initialing Passkey engine", err)
}
passkeyRepo := data.NewPasskeyRepository(db, *logInstance)
webAuthnHandler := handlers.NewWebAuthnHandler(passkeyRepo, logInstance, webAuthnManager)
http.Handle("/api/passkey/registration-begin",
accountHandler.AuthMiddleware(http.HandlerFunc(webAuthnHandler.WebAuthnRegistrationBeginHandler)))
http.Handle("/api/passkey/registration-end",
accountHandler.AuthMiddleware(http.HandlerFunc(webAuthnHandler.WebAuthnRegistrationEndHandler)))
http.HandleFunc("/api/passkey/authentication-begin", webAuthnHandler.WebAuthnAuthenticationBeginHandler)
http.HandleFunc("/api/passkey/authentication-end", webAuthnHandler.WebAuthnAuthenticationEndHandler)
In our HTML add the SimpleWebAuthn library script before app.js loading:
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js" defer></script>
Change in index.html the template-account
<template id="template-account">
<section id="account">
<h2>You are Logged In</h2>
<button onclick="app.logout()">Log out</button>
<button onclick="app.Router.go('/account/favorites')">Your Favorites</button>
<button onclick="app.Router.go('/account/watchlist')">Your Watchlist</button>
<!-- NEW -->
<button onclick="app.addPasskey()">Add a Passkey for faster login</button>
</section>
</template>
And also the template-login removing the required
attribute for the password:
<template id="template-login">
<section>
<h2>Login into Your Account</h2>
<form onsubmit="app.login(event)">
<label for="login-email">Email</label>
<input type="email" id="login-email" placeholder="Email" required autocomplete="email">
<label for="login-password">Password</label>
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password">
<button>Log In</button>
<!-- NEW -->
<button type="button" onclick="app.loginWithPasskey()">Log In with a Passkey</button>
<p>If you don't have an account, please <a href="/account/register">register</a>.</p>
</form>
</section>
</template>
Create a new Service services/Passkey.js
export const Passkeys = {
register: async (username) => {
try {
// Get registration options with the challenge.
const response = await fetch('/api/passkey/registration-begin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
"Authorization": app.Store.jwt ? `Bearer ${app.Store.jwt}` : null
},
body: JSON.stringify({username: username})
});
// Check if the options are ok.
if (!response.ok) {
const err = await response.json();
app.showError('Failed to get registration options from server.' + err)
}
const options = await response.json();
// This triggers the browser to display the passkey modal
// A new public-private-key pair is created.
const attestationResponse = await SimpleWebAuthnBrowser.startRegistration({optionsJSON: options.publicKey});
// Send attestationResponse back to server for verification and storage.
const verificationResponse = await fetch('/api/passkey/registration-end', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
"Authorization": app.Store.jwt ? `Bearer ${app.Store.jwt}` : null
},
body: JSON.stringify(attestationResponse)
});
const msg = await verificationResponse.json();
if (verificationResponse.ok) {
app.showError("Your passkey was saved. You can use it next time to login")
} else {
app.showError(msg, false);
}
} catch (e) {
app.showError('Error: ' + e.message, false);
}
},
authenticate: async (email) => {
try {
// Get login options from your server with the challenge
const response = await fetch('/api/passkey/authentication-begin', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email})
});
const options = await response.json();
// This triggers the browser to display the passkey / WebAuthn modal
// The challenge has been signed after this.
const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication({optionsJSON: options.publicKey});
// Send assertionResponse back to server for verification.
const verificationResponse = await fetch('/api/passkey/authentication-end', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(assertionResponse)
});
const serverResponse = await verificationResponse.json();
if (serverResponse.success) {
app.Store.jwt = serverResponse.jwt;
app.Router.go("/account/")
} else {
app.showError(msg, false);
}
} catch (e) {
console.log(e)
app.showError('We couldn\'t authenticate you using a Passkey', false);
}
}
}
In app.js add
addPasskey: async () => {
const username = "testuser";
await Passkeys.register(username);
},
loginWithPasskey: async () => {
const username = document.getElementById("login-email").value;
if (username.length < 4) {
app.showError("To use a passkey, enter your email address first")
} else {
await Passkeys.authenticate(username);
}
}
Create handlers/ssr_handler.go
package handlers
import (
"errors"
"fmt"
"html"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"frontendmasters.com/movies/data"
"frontendmasters.com/movies/logger"
"frontendmasters.com/movies/models"
)
// In main.go, add this new handler function before the main function
func SSRMovieDetailsHandler(movieRepo *data.MovieRepository, logInstance *logger.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Extract movie ID from URL
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 3 {
http.Error(w, "Movie ID required", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(pathParts[2])
if err != nil {
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
return
}
// Get movie from repository
movie, err := movieRepo.GetMovieByID(id)
if err != nil {
if errors.Is(err, data.ErrMovieNotFound) {
http.Error(w, "Movie not found", http.StatusNotFound)
} else {
logInstance.Error("Error fetching movie", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Serve the HTML with movie data
w.Header().Set("Content-Type", "text/html")
err = renderMovieDetails(w, movie)
if err != nil {
logInstance.Error("Error rendering movie details", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
}
// Add this function to render the HTML
func renderMovieDetails(w io.Writer, movie models.Movie) error {
// Read the index.html file
htmlContent, err := os.ReadFile("./public/index.html")
if err != nil {
log.Fatal(err)
return err
}
// Convert movie data to HTML
genresHTML := ""
for _, genre := range movie.Genres {
genresHTML += fmt.Sprintf(`<li>%s</li>`, html.EscapeString(genre.Name))
}
castHTML := ""
for _, actor := range movie.Casting {
imageURL := "/images/generic_actor.jpg"
if actor.ImageURL != nil {
imageURL = *actor.ImageURL
}
castHTML += fmt.Sprintf(`
<li>
<img src="%s" alt="Picture of %s">
<p>%s %s</p>
</li>`,
html.EscapeString(imageURL),
html.EscapeString(actor.LastName),
html.EscapeString(actor.FirstName),
html.EscapeString(actor.LastName))
}
// Replace the main content
mainContent := fmt.Sprintf(`
<main>
<article id="movie">
<h2>%s</h2>
<h3>%s</h3>
<header>
<img src="%s" alt="Poster">
<youtube-embed id="trailer" data-url="%s"></youtube-embed>
<section id="actions">
<dl id="metadata">
<dt>Release Date</dt>
<dd>%d</dd>
<dt>Score</dt>
<dd>%.1f / 10</dd>
<dt>Original language</dt>
<dd>%s</dd>
</dl>
<button id="btnFavorites">Add to Favorites</button>
<button id="btnWatchlist">Add to Watchlist</button>
</section>
</header>
<ul id="genres">%s</ul>
<p id="overview">%s</p>
<ul id="cast">%s</ul>
</article>
</main>`,
html.EscapeString(movie.Title),
html.EscapeString(*movie.Tagline),
html.EscapeString(*movie.PosterURL),
html.EscapeString(*movie.TrailerURL),
movie.ReleaseYear,
*movie.Score,
html.EscapeString(*movie.Language),
genresHTML,
html.EscapeString(*movie.Overview),
castHTML)
// Replace the main tag content in the HTML
htmlStr := string(htmlContent)
htmlStr = strings.Replace(htmlStr, "<main></main>", mainContent, 1)
fmt.Println(htmlStr)
// Write the response
_, err = w.Write([]byte(htmlStr))
return err
}
At main.go replace the current /movies/
handler with:
http.HandleFunc("/movies/", func(w http.ResponseWriter, r *http.Request) {
if strings.Count(r.URL.Path, "/") == 2 && strings.HasPrefix(r.URL.Path, "/movies/") {
handlers.SSRMovieDetailsHandler(movieRepo, logInstance)(w, r)
} else {
catchAllHandler(w, r)
}
})
Add to app.webmanifest
the display: standalone
, scope
and start_url
attributes.
Create sw.js in the root of the public folder:
// service-worker.js
const CACHE_NAME = 'my-cache-v1';
// Install event - precache any initial resources if needed
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(() => {
// Skip waiting to activate immediately
self.skipWaiting();
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => {
// Take control of clients immediately
return self.clients.claim();
})
);
});
// Fetch event - handle caching strategies
self.addEventListener('fetch', (event) => {
const requestUrl = new URL(event.request.url);
// Handle /api/ requests (network first, cache fallback)
if (requestUrl.pathname.startsWith('/api/')) {
event.respondWith(
fetch(event.request)
.then((networkResponse) => {
// Cache successful network response
return caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
})
.catch(() => {
// If network fails, try cache
return caches.match(event.request)
.then((cachedResponse) => {
return cachedResponse || Promise.reject('No network or cache available');
});
})
);
}
// Handle all other requests (stale-while-revalidate)
else {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
// Start fetching new version in background
const fetchPromise = fetch(event.request)
.then((networkResponse) => {
// Update cache with new response
cache.put(event.request, networkResponse.clone());
return networkResponse;
})
.catch((error) => {
console.error('Fetch failed:', error);
});
// Return cached version if available, otherwise wait for network
return cachedResponse || fetchPromise;
});
})
);
}
});
In app.js change the DOMContentLoad
event with:
navigator.serviceWorker.register("/sw.js")