From e947d7567acbea90253ce937cd009329f1a4157c Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Fri, 18 Aug 2023 10:37:52 -0400 Subject: [PATCH] Added deployment script. --- deploy.sh | 24 ++++++++ litelist-api.service | 13 ++++ litelist-api/dub.json | 1 + litelist-api/dub.selections.json | 1 + litelist-api/source/app.d | 50 +++++++++++----- litelist-api/source/auth.d | 9 +++ litelist-api/source/data.d | 5 +- litelist-app/.env.development | 1 + litelist-app/.env.production | 1 + litelist-app/src/api/auth.ts | 28 +++++++-- litelist-app/src/api/base.ts | 2 +- litelist-app/src/components/LogOutButton.vue | 28 +++++++++ litelist-app/src/router/index.ts | 36 ++++++++---- litelist-app/src/stores/auth.ts | 62 ++++++++++++++++++-- litelist-app/src/util.ts | 21 ++++++- litelist-app/src/views/ListsView.vue | 17 +++++- litelist-app/src/views/LoginView.vue | 3 +- litelist-app/src/views/SingleListView.vue | 17 ++++-- 18 files changed, 265 insertions(+), 54 deletions(-) create mode 100755 deploy.sh create mode 100644 litelist-api.service create mode 100644 litelist-app/.env.development create mode 100644 litelist-app/.env.production create mode 100644 litelist-app/src/components/LogOutButton.vue diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..d3373f9 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# A build/deploy script to deploy litelist to litelist.andrewlalis.com +# Builds the front-end app, builds the API, and deploys them to the server. + +echo "Building app" +cd litelist-app +rm -rf dist +npm run build +cd .. + +echo "Building api" +cd litelist-api +dub clean +dub build --build=release --compiler=/opt/ldc2/ldc2-1.33.0-linux-x86_64/bin/ldc2 +cd .. + +# Now deploy +ssh -f root@andrewlalis.com 'systemctl stop litelist-api.service' +echo "Copying litelist-api binary to server" +scp litelist-api/litelist-api root@andrewlalis.com:/opt/litelist/ +echo "Copying app distribution to server" +rsync -rav -e ssh --delete litelist-app/dist/* root@andrewlalis.com:/opt/litelist/app-content +ssh -f root@andrewlalis.com 'systemctl start litelist-api.service' diff --git a/litelist-api.service b/litelist-api.service new file mode 100644 index 0000000..2b95afa --- /dev/null +++ b/litelist-api.service @@ -0,0 +1,13 @@ +[Unit] +Description=litelist-api +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/litelist +ExecStart=/opt/litelist/litelist-api +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/litelist-api/dub.json b/litelist-api/dub.json index cdcd3bb..875c33a 100644 --- a/litelist-api/dub.json +++ b/litelist-api/dub.json @@ -5,6 +5,7 @@ "copyright": "Copyright © 2023, Andrew Lalis", "dependencies": { "botan": "~>1.13.5", + "d-properties": "~>1.0.4", "d2sqlite3": "~>1.0.0", "handy-httpd": "~>7.9.3", "jwt": "~>0.4.0", diff --git a/litelist-api/dub.selections.json b/litelist-api/dub.selections.json index 723af13..4ffd16e 100644 --- a/litelist-api/dub.selections.json +++ b/litelist-api/dub.selections.json @@ -3,6 +3,7 @@ "versions": { "botan": "1.13.5", "botan-math": "1.0.4", + "d-properties": "1.0.4", "d2sqlite3": "1.0.0", "handy-httpd": "7.9.3", "httparsed": "1.2.1", diff --git a/litelist-api/source/app.d b/litelist-api/source/app.d index 2fb91cc..8bae793 100644 --- a/litelist-api/source/app.d +++ b/litelist-api/source/app.d @@ -4,7 +4,7 @@ import slf4d.default_provider; void main() { auto provider = new shared DefaultProvider(true, Levels.INFO); - provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN); + // provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN); configureLoggingProvider(provider); HttpServer server = initServer(); @@ -14,23 +14,41 @@ void main() { private HttpServer initServer() { import handy_httpd.handlers.path_delegating_handler; import handy_httpd.handlers.filtered_handler; + import handy_httpd.handlers.file_resolving_handler; + import d_properties; import auth; import lists; + import std.file; + import std.conv; ServerConfig config = ServerConfig.defaultValues(); config.enableWebSockets = false; config.workerPoolSize = 3; - config.port = 8080; config.connectionQueueSize = 10; + if (exists("application.properties")) { + Properties props = Properties("application.properties"); + if (props.has("port")) { + config.port = props.get("port").to!ushort; + } + if (props.has("workers")) { + config.workerPoolSize = props.get("workers").to!size_t; + } + if (props.has("hostname")) { + config.hostname = props.get("hostname"); + } + } + + // Set some CORS headers to prevent headache. config.defaultHeaders["Access-Control-Allow-Origin"] = "*"; config.defaultHeaders["Access-Control-Allow-Credentials"] = "true"; config.defaultHeaders["Access-Control-Allow-Methods"] = "*"; config.defaultHeaders["Vary"] = "origin"; config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization"; + immutable string API_PATH = "/api"; auto mainHandler = new PathDelegatingHandler(); - mainHandler.addMapping(Method.GET, "/status", (ref HttpRequestContext ctx) { + mainHandler.addMapping(Method.GET, API_PATH ~ "/status", (ref HttpRequestContext ctx) { ctx.response.writeBodyString("online"); }); @@ -38,18 +56,22 @@ private HttpServer initServer() { ctx.response.setStatus(HttpStatus.OK); }); - mainHandler.addMapping(Method.POST, "/register", &createNewUser); - mainHandler.addMapping(Method.POST, "/login", &handleLogin); - mainHandler.addMapping(Method.GET, "/me", &getMyUser); - mainHandler.addMapping(Method.OPTIONS, "/**", optionsHandler); - mainHandler.addMapping(Method.DELETE, "/me", &deleteMyUser); + mainHandler.addMapping(Method.POST, API_PATH ~ "/register", &createNewUser); + mainHandler.addMapping(Method.POST, API_PATH ~ "/login", &handleLogin); + mainHandler.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser); + mainHandler.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser); + mainHandler.addMapping(Method.GET, API_PATH ~ "/renew-token", &renewToken); - mainHandler.addMapping(Method.GET, "/lists", &getNoteLists); - mainHandler.addMapping(Method.POST, "/lists", &createNoteList); - mainHandler.addMapping(Method.GET, "/lists/{id}", &getNoteList); - mainHandler.addMapping(Method.DELETE, "/lists/{id}", &deleteNoteList); - mainHandler.addMapping(Method.POST, "/lists/{listId}/notes", &createNote); - mainHandler.addMapping(Method.DELETE, "/lists/{listId}/notes/{noteId}", &deleteNote); + mainHandler.addMapping(Method.GET, API_PATH ~ "/lists", &getNoteLists); + mainHandler.addMapping(Method.POST, API_PATH ~ "/lists", &createNoteList); + mainHandler.addMapping(Method.GET, API_PATH ~ "/lists/{id}", &getNoteList); + mainHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{id}", &deleteNoteList); + mainHandler.addMapping(Method.POST, API_PATH ~ "/lists/{listId}/notes", &createNote); + mainHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{listId}/notes/{noteId}", &deleteNote); + + mainHandler.addMapping(Method.OPTIONS, API_PATH ~ "/**", optionsHandler); + + mainHandler.addMapping("/**", new FileResolvingHandler("app-content", DirectoryResolutionStrategies.none)); return new HttpServer(mainHandler, config); } diff --git a/litelist-api/source/auth.d b/litelist-api/source/auth.d index 5e1df21..1bf168f 100644 --- a/litelist-api/source/auth.d +++ b/litelist-api/source/auth.d @@ -44,6 +44,15 @@ void handleLogin(ref HttpRequestContext ctx) { ctx.response.writeBodyString(resp.toString(), "application/json"); } +void renewToken(ref HttpRequestContext ctx) { + if (!validateAuthenticatedRequest(ctx)) return; + AuthContext auth = AuthContextHolder.getOrThrow(); + + JSONValue resp = JSONValue(string[string].init); + resp.object["token"] = generateToken(auth.user); + ctx.response.writeBodyString(resp.toString(), "application/json"); +} + void createNewUser(ref HttpRequestContext ctx) { JSONValue userData = ctx.request.readBodyAsJson(); string username = userData.object["username"].str; diff --git a/litelist-api/source/data.d b/litelist-api/source/data.d index 0435084..c07e1c2 100644 --- a/litelist-api/source/data.d +++ b/litelist-api/source/data.d @@ -14,9 +14,6 @@ static UserDataSource userDataSource; static this() { userDataSource = new FsSqliteDataSource(); - import slf4d; - import d2sqlite3.library; - infoF!"Sqlite version %s"(versionString()); } struct User { @@ -62,7 +59,7 @@ class FsSqliteDataSource : UserDataSource { User createUser(string username, string email, string passwordHash) { string dirPath = buildPath(USERS_DIR, username); if (exists(dirPath)) throw new Exception("User already has a directory."); - mkdir(dirPath); + mkdirRecurse(dirPath); string dataPath = buildPath(dirPath, DATA_FILE); JSONValue userObj = JSONValue(string[string].init); userObj.object["username"] = username; diff --git a/litelist-app/.env.development b/litelist-app/.env.development new file mode 100644 index 0000000..ffbe7a9 --- /dev/null +++ b/litelist-app/.env.development @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8080/api diff --git a/litelist-app/.env.production b/litelist-app/.env.production new file mode 100644 index 0000000..286cad7 --- /dev/null +++ b/litelist-app/.env.production @@ -0,0 +1 @@ +VITE_API_URL=https://litelist.andrewlalis.com/api diff --git a/litelist-app/src/api/auth.ts b/litelist-app/src/api/auth.ts index 7a82133..993248d 100644 --- a/litelist-app/src/api/auth.ts +++ b/litelist-app/src/api/auth.ts @@ -38,12 +38,7 @@ export async function login(username: string, password: string): Promise { + const userResponse = await fetch(API_URL + "/me", { + headers: { + "Authorization": "Bearer " + token + } + }) + if (userResponse.ok) return await userResponse.json() + throw userResponse +} + +export async function renewToken(token: string): Promise { + const response = await fetch(API_URL + "/renew-token", { + headers: {"Authorization": "Bearer " + token} + }) + if (response.ok) { + const content: LoginTokenResponse = await response.json() + return content.token + } + throw response +} diff --git a/litelist-app/src/api/base.ts b/litelist-app/src/api/base.ts index 3017938..baa7b99 100644 --- a/litelist-app/src/api/base.ts +++ b/litelist-app/src/api/base.ts @@ -1 +1 @@ -export const API_URL = "http://localhost:8080" +export const API_URL = import.meta.env.VITE_API_URL diff --git a/litelist-app/src/components/LogOutButton.vue b/litelist-app/src/components/LogOutButton.vue new file mode 100644 index 0000000..9d9cba1 --- /dev/null +++ b/litelist-app/src/components/LogOutButton.vue @@ -0,0 +1,28 @@ + + + + + \ No newline at end of file diff --git a/litelist-app/src/router/index.ts b/litelist-app/src/router/index.ts index 53db10e..5314acb 100644 --- a/litelist-app/src/router/index.ts +++ b/litelist-app/src/router/index.ts @@ -1,39 +1,51 @@ -import { createRouter, createWebHistory } from 'vue-router' +import {createRouter, createWebHistory} from 'vue-router' import LoginView from "@/views/LoginView.vue"; import ListsView from "@/views/ListsView.vue"; import {useAuthStore} from "@/stores/auth"; import SingleListView from "@/views/SingleListView.vue"; -function checkAuth() { - const authStore = useAuthStore() - if (!authStore.authenticated) return "login" -} - const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: "/", - name: "home-redirect", redirect: to => { return "/login" } }, { path: "/login", - component: LoginView + component: LoginView, + beforeEnter: () => { + // Check if the user is already logged in, and if so, go straight to /lists. + const authStore = useAuthStore() + if (authStore.authenticated) { + return "/lists" + } + } }, { path: "/lists", - component: ListsView, - beforeEnter: checkAuth + component: ListsView }, { path: "/lists/:id", - component: SingleListView, - beforeEnter: checkAuth + component: SingleListView } ] }) +const publicRoutes = [ + "/", + "/login" +] + +router.beforeEach(async (to, from) => { + const authStore = useAuthStore() + await authStore.tryLogInFromStoredToken() + if (!publicRoutes.includes(to.path) && !authStore.authenticated) { + return "/login" // Redirect to login page if user is trying to go to an authenticated page. + } +}) + export default router diff --git a/litelist-app/src/stores/auth.ts b/litelist-app/src/stores/auth.ts index 9dbab7c..5117c64 100644 --- a/litelist-app/src/stores/auth.ts +++ b/litelist-app/src/stores/auth.ts @@ -1,26 +1,76 @@ import {defineStore} from "pinia"; import {type Ref, ref} from "vue"; import type {User} from "@/api/auth"; -import {emptyUser} from "@/api/auth"; +import {emptyUser, getMyUser, renewToken} from "@/api/auth"; +import {getSecondsTilExpire} from "@/util"; +import {useRouter} from "vue-router"; + +const LOCAL_STORAGE_KEY = "access_token" export const useAuthStore = defineStore("auth", () => { const authenticated: Ref = ref(false) const user: Ref = ref(emptyUser()) const token: Ref = ref("") + const tokenRefreshInterval: Ref = ref(0) - function logIn(newToken: string, newUser: User) { + const router = useRouter() + + async function logIn(newToken: string, newUser: User) { authenticated.value = true user.value = newUser token.value = newToken + localStorage.setItem(LOCAL_STORAGE_KEY, token.value) + tokenRefreshInterval.value = setInterval(tryRefreshToken, 60000) + await router.push("/lists") } - function logOut() { + async function logOut() { authenticated.value = false user.value = emptyUser() token.value = "" + if (tokenRefreshInterval.value) { + clearInterval(tokenRefreshInterval.value) + } + localStorage.removeItem(LOCAL_STORAGE_KEY) + await router.push("/login") } - return {authenticated, user, token, logIn, logOut} -}) + /** + * Periodically called to renew the access token, if it's close to expiring. + */ + async function tryRefreshToken() { + if (authenticated.value && getSecondsTilExpire(token.value) < 100) { + try { + const newToken = await renewToken(token.value) + token.value = newToken + localStorage.setItem(LOCAL_STORAGE_KEY, newToken) + } catch (e: any) { + console.warn("Failed to renew the access token.", e) + await logOut() + } + } + } -export type AuthStore = typeof useAuthStore + /** + * Tries to log in using an access token stored in the local storage. + */ + async function tryLogInFromStoredToken(): Promise { + if (authenticated.value) return + const storedToken: string | null = localStorage.getItem(LOCAL_STORAGE_KEY) + if (storedToken && getSecondsTilExpire(storedToken) > 60) { + try { + const storedUser = await getMyUser(storedToken) + console.log("Logging in using stored token for user: " + storedUser.username) + logIn(storedToken, storedUser) + } catch (e: any) { + console.warn("Failed to log in using stored token.", e) + } + } + } + + return { + authenticated, user, token, + logIn, logOut, + tryLogInFromStoredToken + } +}) diff --git a/litelist-app/src/util.ts b/litelist-app/src/util.ts index 79c8146..986703c 100644 --- a/litelist-app/src/util.ts +++ b/litelist-app/src/util.ts @@ -5,4 +5,23 @@ export function stringToColor(str: string, saturation: number = 100, lightness: hash = hash & hash; } return `hsl(${(hash % 360)}, ${saturation}%, ${lightness}%)`; -} \ No newline at end of file +} + +export function parseJwt (token: string): any { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); + return JSON.parse(jsonPayload); +} + +export function getUnixTime(): number { + return Math.floor(new Date().getTime() / 1000) +} + +export function getSecondsTilExpire(token: string): number { + const now = getUnixTime() + const decoded = parseJwt(token) + return decoded.exp - now +} diff --git a/litelist-app/src/views/ListsView.vue b/litelist-app/src/views/ListsView.vue index 9e26ee6..067cdd8 100644 --- a/litelist-app/src/views/ListsView.vue +++ b/litelist-app/src/views/ListsView.vue @@ -1,10 +1,11 @@ @@ -76,6 +83,10 @@ async function createList() {

+ +
+ +