Added deployment script.
This commit is contained in:
parent
3a1092d468
commit
e947d7567a
|
@ -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'
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL=http://localhost:8080/api
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL=https://litelist.andrewlalis.com/api
|
|
@ -38,12 +38,7 @@ export async function login(username: string, password: string): Promise<LoginIn
|
|||
if (response.ok) {
|
||||
const content: LoginTokenResponse = await response.json()
|
||||
const token = content.token
|
||||
const userResponse = await fetch(API_URL + "/me", {
|
||||
headers: {
|
||||
"Authorization": "Bearer " + token
|
||||
}
|
||||
})
|
||||
const user: User = await userResponse.json()
|
||||
const user = await getMyUser(token)
|
||||
return {token: token, user: user}
|
||||
} else if (response.status < 500) {
|
||||
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."}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
export const API_URL = "http://localhost:8080"
|
||||
export const API_URL = import.meta.env.VITE_API_URL
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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<boolean> = ref(false)
|
||||
const user: Ref<User> = ref(emptyUser())
|
||||
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
|
||||
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<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
|
||||
}
|
||||
})
|
||||
|
|
|
@ -5,4 +5,23 @@ export function stringToColor(str: string, saturation: number = 100, lightness:
|
|||
hash = hash & hash;
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
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 {createNoteList, getNoteLists} from "@/api/lists";
|
||||
import {useRouter} from "vue-router";
|
||||
import {stringToColor} from "@/util";
|
||||
import LogOutButton from "@/components/LogOutButton.vue";
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
@ -26,6 +27,12 @@ function toggleCreatingNewList() {
|
|||
newListModel.value.description = ""
|
||||
}
|
||||
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) {
|
||||
|
@ -33,12 +40,12 @@ async function goToList(id: number) {
|
|||
}
|
||||
|
||||
async function createList() {
|
||||
const noteList = await createNoteList(
|
||||
await createNoteList(
|
||||
authStore.token,
|
||||
newListModel.value.name,
|
||||
newListModel.value.description
|
||||
)
|
||||
await router.push("/lists/" + noteList.id)
|
||||
noteLists.value = await getNoteLists(authStore.token)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -76,6 +83,10 @@ async function createList() {
|
|||
<h3 v-text="list.name"></h3>
|
||||
<p v-text="list.description"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LogOutButton/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -20,8 +20,7 @@ function resetLogin() {
|
|||
async function doLogin() {
|
||||
try {
|
||||
const info = await login(loginModel.value.username, loginModel.value.password)
|
||||
authStore.logIn(info.token, info.user)
|
||||
await router.push("lists")
|
||||
await authStore.logIn(info.token, info.user)
|
||||
} catch (error: any) {
|
||||
console.error(error.message)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
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 {createNote, deleteNote, deleteNoteList, getNoteList} from "@/api/lists";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
|
@ -14,7 +14,8 @@ const creatingNote: Ref<boolean> = ref(false)
|
|||
const newNoteText: Ref<string> = ref("")
|
||||
|
||||
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 (!listId) {
|
||||
await router.push("/lists")
|
||||
|
@ -34,15 +35,15 @@ async function deleteNoteAndRefresh(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()
|
||||
const confirmButton: HTMLButtonElement = document.getElementById("delete-confirm-button")
|
||||
const confirmButton = document.getElementById("delete-confirm-button") as HTMLButtonElement
|
||||
confirmButton.onclick = async () => {
|
||||
dialog.close()
|
||||
await deleteNoteList(authStore.token, id)
|
||||
await router.push("/lists")
|
||||
}
|
||||
const cancelButton: HTMLButtonElement = document.getElementById("delete-cancel-button")
|
||||
const cancelButton = document.getElementById("delete-cancel-button") as HTMLButtonElement
|
||||
cancelButton.onclick = async () => {
|
||||
dialog.close()
|
||||
}
|
||||
|
@ -53,6 +54,12 @@ function toggleCreatingNewNote() {
|
|||
newNoteText.value = ""
|
||||
}
|
||||
creatingNote.value = !creatingNote.value
|
||||
if (creatingNote.value) {
|
||||
nextTick(() => {
|
||||
const noteInput = document.getElementById("note-content")
|
||||
if (noteInput) noteInput.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoteAndRefresh() {
|
||||
|
|
Loading…
Reference in New Issue