From 75be79c6cf72c8597293266800dd572d8041260f Mon Sep 17 00:00:00 2001 From: Daniel Hjartland Date: Tue, 26 Nov 2024 18:08:49 +0100 Subject: [PATCH] initial commit --- .env.example | 12 ++ .gitignore | 5 + README.md | 65 ++++++ app/app.js | 301 ++++++++++++++++++++++++++++ app/index.html | 105 ++++++++++ app/styles.css | 183 +++++++++++++++++ assets.go | 12 ++ cache.go | 10 + go.mod | 15 ++ go.sum | 12 ++ handler_get_thumbnail.go | 32 +++ handler_login.go | 74 +++++++ handler_refresh.go | 56 ++++++ handler_upload_thumbnail.go | 37 ++++ handler_upload_video.go | 7 + handler_users.go | 46 +++++ handler_video_meta.go | 120 +++++++++++ internal/auth/auth.go | 116 +++++++++++ internal/database/database.go | 89 ++++++++ internal/database/refresh_tokens.go | 83 ++++++++ internal/database/users.go | 147 ++++++++++++++ internal/database/videos.go | 155 ++++++++++++++ json.go | 34 ++++ main.go | 134 +++++++++++++ reset.go | 19 ++ samplesdownload.sh | 16 ++ 26 files changed, 1885 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/app.js create mode 100644 app/index.html create mode 100644 app/styles.css create mode 100644 assets.go create mode 100644 cache.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handler_get_thumbnail.go create mode 100644 handler_login.go create mode 100644 handler_refresh.go create mode 100644 handler_upload_thumbnail.go create mode 100644 handler_upload_video.go create mode 100644 handler_users.go create mode 100644 handler_video_meta.go create mode 100644 internal/auth/auth.go create mode 100644 internal/database/database.go create mode 100644 internal/database/refresh_tokens.go create mode 100644 internal/database/users.go create mode 100644 internal/database/videos.go create mode 100644 json.go create mode 100644 main.go create mode 100644 reset.go create mode 100755 samplesdownload.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fba2c3e --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +DB_PATH="./tubely.db" +JWT_SECRET="JKFNDKAJSDKFASFNJWIROIOTNKNFDSKNFD" +PLATFORM="dev" +FILEPATH_ROOT="./app" +ASSETS_ROOT="./assets" +S3_BUCKET="tubely-123456789" +S3_REGION="us-east-2" +S3_CF_DISTRO="TEST" +PORT="8091" +# aws credentials should be set in ~/.aws/credentials +# using the `aws configure` command, the SDK will automatically +# read them from there diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c852ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +out +*.db +.env +assets/ +samples/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..34d955b --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# learn-file-storage-s3-golang-starter (Tubely) + +This repo contains the starter code for the Tubely application - the #1 tool for engagement bait - for the "Learn File Servers and CDNs with S3 and CloudFront" [course](https://www.boot.dev/courses/learn-file-servers-s3-cloudfront-golang) on [boot.dev](https://www.boot.dev) + +## Quickstart + +*This is to be used as a *reference\* in case you need it, you should follow the instructions in the course rather than trying to do everything here. + +## 1. Install dependencies + +- [Go](https://golang.org/doc/install) +- `go mod download` to download all dependencies +- [FFMPEG](https://ffmpeg.org/download.html) - both `ffmpeg` and `ffprobe` are required to be in your `PATH`. + +```bash +# linux +sudo apt update +sudo apt install ffmpeg + +# mac +brew update +brew install ffmpeg +``` + +- [SQLite 3](https://www.sqlite.org/download.html) only required for you to manually inspect the database. + +```bash +# linux +sudo apt update +sudo apt install sqlite3 + +# mac +brew update +brew install sqlite3 +``` + +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) + +## 2. Download sample images and videos + +```bash +./samplesdownload.sh +# samples/ dir will be created +# with sample images and videos +``` + +## 3. Configure environment variables + +Copy the `.env.example` file to `.env` and fill in the values. + +```bash +cp .env.example .env +``` + +You'll need to update values in the `.env` file to match your configuration, but _you won't need to do anything here until the course tells you to_. + +## 3. Run the server + +```bash +go run . +``` + +- You should see a new database file `tubely.db` created in the root directory. +- You should see a new `assets` directory created in the root directory, this is where the images will be stored. +- You should see a link in your console to open the local web page. diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000..f688af9 --- /dev/null +++ b/app/app.js @@ -0,0 +1,301 @@ +document.addEventListener('DOMContentLoaded', async () => { + const token = localStorage.getItem('token'); + + if (token) { + document.getElementById('auth-section').style.display = 'none'; + document.getElementById('video-section').style.display = 'block'; + await getVideos(); + } else { + document.getElementById('auth-section').style.display = 'block'; + document.getElementById('video-section').style.display = 'none'; + } +}); + +document.getElementById('video-draft-form').addEventListener('submit', async (event) => { + event.preventDefault(); + await createVideoDraft(); +}); + +document.getElementById('login-form').addEventListener('submit', async (event) => { + event.preventDefault(); + await login(); +}); + +async function createVideoDraft() { + const title = document.getElementById('video-title').value; + const description = document.getElementById('video-description').value; + + try { + const res = await fetch('/api/videos', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + body: JSON.stringify({ title, description }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(`Failed to create video draft: ${data.error}`); + } + + const videoID = data.id; + if (videoID) { + await getVideos(); + await videoStateHandler(videoID); + } + } catch (error) { + alert(`Error: ${error.message}`); + } +} + +async function login() { + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + + try { + const res = await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(`Failed to login: ${data.error}`); + } + + if (data.token) { + localStorage.setItem('token', data.token); + document.getElementById('auth-section').style.display = 'none'; + document.getElementById('video-section').style.display = 'block'; + await getVideos(); + } else { + alert('Login failed. Please check your credentials.'); + } + } catch (error) { + alert(`Error: ${error.message}`); + } +} + +async function signup() { + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + + try { + const res = await fetch('/api/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(`Failed to create user: ${data.error}`); + } + console.log('User created!'); + await login(); + } catch (error) { + alert(`Error: ${error.message}`); + } +} + +function logout() { + localStorage.removeItem('token'); + document.getElementById('auth-section').style.display = 'block'; + document.getElementById('video-section').style.display = 'none'; +} + +function setUploadButtonState(uploading, selector) { + const uploadBtn = document.getElementById(selector); + if (uploading) { + uploadBtn.textContent = 'Uploading...'; + uploadBtn.disabled = true; + return; + } + uploadBtn.textContent = 'Upload'; + uploadBtn.disabled = false; +} + +async function uploadThumbnail(videoID) { + const thumbnailFile = document.getElementById('thumbnail').files[0]; + if (!thumbnailFile) return; + + const formData = new FormData(); + formData.append('thumbnail', thumbnailFile); + + uploadBtnSelector = 'upload-thumbnail-btn'; + setUploadButtonState(true, uploadBtnSelector); + + try { + const res = await fetch(`/api/thumbnail_upload/${videoID}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + body: formData, + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(`Failed to upload thumbnail. Error: ${data.error}`); + } + + await res.json(); + console.log('Thumbnail uploaded!'); + await getVideo(videoID); + } catch (error) { + alert(`Error: ${error.message}`); + } + + setUploadButtonState(false, uploadBtnSelector); +} + +async function uploadVideoFile(videoID) { + const videoFile = document.getElementById('video-file').files[0]; + if (!videoFile) return; + + const formData = new FormData(); + formData.append('video', videoFile); + + uploadBtnSelector = 'upload-video-btn'; + setUploadButtonState(true, uploadBtnSelector); + + try { + const res = await fetch(`/api/video_upload/${videoID}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + body: formData, + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(`Failed to upload video file. Error: ${data.error}`); + } + + console.log('Video uploaded!'); + await getVideo(videoID); + } catch (error) { + alert(`Error: ${error.message}`); + } + + setUploadButtonState(false, uploadBtnSelector); +} + +const videoStateHandler = createVideoStateHandler(); + +async function getVideos() { + try { + const res = await fetch('/api/videos', { + method: 'GET', + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(`Failed to get videos. Error: ${data.error}`); + } + + const videos = await res.json(); + const videoList = document.getElementById('video-list'); + videoList.innerHTML = ''; + for (const video of videos) { + const listItem = document.createElement('li'); + listItem.textContent = video.title; + listItem.onclick = () => videoStateHandler(video.id); + videoList.appendChild(listItem); + } + } catch (error) { + alert(`Error: ${error.message}`); + } +} + +function createVideoStateHandler() { + let currentVideoID = null; + + return async function handleVideoClick(videoID) { + if (currentVideoID !== videoID) { + currentVideoID = videoID; + + // Reset file input values + document.getElementById('thumbnail').value = ''; + document.getElementById('video-file').value = ''; + + await getVideo(videoID); + } + }; +} + +async function getVideo(videoID) { + try { + const res = await fetch(`/api/videos/${videoID}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }); + if (!res.ok) { + throw new Error('Failed to get video.'); + } + + const video = await res.json(); + viewVideo(video); + } catch (error) { + alert(`Error: ${error.message}`); + } +} + +let currentVideo = null; + +function viewVideo(video) { + currentVideo = video; + document.getElementById('video-display').style.display = 'block'; + document.getElementById('video-title-display').textContent = video.title; + document.getElementById('video-description-display').textContent = video.description; + + const thumbnailImg = document.getElementById('thumbnail-image'); + if (!video.thumbnail_url) { + thumbnailImg.style.display = 'none'; + } else { + thumbnailImg.style.display = 'block'; + thumbnailImg.src = video.thumbnail_url; + } + + const videoPlayer = document.getElementById('video-player'); + if (videoPlayer) { + if (!video.video_url) { + videoPlayer.style.display = 'none'; + } else { + videoPlayer.style.display = 'block'; + videoPlayer.src = video.video_url; + videoPlayer.load(); + } + } +} + +async function deleteVideo() { + if (!currentVideo) { + alert('No video selected for deletion.'); + return; + } + + try { + const res = await fetch(`/api/videos/${currentVideo.id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }); + if (!res.ok) { + throw new Error('Failed to delete video.'); + } + alert('Video deleted successfully.'); + document.getElementById('video-display').style.display = 'none'; + await getVideos(); + } catch (error) { + alert(`Error: ${error.message}`); + } +} diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..4f53ca8 --- /dev/null +++ b/app/index.html @@ -0,0 +1,105 @@ + + + + + + Tubely + + + + + + +
+

Login

+
+ + +
+ + +
+
+
+ + + + diff --git a/app/styles.css b/app/styles.css new file mode 100644 index 0000000..424a8c5 --- /dev/null +++ b/app/styles.css @@ -0,0 +1,183 @@ +:root { + --bg-color: #1e1e1e; + --fg-color: #f5f5f5; + --subtle-color: #888; + --primary-color: #bb86fc; + --input-bg: #2a2a2a; + --input-border: #444; + --button-bg: #7e57c2; + --button-hover: #5c4db1; +} + +body { + margin: 0; + font-family: Arial, sans-serif; + background-color: var(--bg-color); + color: var(--fg-color); + line-height: 1.6; +} + +.nav-bar { + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--bg-color); + padding: 10px 24px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +.nav-bar h1 { + margin: 0; + color: var(--primary-color); + display: flex; + align-items: center; +} + +.nav-bar button { + background-color: var(--button-bg); + color: white; + border: none; + border-radius: 5px; + padding: 10px 20px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.nav-bar button:hover { + background-color: var(--button-hover); +} + +h1 { + color: var(--primary-color); + margin-bottom: 16px; + margin-left: 24px; +} + +.subtitle { + color: var(--subtle-color); + font-size: 0.5em; + margin-left: 10px; +} + +h2 { + color: var(--primary-color); + margin-bottom: 16px; +} + +.input-area { + width: 100%; + padding: 10px; + border: 1px solid var(--input-border); + border-radius: 5px; + background-color: var(--input-bg); + color: var(--fg-color); + box-sizing: border-box; +} + +button { + padding: 10px 20px; + border: none; + border-radius: 5px; + background-color: var(--button-bg); + color: #fff; + cursor: pointer; + transition: background-color 0.3s ease; +} + +button:hover { + background-color: var(--button-hover); +} + +.button-container { + display: flex; + justify-content: center; + gap: 10px; +} + +#video-display { + border-top: 2px solid #333; + padding-top: 20px; + margin-top: 20px; +} + +#video-list { + list-style: none; + padding: 0; + margin-top: 10px; +} + +#video-list li { + padding: 10px; + margin-top: 5px; + background-color: #1e1e1e; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +#video-list .active { + background-color: var(--primary-color); + color: #000; +} + +#video-list li:hover { + background-color: #333; +} + +#thumbnail-image, +#video-player { + max-width: 300px; + margin-left: 10px; + vertical-align: middle; +} + +#auth-section { + max-width: 600px; + margin: 0 auto; + padding: 20px; +} + +#video-section { + max-width: 1080px; + margin: 0 auto; + padding: 20px; +} + +form { + border-radius: 5px; + display: flex; + flex-direction: column; + gap: 10px; /* Space between form elements */ +} + +#video-upload-forms { + display: flex; + gap: 20px; /* Space between the two forms */ +} + +#thumbnail-upload-form, +#video-container { + background-color: #1a1a1a; + padding: 20px; + border-radius: 5px; + flex: 1; +} + +#download-button, +#video-player { + margin-top: 0.5rem; + width: 100%; +} + +#video-upload-forms form { + flex: 1; +} + +.mb-4 { + margin-bottom: 16px; +} + +button[disabled] { + background-color: var(--subtle-color); + cursor: not-allowed; +} diff --git a/assets.go b/assets.go new file mode 100644 index 0000000..8315787 --- /dev/null +++ b/assets.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" +) + +func (cfg apiConfig) ensureAssetsDir() error { + if _, err := os.Stat(cfg.assetsRoot); os.IsNotExist(err) { + return os.Mkdir(cfg.assetsRoot, 0755) + } + return nil +} diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..84ac0af --- /dev/null +++ b/cache.go @@ -0,0 +1,10 @@ +package main + +import "net/http" + +func cacheMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "max-age=3600") + next.ServeHTTP(w, r) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f8ae500 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/bootdotdev/learn-file-storage-s3-golang-starter + +go 1.23.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.0.0-rc.1 + golang.org/x/crypto v0.7.0 +) + +require ( + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + github.com/mattn/go-sqlite3 v1.14.24 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9903dbd --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/golang-jwt/jwt/v5 v5.0.0-rc.1 h1:tDQ1LjKga657layZ4JLsRdxgvupebc0xuPwRNuTfUgs= +github.com/golang-jwt/jwt/v5 v5.0.0-rc.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= diff --git a/handler_get_thumbnail.go b/handler_get_thumbnail.go new file mode 100644 index 0000000..1ddac14 --- /dev/null +++ b/handler_get_thumbnail.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" +) + +func (cfg *apiConfig) handlerThumbnailGet(w http.ResponseWriter, r *http.Request) { + videoIDString := r.PathValue("videoID") + videoID, err := uuid.Parse(videoIDString) + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid video ID", err) + return + } + + tn, ok := videoThumbnails[videoID] + if !ok { + respondWithError(w, http.StatusNotFound, "Thumbnail not found", nil) + return + } + + w.Header().Set("Content-Type", tn.mediaType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(tn.data))) + + _, err = w.Write(tn.data) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Error writing response", err) + return + } +} diff --git a/handler_login.go b/handler_login.go new file mode 100644 index 0000000..9ccf9f7 --- /dev/null +++ b/handler_login.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/auth" + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/database" +) + +func (cfg *apiConfig) handlerLogin(w http.ResponseWriter, r *http.Request) { + type parameters struct { + Password string `json:"password"` + Email string `json:"email"` + } + type response struct { + database.User + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + } + + decoder := json.NewDecoder(r.Body) + params := parameters{} + err := decoder.Decode(¶ms) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't decode parameters", err) + return + } + + user, err := cfg.db.GetUserByEmail(params.Email) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Incorrect email or password", err) + return + } + + err = auth.CheckPasswordHash(params.Password, user.Password) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Incorrect email or password", err) + return + } + + accessToken, err := auth.MakeJWT( + user.ID, + cfg.jwtSecret, + time.Hour*24*30, + ) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't create access JWT", err) + return + } + + refreshToken, err := auth.MakeRefreshToken() + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't create refresh token", err) + return + } + + _, err = cfg.db.CreateRefreshToken(database.CreateRefreshTokenParams{ + UserID: user.ID, + Token: refreshToken, + ExpiresAt: time.Now().UTC().Add(time.Hour * 24 * 60), + }) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't save refresh token", err) + return + } + + respondWithJSON(w, http.StatusOK, response{ + User: user, + Token: accessToken, + RefreshToken: refreshToken, + }) +} diff --git a/handler_refresh.go b/handler_refresh.go new file mode 100644 index 0000000..029f22c --- /dev/null +++ b/handler_refresh.go @@ -0,0 +1,56 @@ +package main + +import ( + "net/http" + "time" + + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/auth" +) + +func (cfg *apiConfig) handlerRefresh(w http.ResponseWriter, r *http.Request) { + type response struct { + Token string `json:"token"` + } + + refreshToken, err := auth.GetBearerToken(r.Header) + if err != nil { + respondWithError(w, http.StatusBadRequest, "Couldn't find token", err) + return + } + + user, err := cfg.db.GetUserByRefreshToken(refreshToken) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't get user for refresh token", err) + return + } + + accessToken, err := auth.MakeJWT( + user.ID, + cfg.jwtSecret, + time.Hour, + ) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't validate token", err) + return + } + + respondWithJSON(w, http.StatusOK, response{ + Token: accessToken, + }) +} + +func (cfg *apiConfig) handlerRevoke(w http.ResponseWriter, r *http.Request) { + refreshToken, err := auth.GetBearerToken(r.Header) + if err != nil { + respondWithError(w, http.StatusBadRequest, "Couldn't find token", err) + return + } + + err = cfg.db.RevokeRefreshToken(refreshToken) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't revoke session", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/handler_upload_thumbnail.go b/handler_upload_thumbnail.go new file mode 100644 index 0000000..765d87d --- /dev/null +++ b/handler_upload_thumbnail.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "net/http" + + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/auth" + "github.com/google/uuid" +) + +func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Request) { + videoIDString := r.PathValue("videoID") + videoID, err := uuid.Parse(videoIDString) + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid ID", err) + return + } + + token, err := auth.GetBearerToken(r.Header) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't find JWT", err) + return + } + + userID, err := auth.ValidateJWT(token, cfg.jwtSecret) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't validate JWT", err) + return + } + + + fmt.Println("uploading thumbnail for video", videoID, "by user", userID) + + // TODO: implement the upload here + + respondWithJSON(w, http.StatusOK, struct{}{}) +} diff --git a/handler_upload_video.go b/handler_upload_video.go new file mode 100644 index 0000000..44e0549 --- /dev/null +++ b/handler_upload_video.go @@ -0,0 +1,7 @@ +package main + +import ( + "net/http" +) + +func (cfg *apiConfig) handlerUploadVideo(w http.ResponseWriter, r *http.Request) {} diff --git a/handler_users.go b/handler_users.go new file mode 100644 index 0000000..d25e632 --- /dev/null +++ b/handler_users.go @@ -0,0 +1,46 @@ +package main + +import ( + "encoding/json" + "net/http" + + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/auth" + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/database" +) + +func (cfg *apiConfig) handlerUsersCreate(w http.ResponseWriter, r *http.Request) { + type parameters struct { + Password string `json:"password"` + Email string `json:"email"` + } + + decoder := json.NewDecoder(r.Body) + params := parameters{} + err := decoder.Decode(¶ms) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't decode parameters", err) + return + } + + if params.Password == "" || params.Email == "" { + respondWithError(w, http.StatusBadRequest, "Email and password are required", nil) + return + } + + hashedPassword, err := auth.HashPassword(params.Password) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't hash password", err) + return + } + + user, err := cfg.db.CreateUser(database.CreateUserParams{ + Email: params.Email, + Password: hashedPassword, + }) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't create user", err) + return + } + + respondWithJSON(w, http.StatusCreated, user) +} diff --git a/handler_video_meta.go b/handler_video_meta.go new file mode 100644 index 0000000..3266038 --- /dev/null +++ b/handler_video_meta.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "net/http" + + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/auth" + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/database" + "github.com/google/uuid" +) + +func (cfg *apiConfig) handlerVideoMetaCreate(w http.ResponseWriter, r *http.Request) { + type parameters struct { + database.CreateVideoParams + } + + token, err := auth.GetBearerToken(r.Header) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't find JWT", err) + return + } + userID, err := auth.ValidateJWT(token, cfg.jwtSecret) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't validate JWT", err) + return + } + + decoder := json.NewDecoder(r.Body) + params := parameters{} + err = decoder.Decode(¶ms) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't decode parameters", err) + return + } + params.UserID = userID + + video, err := cfg.db.CreateVideo(params.CreateVideoParams) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't create video", err) + return + } + + respondWithJSON(w, http.StatusCreated, video) +} + +func (cfg *apiConfig) handlerVideoMetaDelete(w http.ResponseWriter, r *http.Request) { + videoIDString := r.PathValue("videoID") + videoID, err := uuid.Parse(videoIDString) + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid ID", err) + return + } + + token, err := auth.GetBearerToken(r.Header) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't find JWT", err) + return + } + userID, err := auth.ValidateJWT(token, cfg.jwtSecret) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't validate JWT", err) + return + } + + video, err := cfg.db.GetVideo(videoID) + if err != nil { + respondWithError(w, http.StatusNotFound, "Couldn't get video", err) + return + } + if video.UserID != userID { + respondWithError(w, http.StatusForbidden, "You can't delete this video", err) + return + } + + err = cfg.db.DeleteVideo(videoID) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't delete video", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (cfg *apiConfig) handlerVideoGet(w http.ResponseWriter, r *http.Request) { + videoIDString := r.PathValue("videoID") + videoID, err := uuid.Parse(videoIDString) + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid video ID", err) + return + } + + video, err := cfg.db.GetVideo(videoID) + if err != nil { + respondWithError(w, http.StatusNotFound, "Couldn't get video", err) + return + } + + respondWithJSON(w, http.StatusOK, video) +} + +func (cfg *apiConfig) handlerVideosRetrieve(w http.ResponseWriter, r *http.Request) { + token, err := auth.GetBearerToken(r.Header) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't find JWT", err) + return + } + userID, err := auth.ValidateJWT(token, cfg.jwtSecret) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't validate JWT", err) + return + } + + videos, err := cfg.db.GetVideos(userID) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't retrieve videos", err) + return + } + + respondWithJSON(w, http.StatusOK, videos) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..748e08a --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,116 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +type TokenType string + +const ( + TokenTypeAccess TokenType = "tubely-access" +) + +var ErrNoAuthHeaderIncluded = errors.New("no auth header included in request") + +func HashPassword(password string) (string, error) { + dat, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(dat), nil +} + +func CheckPasswordHash(password, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +func MakeJWT( + userID uuid.UUID, + tokenSecret string, + expiresIn time.Duration, +) (string, error) { + signingKey := []byte(tokenSecret) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Issuer: string(TokenTypeAccess), + IssuedAt: jwt.NewNumericDate(time.Now().UTC()), + ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(expiresIn)), + Subject: userID.String(), + }) + return token.SignedString(signingKey) +} + +func ValidateJWT(tokenString, tokenSecret string) (uuid.UUID, error) { + claimsStruct := jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims( + tokenString, + &claimsStruct, + func(token *jwt.Token) (interface{}, error) { return []byte(tokenSecret), nil }, + ) + if err != nil { + return uuid.Nil, err + } + + userIDString, err := token.Claims.GetSubject() + if err != nil { + return uuid.Nil, err + } + + issuer, err := token.Claims.GetIssuer() + if err != nil { + return uuid.Nil, err + } + if issuer != string(TokenTypeAccess) { + return uuid.Nil, errors.New("invalid issuer") + } + + id, err := uuid.Parse(userIDString) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid user ID: %w", err) + } + return id, nil +} + +func GetBearerToken(headers http.Header) (string, error) { + authHeader := headers.Get("Authorization") + if authHeader == "" { + return "", ErrNoAuthHeaderIncluded + } + splitAuth := strings.Split(authHeader, " ") + if len(splitAuth) < 2 || splitAuth[0] != "Bearer" { + return "", errors.New("malformed authorization header") + } + + return splitAuth[1], nil +} + +func MakeRefreshToken() (string, error) { + token := make([]byte, 32) + _, err := rand.Read(token) + if err != nil { + return "", err + } + return hex.EncodeToString(token), nil +} + +func GetAPIKey(headers http.Header) (string, error) { + authHeader := headers.Get("Authorization") + if authHeader == "" { + return "", ErrNoAuthHeaderIncluded + } + splitAuth := strings.Split(authHeader, " ") + if len(splitAuth) < 2 || splitAuth[0] != "ApiKey" { + return "", errors.New("malformed authorization header") + } + + return splitAuth[1], nil +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..057b170 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,89 @@ +package database + +import ( + "database/sql" + "fmt" + + _ "github.com/mattn/go-sqlite3" +) + +type Client struct { + db *sql.DB +} + +func NewClient(pathToDB string) (Client, error) { + db, err := sql.Open("sqlite3", pathToDB) + if err != nil { + return Client{}, err + } + c := Client{db} + err = c.autoMigrate() + if err != nil { + return Client{}, err + } + return c, nil + +} + +func (c *Client) autoMigrate() error { + userTable := ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + password TEXT NOT NULL, + email TEXT UNIQUE NOT NULL + ); + ` + _, err := c.db.Exec(userTable) + if err != nil { + return err + } + refreshTokenTable := ` + CREATE TABLE IF NOT EXISTS refresh_tokens ( + token TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMP, + user_id TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ); + ` + _, err = c.db.Exec(refreshTokenTable) + if err != nil { + return err + } + + videoTable := ` + CREATE TABLE IF NOT EXISTS videos ( + id TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + title TEXT NOT NULL, + description TEXT, + thumbnail_url TEXT, + video_url TEXT TEXT, + user_id INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) + ); + ` + _, err = c.db.Exec(videoTable) + if err != nil { + return err + } + return nil +} + +func (c Client) Reset() error { + if _, err := c.db.Exec("DELETE FROM refresh_tokens"); err != nil { + return fmt.Errorf("failed to reset table refresh_tokens: %w", err) + } + if _, err := c.db.Exec("DELETE FROM users"); err != nil { + return fmt.Errorf("failed to reset table users: %w", err) + } + if _, err := c.db.Exec("DELETE FROM videos"); err != nil { + return fmt.Errorf("failed to reset table videos: %w", err) + } + return nil +} diff --git a/internal/database/refresh_tokens.go b/internal/database/refresh_tokens.go new file mode 100644 index 0000000..7530807 --- /dev/null +++ b/internal/database/refresh_tokens.go @@ -0,0 +1,83 @@ +package database + +import ( + "database/sql" + "time" + + "github.com/google/uuid" +) + +type RefreshToken struct { + CreateRefreshTokenParams + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RevokedAt *time.Time `json:"revoked_at"` +} + +type CreateRefreshTokenParams struct { + Token string `json:"token"` + UserID uuid.UUID `json:"user_id"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (c Client) CreateRefreshToken(params CreateRefreshTokenParams) (RefreshToken, error) { + query := ` + INSERT INTO refresh_tokens ( + token, + created_at, + updated_at, + user_id, + expires_at + ) VALUES (?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?) + ` + _, err := c.db.Exec(query, params.Token, params.UserID.String(), params.ExpiresAt) + if err != nil { + return RefreshToken{}, err + } + + return c.GetRefreshToken(params.Token) +} + +func (c Client) RevokeRefreshToken(token string) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = CURRENT_TIMESTAMP + WHERE token = ? + ` + _, err := c.db.Exec(query, token) + return err +} + +func (c Client) GetRefreshToken(token string) (RefreshToken, error) { + query := ` + SELECT token, created_at, updated_at, user_id, expires_at, revoked_at + FROM refresh_tokens + WHERE token = ? + ` + var rt RefreshToken + var userID string + err := c.db.QueryRow(query, token). + Scan(&rt.Token, &rt.CreatedAt, &rt.UpdatedAt, &userID, &rt.ExpiresAt, &rt.RevokedAt) + if err != nil { + if err == sql.ErrNoRows { + return RefreshToken{}, nil + } + return RefreshToken{}, err + } + + rt.UserID, err = uuid.Parse(userID) + if err != nil { + return RefreshToken{}, err + } + + return rt, nil +} + +func (c Client) DeleteRefreshToken(token string) error { + query := ` + DELETE FROM refresh_tokens + WHERE token = ? + ` + _, err := c.db.Exec(query, token) + return err +} diff --git a/internal/database/users.go b/internal/database/users.go new file mode 100644 index 0000000..4f16224 --- /dev/null +++ b/internal/database/users.go @@ -0,0 +1,147 @@ +package database + +import ( + "database/sql" + "errors" + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreateUserParams +} + +type CreateUserParams struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (c Client) GetUsers() ([]User, error) { + query := ` + SELECT + id, + email + FROM users + ` + + rows, err := c.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + users := []User{} + for rows.Next() { + var user User + var id string + if err := rows.Scan(&id, &user.Email); err != nil { + return nil, err + } + user.ID, err = uuid.Parse(id) + if err != nil { + return nil, err + } + users = append(users, user) + } + + return users, nil +} + +func (c Client) GetUserByEmail(email string) (User, error) { + query := ` + SELECT id, created_at, updated_at, email, password + FROM users + WHERE email = ? + ` + var user User + var id string + err := c.db.QueryRow(query, email).Scan(&id, &user.CreatedAt, &user.UpdatedAt, &user.Email, &user.Password) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return User{}, nil + } + return User{}, err + } + user.ID, err = uuid.Parse(id) + if err != nil { + return User{}, err + } + return user, nil +} + +func (c Client) GetUserByRefreshToken(token string) (*User, error) { + query := ` + SELECT u.id, u.email, u.created_at, u.updated_at, u.password + FROM users u + JOIN refresh_tokens rt ON u.id = rt.user_id + WHERE rt.token = ? + ` + + var user User + var id string + err := c.db.QueryRow(query, token).Scan(&id, &user.Email, &user.CreatedAt, &user.UpdatedAt, &user.Password) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + user.ID, err = uuid.Parse(id) + if err != nil { + return nil, err + } + + return &user, nil +} + +func (c Client) CreateUser(params CreateUserParams) (*User, error) { + id := uuid.New() + + query := ` + INSERT INTO users + (id, created_at, updated_at, email, password) + VALUES + (?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?) + ` + _, err := c.db.Exec(query, id.String(), params.Email, params.Password) + if err != nil { + return nil, err + } + + return c.GetUser(id) +} + +func (c Client) GetUser(id uuid.UUID) (*User, error) { + query := ` + SELECT id, created_at, updated_at, email, password + FROM users + WHERE id = ? + ` + var user User + var idStr string + err := c.db.QueryRow(query, id.String()).Scan(&idStr, &user.CreatedAt, &user.UpdatedAt, &user.Email, &user.Password) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + user.ID, err = uuid.Parse(idStr) + if err != nil { + return nil, err + } + return &user, nil +} + +func (c Client) DeleteUser(id uuid.UUID) error { + query := ` + DELETE FROM users + WHERE id = ? + ` + _, err := c.db.Exec(query, id.String()) + return err +} diff --git a/internal/database/videos.go b/internal/database/videos.go new file mode 100644 index 0000000..2ddab5b --- /dev/null +++ b/internal/database/videos.go @@ -0,0 +1,155 @@ +package database + +import ( + "database/sql" + "errors" + "time" + + "github.com/google/uuid" +) + +type Video struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ThumbnailURL *string `json:"thumbnail_url"` + VideoURL *string `json:"video_url"` + CreateVideoParams +} + +type CreateVideoParams struct { + Title string `json:"title"` + Description string `json:"description"` + UserID uuid.UUID `json:"user_id"` +} + +func (c Client) GetVideos(userID uuid.UUID) ([]Video, error) { + query := ` + SELECT + id, + created_at, + updated_at, + title, + description, + thumbnail_url, + video_url, + user_id + FROM videos + WHERE user_id = ? + ORDER BY created_at DESC + ` + + rows, err := c.db.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + videos := []Video{} + for rows.Next() { + var video Video + if err := rows.Scan( + &video.ID, + &video.CreatedAt, + &video.UpdatedAt, + &video.Title, + &video.Description, + &video.ThumbnailURL, + &video.VideoURL, + &video.UserID, + ); err != nil { + return nil, err + } + videos = append(videos, video) + } + + return videos, nil +} + +func (c Client) CreateVideo(params CreateVideoParams) (Video, error) { + id := uuid.New() + query := ` + INSERT INTO videos ( + id, + created_at, + updated_at, + title, + description, + user_id + ) VALUES (?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?, ?) + ` + _, err := c.db.Exec(query, id, params.Title, params.Description, params.UserID) + if err != nil { + return Video{}, err + } + + return c.GetVideo(id) +} + +func (c Client) GetVideo(id uuid.UUID) (Video, error) { + query := ` + SELECT + id, + created_at, + updated_at, + title, + description, + thumbnail_url, + video_url, + user_id + FROM videos + WHERE id = ? + ` + + var video Video + err := c.db.QueryRow(query, id).Scan( + &video.ID, + &video.CreatedAt, + &video.UpdatedAt, + &video.Title, + &video.Description, + &video.ThumbnailURL, + &video.VideoURL, + &video.UserID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Video{}, nil + } + return Video{}, err + } + + return video, nil +} + +func (c Client) UpdateVideo(video Video) error { + query := ` + UPDATE videos + SET + title = ?, + description = ?, + thumbnail_url = ?, + video_url = ?, + user_id = ? + WHERE id = ? + ` + + _, err := c.db.Exec( + query, + video.Title, + video.Description, + &video.ThumbnailURL, + &video.VideoURL, + video.UserID, + video.ID, + ) + return err +} + +func (c Client) DeleteVideo(id uuid.UUID) error { + query := ` + DELETE FROM videos + WHERE id = ? + ` + _, err := c.db.Exec(query, id) + return err +} diff --git a/json.go b/json.go new file mode 100644 index 0000000..df19561 --- /dev/null +++ b/json.go @@ -0,0 +1,34 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" +) + +func respondWithError(w http.ResponseWriter, code int, msg string, err error) { + if err != nil { + log.Println(err) + } + if code > 499 { + log.Printf("Responding with 5XX error: %s", msg) + } + type errorResponse struct { + Error string `json:"error"` + } + respondWithJSON(w, code, errorResponse{ + Error: msg, + }) +} + +func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + dat, err := json.Marshal(payload) + if err != nil { + log.Printf("Error marshalling JSON: %s", err) + w.WriteHeader(500) + return + } + w.WriteHeader(code) + w.Write(dat) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a48f140 --- /dev/null +++ b/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "log" + "net/http" + "os" + + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/database" + "github.com/google/uuid" + + "github.com/joho/godotenv" + _ "github.com/lib/pq" +) + +type apiConfig struct { + db database.Client + jwtSecret string + platform string + filepathRoot string + assetsRoot string + s3Bucket string + s3Region string + s3CfDistribution string + port string +} + +type thumbnail struct { + data []byte + mediaType string +} + +var videoThumbnails = map[uuid.UUID]thumbnail{} + +func main() { + godotenv.Load(".env") + + pathToDB := os.Getenv("DB_PATH") + if pathToDB == "" { + log.Fatal("DB_URL must be set") + } + + db, err := database.NewClient(pathToDB) + if err != nil { + log.Fatalf("Couldn't connect to database: %v", err) + } + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + log.Fatal("JWT_SECRET environment variable is not set") + } + + platform := os.Getenv("PLATFORM") + if platform == "" { + log.Fatal("PLATFORM environment variable is not set") + } + + filepathRoot := os.Getenv("FILEPATH_ROOT") + if filepathRoot == "" { + log.Fatal("FILEPATH_ROOT environment variable is not set") + } + + assetsRoot := os.Getenv("ASSETS_ROOT") + if assetsRoot == "" { + log.Fatal("ASSETS_ROOT environment variable is not set") + } + + s3Bucket := os.Getenv("S3_BUCKET") + if s3Bucket == "" { + log.Fatal("S3_BUCKET environment variable is not set") + } + + s3Region := os.Getenv("S3_REGION") + if s3Region == "" { + log.Fatal("S3_REGION environment variable is not set") + } + + s3CfDistribution := os.Getenv("S3_CF_DISTRO") + if s3CfDistribution == "" { + log.Fatal("S3_CF_DISTRO environment variable is not set") + } + + port := os.Getenv("PORT") + if port == "" { + log.Fatal("PORT environment variable is not set") + } + + cfg := apiConfig{ + db: db, + jwtSecret: jwtSecret, + platform: platform, + filepathRoot: filepathRoot, + assetsRoot: assetsRoot, + s3Bucket: s3Bucket, + s3Region: s3Region, + s3CfDistribution: s3CfDistribution, + port: port, + } + + err = cfg.ensureAssetsDir() + if err != nil { + log.Fatalf("Couldn't create assets directory: %v", err) + } + + mux := http.NewServeMux() + appHandler := http.StripPrefix("/app", http.FileServer(http.Dir(filepathRoot))) + mux.Handle("/app/", appHandler) + + assetsHandler := http.StripPrefix("/assets", http.FileServer(http.Dir(assetsRoot))) + mux.Handle("/assets/", cacheMiddleware(assetsHandler)) + + mux.HandleFunc("POST /api/login", cfg.handlerLogin) + mux.HandleFunc("POST /api/refresh", cfg.handlerRefresh) + mux.HandleFunc("POST /api/revoke", cfg.handlerRevoke) + + mux.HandleFunc("POST /api/users", cfg.handlerUsersCreate) + + mux.HandleFunc("POST /api/videos", cfg.handlerVideoMetaCreate) + mux.HandleFunc("POST /api/thumbnail_upload/{videoID}", cfg.handlerUploadThumbnail) + mux.HandleFunc("POST /api/video_upload/{videoID}", cfg.handlerUploadVideo) + mux.HandleFunc("GET /api/videos", cfg.handlerVideosRetrieve) + mux.HandleFunc("GET /api/videos/{videoID}", cfg.handlerVideoGet) + mux.HandleFunc("GET /api/thumbnails/{videoID}", cfg.handlerThumbnailGet) + mux.HandleFunc("DELETE /api/videos/{videoID}", cfg.handlerVideoMetaDelete) + + mux.HandleFunc("POST /admin/reset", cfg.handlerReset) + + srv := &http.Server{ + Addr: ":" + port, + Handler: mux, + } + + log.Printf("Serving on: http://localhost:%s/app/\n", port) + log.Fatal(srv.ListenAndServe()) +} diff --git a/reset.go b/reset.go new file mode 100644 index 0000000..d05ed48 --- /dev/null +++ b/reset.go @@ -0,0 +1,19 @@ +package main + +import "net/http" + +func (cfg *apiConfig) handlerReset(w http.ResponseWriter, r *http.Request) { + if cfg.platform != "dev" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Reset is only allowed in dev environment.")) + return + } + + err := cfg.db.Reset() + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't reset database", err) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("Database reset to initial state")) +} diff --git a/samplesdownload.sh b/samplesdownload.sh new file mode 100755 index 0000000..bd7601c --- /dev/null +++ b/samplesdownload.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +mkdir -p samples + +image_urls=( + "https://storage.googleapis.com/qvault-webapp-dynamic-assets/course_assets/boots-image-horizontal.png" + "https://storage.googleapis.com/qvault-webapp-dynamic-assets/course_assets/boots-image-vertical.png" + "https://storage.googleapis.com/qvault-webapp-dynamic-assets/course_assets/boots-video-horizontal.mp4" + "https://storage.googleapis.com/qvault-webapp-dynamic-assets/course_assets/boots-video-vertical.mp4" + "https://storage.googleapis.com/qvault-webapp-dynamic-assets/course_assets/is-bootdev-for-you.pdf" +) + +for url in "${image_urls[@]}"; do + file_name=$(basename "$url") + curl -sSfL -o "samples/$file_name" "$url" +done