Added deployment script.

This commit is contained in:
Andrew Lalis 2023-08-18 10:37:52 -04:00
parent 3a1092d468
commit e947d7567a
18 changed files with 265 additions and 54 deletions

24
deploy.sh Executable file
View File

@ -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'

13
litelist-api.service Normal file
View File

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

View File

@ -5,6 +5,7 @@
"copyright": "Copyright © 2023, Andrew Lalis", "copyright": "Copyright © 2023, Andrew Lalis",
"dependencies": { "dependencies": {
"botan": "~>1.13.5", "botan": "~>1.13.5",
"d-properties": "~>1.0.4",
"d2sqlite3": "~>1.0.0", "d2sqlite3": "~>1.0.0",
"handy-httpd": "~>7.9.3", "handy-httpd": "~>7.9.3",
"jwt": "~>0.4.0", "jwt": "~>0.4.0",

View File

@ -3,6 +3,7 @@
"versions": { "versions": {
"botan": "1.13.5", "botan": "1.13.5",
"botan-math": "1.0.4", "botan-math": "1.0.4",
"d-properties": "1.0.4",
"d2sqlite3": "1.0.0", "d2sqlite3": "1.0.0",
"handy-httpd": "7.9.3", "handy-httpd": "7.9.3",
"httparsed": "1.2.1", "httparsed": "1.2.1",

View File

@ -4,7 +4,7 @@ import slf4d.default_provider;
void main() { void main() {
auto provider = new shared DefaultProvider(true, Levels.INFO); auto provider = new shared DefaultProvider(true, Levels.INFO);
provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN); // provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN);
configureLoggingProvider(provider); configureLoggingProvider(provider);
HttpServer server = initServer(); HttpServer server = initServer();
@ -14,23 +14,41 @@ void main() {
private HttpServer initServer() { private HttpServer initServer() {
import handy_httpd.handlers.path_delegating_handler; import handy_httpd.handlers.path_delegating_handler;
import handy_httpd.handlers.filtered_handler; import handy_httpd.handlers.filtered_handler;
import handy_httpd.handlers.file_resolving_handler;
import d_properties;
import auth; import auth;
import lists; import lists;
import std.file;
import std.conv;
ServerConfig config = ServerConfig.defaultValues(); ServerConfig config = ServerConfig.defaultValues();
config.enableWebSockets = false; config.enableWebSockets = false;
config.workerPoolSize = 3; config.workerPoolSize = 3;
config.port = 8080;
config.connectionQueueSize = 10; 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-Origin"] = "*";
config.defaultHeaders["Access-Control-Allow-Credentials"] = "true"; config.defaultHeaders["Access-Control-Allow-Credentials"] = "true";
config.defaultHeaders["Access-Control-Allow-Methods"] = "*"; config.defaultHeaders["Access-Control-Allow-Methods"] = "*";
config.defaultHeaders["Vary"] = "origin"; config.defaultHeaders["Vary"] = "origin";
config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization"; config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization";
immutable string API_PATH = "/api";
auto mainHandler = new PathDelegatingHandler(); 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"); ctx.response.writeBodyString("online");
}); });
@ -38,18 +56,22 @@ private HttpServer initServer() {
ctx.response.setStatus(HttpStatus.OK); ctx.response.setStatus(HttpStatus.OK);
}); });
mainHandler.addMapping(Method.POST, "/register", &createNewUser); mainHandler.addMapping(Method.POST, API_PATH ~ "/register", &createNewUser);
mainHandler.addMapping(Method.POST, "/login", &handleLogin); mainHandler.addMapping(Method.POST, API_PATH ~ "/login", &handleLogin);
mainHandler.addMapping(Method.GET, "/me", &getMyUser); mainHandler.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
mainHandler.addMapping(Method.OPTIONS, "/**", optionsHandler); mainHandler.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
mainHandler.addMapping(Method.DELETE, "/me", &deleteMyUser); mainHandler.addMapping(Method.GET, API_PATH ~ "/renew-token", &renewToken);
mainHandler.addMapping(Method.GET, "/lists", &getNoteLists); mainHandler.addMapping(Method.GET, API_PATH ~ "/lists", &getNoteLists);
mainHandler.addMapping(Method.POST, "/lists", &createNoteList); mainHandler.addMapping(Method.POST, API_PATH ~ "/lists", &createNoteList);
mainHandler.addMapping(Method.GET, "/lists/{id}", &getNoteList); mainHandler.addMapping(Method.GET, API_PATH ~ "/lists/{id}", &getNoteList);
mainHandler.addMapping(Method.DELETE, "/lists/{id}", &deleteNoteList); mainHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{id}", &deleteNoteList);
mainHandler.addMapping(Method.POST, "/lists/{listId}/notes", &createNote); mainHandler.addMapping(Method.POST, API_PATH ~ "/lists/{listId}/notes", &createNote);
mainHandler.addMapping(Method.DELETE, "/lists/{listId}/notes/{noteId}", &deleteNote); 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); return new HttpServer(mainHandler, config);
} }

View File

@ -44,6 +44,15 @@ void handleLogin(ref HttpRequestContext ctx) {
ctx.response.writeBodyString(resp.toString(), "application/json"); 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) { void createNewUser(ref HttpRequestContext ctx) {
JSONValue userData = ctx.request.readBodyAsJson(); JSONValue userData = ctx.request.readBodyAsJson();
string username = userData.object["username"].str; string username = userData.object["username"].str;

View File

@ -14,9 +14,6 @@ static UserDataSource userDataSource;
static this() { static this() {
userDataSource = new FsSqliteDataSource(); userDataSource = new FsSqliteDataSource();
import slf4d;
import d2sqlite3.library;
infoF!"Sqlite version %s"(versionString());
} }
struct User { struct User {
@ -62,7 +59,7 @@ class FsSqliteDataSource : UserDataSource {
User createUser(string username, string email, string passwordHash) { User createUser(string username, string email, string passwordHash) {
string dirPath = buildPath(USERS_DIR, username); string dirPath = buildPath(USERS_DIR, username);
if (exists(dirPath)) throw new Exception("User already has a directory."); if (exists(dirPath)) throw new Exception("User already has a directory.");
mkdir(dirPath); mkdirRecurse(dirPath);
string dataPath = buildPath(dirPath, DATA_FILE); string dataPath = buildPath(dirPath, DATA_FILE);
JSONValue userObj = JSONValue(string[string].init); JSONValue userObj = JSONValue(string[string].init);
userObj.object["username"] = username; userObj.object["username"] = username;

View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:8080/api

View File

@ -0,0 +1 @@
VITE_API_URL=https://litelist.andrewlalis.com/api

View File

@ -38,12 +38,7 @@ export async function login(username: string, password: string): Promise<LoginIn
if (response.ok) { if (response.ok) {
const content: LoginTokenResponse = await response.json() const content: LoginTokenResponse = await response.json()
const token = content.token const token = content.token
const userResponse = await fetch(API_URL + "/me", { const user = await getMyUser(token)
headers: {
"Authorization": "Bearer " + token
}
})
const user: User = await userResponse.json()
return {token: token, user: user} return {token: token, user: user}
} else if (response.status < 500) { } else if (response.status < 500) {
throw {message: "Invalid credentials."} throw {message: "Invalid credentials."}
@ -51,3 +46,24 @@ export async function login(username: string, password: string): Promise<LoginIn
throw {message: "Server error. Try again later."} throw {message: "Server error. Try again later."}
} }
} }
export async function getMyUser(token: string): Promise<User> {
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<string> {
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
}

View File

@ -1 +1 @@
export const API_URL = "http://localhost:8080" export const API_URL = import.meta.env.VITE_API_URL

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import {useAuthStore} from "@/stores/auth";
const authStore = useAuthStore()
async function doLogOut() {
await authStore.logOut()
}
</script>
<template>
<div class="button-container">
<button @click="doLogOut()">
Log Out
</button>
</div>
</template>
<style scoped>
.button-container {
max-width: 50ch;
margin: 0 auto;
}
button {
font-size: medium;
}
</style>

View File

@ -1,39 +1,51 @@
import { createRouter, createWebHistory } from 'vue-router' import {createRouter, createWebHistory} from 'vue-router'
import LoginView from "@/views/LoginView.vue"; import LoginView from "@/views/LoginView.vue";
import ListsView from "@/views/ListsView.vue"; import ListsView from "@/views/ListsView.vue";
import {useAuthStore} from "@/stores/auth"; import {useAuthStore} from "@/stores/auth";
import SingleListView from "@/views/SingleListView.vue"; import SingleListView from "@/views/SingleListView.vue";
function checkAuth() {
const authStore = useAuthStore()
if (!authStore.authenticated) return "login"
}
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: "/", path: "/",
name: "home-redirect",
redirect: to => { redirect: to => {
return "/login" return "/login"
} }
}, },
{ {
path: "/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", path: "/lists",
component: ListsView, component: ListsView
beforeEnter: checkAuth
}, },
{ {
path: "/lists/:id", path: "/lists/:id",
component: SingleListView, component: SingleListView
beforeEnter: checkAuth
} }
] ]
}) })
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 export default router

View File

@ -1,26 +1,76 @@
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {type Ref, ref} from "vue"; import {type Ref, ref} from "vue";
import type {User} from "@/api/auth"; 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", () => { export const useAuthStore = defineStore("auth", () => {
const authenticated: Ref<boolean> = ref(false) const authenticated: Ref<boolean> = ref(false)
const user: Ref<User> = ref(emptyUser()) const user: Ref<User> = ref(emptyUser())
const token: Ref<string> = ref("") const token: Ref<string> = ref("")
const tokenRefreshInterval: Ref<number> = ref(0)
function logIn(newToken: string, newUser: User) { const router = useRouter()
async function logIn(newToken: string, newUser: User) {
authenticated.value = true authenticated.value = true
user.value = newUser user.value = newUser
token.value = newToken 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 authenticated.value = false
user.value = emptyUser() user.value = emptyUser()
token.value = "" 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<void> {
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
}
})

View File

@ -5,4 +5,23 @@ export function stringToColor(str: string, saturation: number = 100, lightness:
hash = hash & hash; hash = hash & hash;
} }
return `hsl(${(hash % 360)}, ${saturation}%, ${lightness}%)`; return `hsl(${(hash % 360)}, ${saturation}%, ${lightness}%)`;
} }
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
}

View File

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import {useAuthStore} from "@/stores/auth"; import {useAuthStore} from "@/stores/auth";
import {onMounted, ref, type Ref} from "vue"; import {nextTick, onMounted, ref, type Ref} from "vue";
import type {NoteList} from "@/api/lists"; import type {NoteList} from "@/api/lists";
import {createNoteList, getNoteLists} from "@/api/lists"; import {createNoteList, getNoteLists} from "@/api/lists";
import {useRouter} from "vue-router"; import {useRouter} from "vue-router";
import {stringToColor} from "@/util"; import {stringToColor} from "@/util";
import LogOutButton from "@/components/LogOutButton.vue";
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
@ -26,6 +27,12 @@ function toggleCreatingNewList() {
newListModel.value.description = "" newListModel.value.description = ""
} }
creatingNewList.value = !creatingNewList.value creatingNewList.value = !creatingNewList.value
if (creatingNewList.value) {
nextTick(() => {
const nameField: HTMLElement | null = document.getElementById("list-name")
if (nameField) nameField.focus()
})
}
} }
async function goToList(id: number) { async function goToList(id: number) {
@ -33,12 +40,12 @@ async function goToList(id: number) {
} }
async function createList() { async function createList() {
const noteList = await createNoteList( await createNoteList(
authStore.token, authStore.token,
newListModel.value.name, newListModel.value.name,
newListModel.value.description newListModel.value.description
) )
await router.push("/lists/" + noteList.id) noteLists.value = await getNoteLists(authStore.token)
} }
</script> </script>
@ -76,6 +83,10 @@ async function createList() {
<h3 v-text="list.name"></h3> <h3 v-text="list.name"></h3>
<p v-text="list.description"></p> <p v-text="list.description"></p>
</div> </div>
<div>
<LogOutButton/>
</div>
</template> </template>
<style scoped> <style scoped>

View File

@ -20,8 +20,7 @@ function resetLogin() {
async function doLogin() { async function doLogin() {
try { try {
const info = await login(loginModel.value.username, loginModel.value.password) const info = await login(loginModel.value.username, loginModel.value.password)
authStore.logIn(info.token, info.user) await authStore.logIn(info.token, info.user)
await router.push("lists")
} catch (error: any) { } catch (error: any) {
console.error(error.message) console.error(error.message)
} }

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type {NoteList} from "@/api/lists"; import type {NoteList} from "@/api/lists";
import {onMounted, ref, type Ref} from "vue"; import {nextTick, onMounted, ref, type Ref} from "vue";
import {useAuthStore} from "@/stores/auth"; import {useAuthStore} from "@/stores/auth";
import {createNote, deleteNote, deleteNoteList, getNoteList} from "@/api/lists"; import {createNote, deleteNote, deleteNoteList, getNoteList} from "@/api/lists";
import {useRoute, useRouter} from "vue-router"; import {useRoute, useRouter} from "vue-router";
@ -14,7 +14,8 @@ const creatingNote: Ref<boolean> = ref(false)
const newNoteText: Ref<string> = ref("") const newNoteText: Ref<string> = ref("")
onMounted(async () => { onMounted(async () => {
const listId = parseInt(route.params.id) let listId: number | null = null;
if (!Array.isArray(route.params.id)) listId = parseInt(route.params.id)
// If no valid list id could be found, go back. // If no valid list id could be found, go back.
if (!listId) { if (!listId) {
await router.push("/lists") await router.push("/lists")
@ -34,15 +35,15 @@ async function deleteNoteAndRefresh(id: number) {
} }
async function deleteList(id: number) { async function deleteList(id: number) {
const dialog: HTMLDialogElement = document.getElementById("list-delete-dialog") const dialog = document.getElementById("list-delete-dialog") as HTMLDialogElement
dialog.showModal() dialog.showModal()
const confirmButton: HTMLButtonElement = document.getElementById("delete-confirm-button") const confirmButton = document.getElementById("delete-confirm-button") as HTMLButtonElement
confirmButton.onclick = async () => { confirmButton.onclick = async () => {
dialog.close() dialog.close()
await deleteNoteList(authStore.token, id) await deleteNoteList(authStore.token, id)
await router.push("/lists") await router.push("/lists")
} }
const cancelButton: HTMLButtonElement = document.getElementById("delete-cancel-button") const cancelButton = document.getElementById("delete-cancel-button") as HTMLButtonElement
cancelButton.onclick = async () => { cancelButton.onclick = async () => {
dialog.close() dialog.close()
} }
@ -53,6 +54,12 @@ function toggleCreatingNewNote() {
newNoteText.value = "" newNoteText.value = ""
} }
creatingNote.value = !creatingNote.value creatingNote.value = !creatingNote.value
if (creatingNote.value) {
nextTick(() => {
const noteInput = document.getElementById("note-content")
if (noteInput) noteInput.focus()
})
}
} }
async function createNoteAndRefresh() { async function createNoteAndRefresh() {