Compare commits

..

13 Commits

Author SHA1 Message Date
Ean Milligan
8b36742150 update xivplan to support export button, try to make it clear db mod is not the same as xivplan for bug reporting 2026-04-24 14:47:59 -04:00
Ean Milligan
f412f06e10 implement export all 2026-04-24 14:28:01 -04:00
Ean Milligan
31e3e13ab9 pin bcrypt version 2026-04-24 14:00:37 -04:00
Ean Milligan
b31886ce7b change flags to min required 2026-04-23 19:10:32 -04:00
Ean Milligan
884b5b81a0 change bcrypt dep, fix buildHome 2026-04-23 19:09:30 -04:00
Ean Milligan
1ceae4d158 more _ 2026-04-23 18:00:21 -04:00
Ean Milligan
e9b46d136d update rc 2026-04-23 17:58:48 -04:00
Ean Milligan
0c0c175f5c add start command 2026-04-23 17:54:07 -04:00
Ean Milligan
f01a3e3abf add new files 2026-04-23 17:33:10 -04:00
Ean Milligan
aab802b66a new patch file 2026-04-23 17:30:28 -04:00
Ean Milligan
7bd835c68a add db version patch to repo 2026-04-23 17:01:12 -04:00
Ean Milligan
8126e6dc95 add unenroll to api home page, minor changes 2026-04-23 16:51:06 -04:00
Ean Milligan
1f4c4723d3 ok turns out this is something that should be configured on the db columns, ideally not in the queries, makes sense to me https://stackoverflow.com/a/36768933 https://dev.mysql.com/doc/refman/5.7/en/case-sensitivity.html 2026-04-23 02:53:07 -04:00
11 changed files with 1619 additions and 154 deletions

View File

@@ -1,6 +1,10 @@
# XIVPlan+DB Mod Edition
This is just XIVPlan with a very basic DB implementation to make sharing links to a plan much more accessible. This repo contains the modification to XIVPlan as a git `.patch` file, and the rest of the source in this repo is the server that acts as an interface between XIVPlan and the DB.
This is just XIVPlan with a very basic DB implementation to make sharing links to a plan much more accessible. This repo contains the modification to XIVPlan as a git `db-version.patch` file, and the rest of the source in this repo is the server that acts as an interface between XIVPlan and the DB.
## Applying the patch
`git apply db-version.patch`
## Ideas around the mod

1358
db-version.patch Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,8 @@ console.log('Tables dropped');
console.log('Attempting to create table users');
await dbClient.execute(`
CREATE TABLE users (
id varchar(20) NOT NULL,
name varchar(20) NOT NULL,
id varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
name varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
hash varchar(60) NOT NULL,
email varchar(255) NULL,
deleteCode varchar(20) NULL,
@@ -31,8 +31,8 @@ console.log('Table created');
console.log('Attempting to create table plans');
await dbClient.execute(`
CREATE TABLE plans (
id varchar(20) NOT NULL,
ownerId varchar(20) NOT NULL,
id varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
ownerId varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
name varchar(200) NOT NULL,
folder varchar(200) NOT NULL,
lastUpdated timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,

View File

@@ -22,7 +22,8 @@
},
"nodeModulesDir": "none",
"imports": {
"@bcrypt": "jsr:@felix/bcrypt",
"@bcrypt": "https://deno.land/x/bcrypt@v0.4.1/mod.ts",
"@deno-zip": "https://deno.land/x/jszip@0.11.0/mod.ts",
"@mysql": "https://deno.land/x/mysql@v2.12.1/mod.ts",
"@nanoid": "https://deno.land/x/nanoid@v3.0.0/mod.ts",
"@std/http": "jsr:@std/http@1.0.15",

145
deno.lock generated
View File

@@ -1,94 +1,28 @@
{
"version": "5",
"specifiers": {
"jsr:@denosaurs/plug@^1.1.0": "1.1.0",
"jsr:@felix/bcrypt@*": "1.0.8",
"jsr:@std/cli@^1.0.17": "1.0.17",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@1": "1.0.7",
"jsr:@std/fmt@^1.0.7": "1.0.7",
"jsr:@std/fs@1": "1.0.23",
"jsr:@std/html@^1.0.3": "1.0.3",
"jsr:@std/http@1.0.15": "1.0.15",
"jsr:@std/internal@^1.0.12": "1.0.13",
"jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/net@^1.0.4": "1.0.4",
"jsr:@std/path@1": "1.0.9",
"jsr:@std/path@^1.0.9": "1.0.9",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/streams@^1.0.9": "1.0.9"
"jsr:@std/http@1.0.15": "1.0.15"
},
"jsr": {
"@denosaurs/plug@1.1.0": {
"integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
"dependencies": [
"jsr:@std/encoding@1",
"jsr:@std/fmt@1",
"jsr:@std/fs",
"jsr:@std/path@1"
]
},
"@felix/bcrypt@1.0.8": {
"integrity": "59c41160fc027882479c512db5d53792c4d91aadcd49467c85caa2f1679046f2",
"dependencies": [
"jsr:@denosaurs/plug"
]
},
"@std/cli@1.0.17": {
"integrity": "e15b9abe629e17be90cc6216327f03a29eae613365f1353837fa749aad29ce7b"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@1.0.7": {
"integrity": "2a727c043d8df62cd0b819b3fb709b64dd622e42c3b1bb817ea7e6cc606360fb"
},
"@std/fs@1.0.23": {
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37",
"dependencies": [
"jsr:@std/internal",
"jsr:@std/path@^1.1.4"
]
},
"@std/html@1.0.3": {
"integrity": "7a0ac35e050431fb49d44e61c8b8aac1ebd55937e0dc9ec6409aa4bab39a7988"
},
"@std/http@1.0.15": {
"integrity": "435a4934b4e196e82a8233f724da525f7b7112f3566502f28815e94764c19159",
"dependencies": [
"jsr:@std/cli",
"jsr:@std/encoding@^1.0.10",
"jsr:@std/fmt@^1.0.7",
"jsr:@std/html",
"jsr:@std/media-types",
"jsr:@std/net",
"jsr:@std/path@^1.0.9",
"jsr:@std/streams"
]
},
"@std/internal@1.0.13": {
"integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0"
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/net@1.0.4": {
"integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852"
},
"@std/path@1.0.9": {
"integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/streams@1.0.9": {
"integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035"
"integrity": "435a4934b4e196e82a8233f724da525f7b7112f3566502f28815e94764c19159"
}
},
"redirects": {
"https://deno.land/x/bcrypt/mod.ts": "https://deno.land/x/bcrypt@v0.4.1/mod.ts",
"https://esm.sh/core-util-is@~1.0.0?target=denonext": "https://esm.sh/core-util-is@1.0.3?target=denonext",
"https://esm.sh/immediate@~3.0.5?target=denonext": "https://esm.sh/immediate@3.0.6?target=denonext",
"https://esm.sh/isarray@~1.0.0?target=denonext": "https://esm.sh/isarray@1.0.0?target=denonext",
"https://esm.sh/lie@~3.3.0?target=denonext": "https://esm.sh/lie@3.3.0?target=denonext",
"https://esm.sh/pako@~1.0.2?target=denonext": "https://esm.sh/pako@1.0.11?target=denonext",
"https://esm.sh/process-nextick-args@~2.0.0?target=denonext": "https://esm.sh/process-nextick-args@2.0.1?target=denonext",
"https://esm.sh/readable-stream@~2.3.6?target=denonext": "https://esm.sh/readable-stream@2.3.8?target=denonext",
"https://esm.sh/safe-buffer@~5.1.0?target=denonext": "https://esm.sh/safe-buffer@5.1.2?target=denonext",
"https://esm.sh/safe-buffer@~5.1.1?target=denonext": "https://esm.sh/safe-buffer@5.1.2?target=denonext",
"https://esm.sh/set-immediate-shim@~1.0.1?target=denonext": "https://esm.sh/set-immediate-shim@1.0.1?target=denonext",
"https://esm.sh/string_decoder@~1.1.1?target=denonext": "https://esm.sh/string_decoder@1.1.1?target=denonext",
"https://esm.sh/util-deprecate@~1.0.1?target=denonext": "https://esm.sh/util-deprecate@1.0.2?target=denonext"
},
"remote": {
"https://deno.land/std@0.104.0/_util/assert.ts": "2f868145a042a11d5ad0a3c748dcf580add8a0dbc0e876eaa0026303a5488f58",
"https://deno.land/std@0.104.0/async/deadline.ts": "1d6ac7aeaee22f75eb86e4e105d6161118aad7b41ae2dd14f4cfd3bf97472b93",
@@ -118,10 +52,28 @@
"https://deno.land/std@0.104.0/log/mod.ts": "91711789b28803082b1bdfb123d2c9685a7e01767f2e79c0a82706063ad964d8",
"https://deno.land/std@0.104.0/testing/_diff.ts": "5d3693155f561d1a5443ac751ac70aab9f5d67b4819a621d4b96b8a1a1c89620",
"https://deno.land/std@0.104.0/testing/asserts.ts": "e4311d45d956459d4423bc267208fe154b5294989da2ed93257b6a85cae0427e",
"https://deno.land/std@0.116.0/_util/assert.ts": "2f868145a042a11d5ad0a3c748dcf580add8a0dbc0e876eaa0026303a5488f58",
"https://deno.land/std@0.116.0/_util/os.ts": "dfb186cc4e968c770ab6cc3288bd65f4871be03b93beecae57d657232ecffcac",
"https://deno.land/std@0.116.0/fs/walk.ts": "31464d75099aa3fc7764212576a8772dfabb2692783e6eabb910f874a26eac54",
"https://deno.land/std@0.116.0/path/_constants.ts": "1247fee4a79b70c89f23499691ef169b41b6ccf01887a0abd131009c5581b853",
"https://deno.land/std@0.116.0/path/_interface.ts": "1fa73b02aaa24867e481a48492b44f2598cd9dfa513c7b34001437007d3642e4",
"https://deno.land/std@0.116.0/path/_util.ts": "2e06a3b9e79beaf62687196bd4b60a4c391d862cfa007a20fc3a39f778ba073b",
"https://deno.land/std@0.116.0/path/common.ts": "f41a38a0719a1e85aa11c6ba3bea5e37c15dd009d705bd8873f94c833568cbc4",
"https://deno.land/std@0.116.0/path/glob.ts": "ea87985765b977cc284b92771003b2070c440e0807c90e1eb0ff3e095911a820",
"https://deno.land/std@0.116.0/path/mod.ts": "4465dc494f271b02569edbb4a18d727063b5dbd6ed84283ff906260970a15d12",
"https://deno.land/std@0.116.0/path/posix.ts": "34349174b9cd121625a2810837a82dd8b986bbaaad5ade690d1de75bbb4555b2",
"https://deno.land/std@0.116.0/path/separator.ts": "8fdcf289b1b76fd726a508f57d3370ca029ae6976fcde5044007f062e643ff1c",
"https://deno.land/std@0.116.0/path/win32.ts": "11549e8c6df8307a8efcfa47ad7b2a75da743eac7d4c89c9723a944661c8bd2e",
"https://deno.land/std@0.77.0/fmt/colors.ts": "c5665c66f1a67228f21c5989bbb04b36d369b98dd7ceac06f5e26856c81c2531",
"https://deno.land/x/bcrypt@v0.4.1/mod.ts": "ff09bdae282583cf5f7d87efe37ddcecef7f14f6d12e8b8066a3058db8c6c2f7",
"https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/base64.ts": "b8266450a4f1eb6960f60f2f7986afc4dde6b45bd2d7ee7ba10789e67e17b9f7",
"https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/bcrypt.ts": "ec221648cc6453ea5e3803bc817c01157dada06aa6f7a0ba6b9f87aae32b21e2",
"https://deno.land/x/bcrypt@v0.4.1/src/main.ts": "08d201b289c8d9c46f8839c69cd6625b213863db29775c7a200afc3b540e64f8",
"https://deno.land/x/bcrypt@v0.4.1/src/worker.ts": "5a73bdfee9c9e622f47c9733d374b627dce52fb3ec1e74c8226698b3fc57ffac",
"https://deno.land/x/bytes_formater@v1.4.0/deps.ts": "4f98f74e21145423b873a5ca6ead66dc3e674fa81e230a0a395f9b86aafeceea",
"https://deno.land/x/bytes_formater@v1.4.0/format.ts": "657c41b9f180c3ed0f934dcf75f77b09b6a610be98bb07525bffe2acfd5af4d5",
"https://deno.land/x/bytes_formater@v1.4.0/mod.ts": "c6bf35303f53d74e9134eb13f666fb388fb4c62c6b12b17542bbadade250a864",
"https://deno.land/x/jszip@0.11.0/mod.ts": "5661ddc18e9ac9c07e3c5d2483bc912a7022b6af0d784bb7b05035973e640ba1",
"https://deno.land/x/mysql@v2.12.1/deps.ts": "68635959a41bb08bc87db007679fb8449febc55d48202dff20b93cc23ef5820d",
"https://deno.land/x/mysql@v2.12.1/mod.ts": "3246c9c259434563be69cc95d5b792f8aac7ef5d10b8a6c6589aa54ebf1bd266",
"https://deno.land/x/mysql@v2.12.1/src/auth.ts": "129ea08b180d3e90e567c3f71e60432bb266304c224e17ea39d604bbcc1160d8",
@@ -155,11 +107,34 @@
"https://deno.land/x/nanoid@v3.0.0/nanoid.ts": "8d119bc89a0f34e7bbe0c2dbdc280d01753e431af553d189663492310a31085d",
"https://deno.land/x/nanoid@v3.0.0/random.ts": "4da71d5f72f2bfcc6a4ee79b5d4e72f48dcf4fe4c3835fd5ebab08b9f33cd598",
"https://deno.land/x/nanoid@v3.0.0/urlAlphabet.ts": "8b1511deb1ecb23c66202b6000dc10fb68f9a96b5550c6c8cef5009324793431",
"https://deno.land/x/sql_builder@v1.9.1/util.ts": "b9855dc435972704cf82655019f4ec168ac83550ab4db596c5f6b6d201466384"
"https://deno.land/x/sql_builder@v1.9.1/util.ts": "b9855dc435972704cf82655019f4ec168ac83550ab4db596c5f6b6d201466384",
"https://esm.sh/core-util-is@1.0.3/denonext/core-util-is.mjs": "cfcf1ae63d56751cbe4b3b90b90b7eea577c5380c4adc272ddea4b7db2bdbbf2",
"https://esm.sh/core-util-is@1.0.3?target=denonext": "6c72958f8a1c8f42016b48c984a0f3d799ea1e0cd321f499fec0bf8db916c17f",
"https://esm.sh/immediate@3.0.6/denonext/immediate.mjs": "7148ba33cb905f7aca49affbacfa6a8257cd6b89e8c3c7c728d2d0387b4cce29",
"https://esm.sh/immediate@3.0.6?target=denonext": "fba8d9ddb37f19ff27c0b1c5b4486ab82805114b14959379d92ca05d6351c5d3",
"https://esm.sh/isarray@1.0.0/denonext/isarray.mjs": "0f26133cd58fc8580f99bbfd81f6290718328dc2a683c313c36f6b1e8c174edc",
"https://esm.sh/isarray@1.0.0?target=denonext": "00e227f6d016cb5a5f832f6f2de91dd8ab092c7ac830c551bfcf0f63284d89e6",
"https://esm.sh/jszip@3.7.1": "5161d6a228d844791a60ab58360bd3b76c4d3921b4a725616cd7403203519249",
"https://esm.sh/jszip@3.7.1/denonext/jszip.mjs": "325e8509d94e1460a8bb0bbb58b47a7b70c63b48568a60993e2880dba3a3062d",
"https://esm.sh/lie@3.3.0/denonext/lie.mjs": "20db2fef139e87d467b7cf24a9e53053e96460fefedde5910f925b1d0ddc0cba",
"https://esm.sh/lie@3.3.0?target=denonext": "74a2c724bd2fef30c46c612632dfd2ee37394f1a4540eb112e0df2ef98df0434",
"https://esm.sh/pako@1.0.11/denonext/pako.mjs": "4895feb3e2441ef725da4052a5dc93d219d065fef1decc4452bb9b7ee1477c0d",
"https://esm.sh/pako@1.0.11?target=denonext": "bc43f66ed245d58d468bf9867b3e9080c5b0590b4c14038ea308954490e0b2ea",
"https://esm.sh/process-nextick-args@2.0.1/denonext/process-nextick-args.mjs": "adffdd507c6571957aaab9d3f0a2aa54febdda1b4d546a57967fd2299505339e",
"https://esm.sh/process-nextick-args@2.0.1?target=denonext": "b80260031d83086964facc0efc6e2cc8fd878d9ce14dfcf6999e508a4d8d13d0",
"https://esm.sh/readable-stream@2.3.8/denonext/readable-stream.mjs": "ce8c7e2e7783c4487c1e9fcaf8824f0af26d48e6f5fe02fa9cbd70c34799ef98",
"https://esm.sh/readable-stream@2.3.8?target=denonext": "a8d158c470101e7518fdf293728d4cb8b2ab2cac73140940c8a9ee5542194e13",
"https://esm.sh/safe-buffer@5.1.2/denonext/safe-buffer.mjs": "848e2c2dafb98ea738399526e4396607872d1118acf8eb56eecd2a5f3be75568",
"https://esm.sh/safe-buffer@5.1.2?target=denonext": "3126988c629e3dc2d6126b26f654aceae10ad989622a21cb2a73ee72603f7df8",
"https://esm.sh/set-immediate-shim@1.0.1/denonext/set-immediate-shim.mjs": "a0fc9b90f281a6541c474dbf55184ef3a9360248f53cb3fa9479480cd24cdd40",
"https://esm.sh/set-immediate-shim@1.0.1?target=denonext": "8d30997d25a26dbcd4d79b613e6f400af85194f8e18e8e7014bc5fe3c9ffd429",
"https://esm.sh/string_decoder@1.1.1/denonext/string_decoder.mjs": "494e5a7fae95d5326e8aee93b4adfde75e389eea7a54bc1feea8549e786da032",
"https://esm.sh/string_decoder@1.1.1?target=denonext": "092c97b62b99368a40fa044c402188472658bc71529415f73c16f66c05aaf6bf",
"https://esm.sh/util-deprecate@1.0.2/denonext/util-deprecate.mjs": "083639894972cb68837eef26346c43bdd01357977149e0a4493f76192a4008b8",
"https://esm.sh/util-deprecate@1.0.2?target=denonext": "859f4df8ba771a4c33143185d3db6a7edb824fab1ed4f9a4b96ac0e6bc3ef1a4"
},
"workspace": {
"dependencies": [
"jsr:@felix/bcrypt@*",
"jsr:@std/http@1.0.15"
]
}

74
mod.ts
View File

@@ -1,4 +1,5 @@
import { hash, verify } from '@bcrypt';
import { hash, compare } from '@bcrypt';
import { JSZip } from '@deno-zip';
import { customAlphabet } from '@nanoid';
import { STATUS_CODE, STATUS_TEXT, StatusCode } from '@std/http/status';
@@ -43,7 +44,7 @@ Deno.serve({ port: config.api.port }, async (req) => {
} else if (path.startsWith('/home/')) {
// SSR "home page"
const userId = path.replace('/home/', '');
const userMatch = await dbClient.query('SELECT name FROM users WHERE BINARY id = ?', [userId]).catch(() => {
const userMatch = await dbClient.query('SELECT name FROM users WHERE id = ?', [userId]).catch(() => {
failed = true;
});
if (failed) return buildPage("Couldn't read DB. Please try again.");
@@ -53,7 +54,7 @@ Deno.serve({ port: config.api.port }, async (req) => {
} else if (path.startsWith('/read/')) {
const planId = path.replace('/read/', '');
const plans = await dbClient.query('SELECT name, folder, data FROM plans WHERE BINARY id = ? AND deleted = 0', [planId]).catch(() => {
const plans = await dbClient.query('SELECT name, folder, data FROM plans WHERE id = ? AND deleted = 0', [planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
@@ -62,14 +63,14 @@ Deno.serve({ port: config.api.port }, async (req) => {
return genericResponse(STATUS_CODE.OK, JSON.stringify(plans[0]));
} else if (path.startsWith('/list/')) {
const userId = path.replace('/list/', '');
const userMatch = await dbClient.query('SELECT id FROM users WHERE BINARY id = ?', [userId]).catch(() => {
const userMatch = await dbClient.query('SELECT id FROM users WHERE id = ?', [userId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
if (!userMatch.length) return genericResponse(STATUS_CODE.NotFound, 'User ID does not exist.');
const plans = await dbClient
.query('SELECT id, name, folder, lastUpdated FROM plans WHERE BINARY ownerId = ? AND deleted = 0 ORDER BY folder ASC,name ASC', [userId])
.query('SELECT id, name, folder, lastUpdated FROM plans WHERE ownerId = ? AND deleted = 0 ORDER BY folder ASC,name ASC', [userId])
.catch(() => {
failed = true;
});
@@ -82,19 +83,33 @@ Deno.serve({ port: config.api.port }, async (req) => {
return genericResponse(STATUS_CODE.OK, JSON.stringify(plans));
} else if (path.startsWith('/export/')) {
const userId = path.replace('/export/', '');
const userMatch = await dbClient.query('SELECT id FROM users WHERE BINARY id = ?', [userId]).catch(() => {
const userMatch = await dbClient.query('SELECT id, name FROM users WHERE id = ?', [userId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
if (!userMatch.length) return genericResponse(STATUS_CODE.NotFound, 'User ID does not exist.');
// WIP: export plans to zip code goes here
return genericResponse(STATUS_CODE.NotImplemented, 'Export function WIP.');
const plans = await dbClient
.query('SELECT name, folder, data FROM plans WHERE ownerId = ? AND deleted = 0 ORDER BY folder ASC,name ASC', [userId])
.catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
const zip = new JSZip();
for (const plan of plans) {
zip.addFile(`${plan.folder}${plan.folder ? '/' : ''}${plan.name}.xivplan`, plan.data);
}
return new Response(await zip.generateAsync({ type: 'blob' }), {
status: STATUS_CODE.OK,
statusText: STATUS_TEXT[STATUS_CODE.OK],
});
}
} else if (req.method === 'POST' && path === '/enroll') {
const body = await req.json();
const userNameMatches = await dbClient.query('SELECT name FROM users WHERE BINARY name = ?', [body.name]).catch(() => {
const userNameMatches = await dbClient.query('SELECT name FROM users WHERE name = ?', [body.name]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
@@ -123,21 +138,22 @@ Deno.serve({ port: config.api.port }, async (req) => {
} else {
const body = await req.json();
const loginMatch = await dbClient.query('SELECT id, hash, email, deleteCode FROM users WHERE BINARY name = ?', [body.name]).catch(() => {
const loginMatch = await dbClient.query('SELECT id, hash, email, deleteCode FROM users WHERE name = ?', [body.name]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
if (loginMatch.length === 0) return genericResponse(STATUS_CODE.Forbidden, 'Invalid name/PIN combination. Remember name is case sensitive.');
if (!(await verify(body.pin, loginMatch[0].hash))) return genericResponse(STATUS_CODE.Forbidden, 'Invalid name/PIN combination.');
if (!(await compare(body.pin, loginMatch[0].hash))) return genericResponse(STATUS_CODE.Forbidden, 'Invalid name/PIN combination.');
const id = loginMatch[0].id;
const email = loginMatch[0].email;
const hasEmail = email.length > 0;
const hasEmail = (email ?? '').length > 0;
const deleteCode = loginMatch[0].deleteCode;
const deleteCodeSet = (deleteCode ?? '').length > 0;
switch (req.method) {
case 'POST':
if (path === '/auth') {
return genericResponse(STATUS_CODE.OK, JSON.stringify({ id, hasEmail }));
return genericResponse(STATUS_CODE.OK, JSON.stringify({ id, hasEmail, deleteCodeSet }));
} else if (path === '/create') {
if (body.planName.trim().length > 200) return genericResponse(STATUS_CODE.BadRequest, 'Name too long.');
if (body.folder.trim() && body.folder.trim().length > 200) return genericResponse(STATUS_CODE.BadRequest, 'Folder name too long.');
@@ -155,28 +171,28 @@ Deno.serve({ port: config.api.port }, async (req) => {
case 'PUT':
if (path.startsWith('/undelete/')) {
const planId = path.replace('/undelete/', '');
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE BINARY id = ?', [planId]).catch(() => {
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
await dbClient.execute('UPDATE plans SET deleted = 0, lastUpdated = ? WHERE BINARY id = ?', [new Date(), planId]).catch(() => {
await dbClient.execute('UPDATE plans SET deleted = 0, lastUpdated = ? WHERE id = ?', [new Date(), planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
return genericResponse(STATUS_CODE.OK, 'Plan restored.');
} else if (path.startsWith('/update/')) {
const planId = path.replace('/update/', '');
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE BINARY id = ?', [planId]).catch(() => {
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
await dbClient.execute('UPDATE plans SET data = ?, lastUpdated = ? WHERE BINARY id = ?', [body.data, new Date(), planId]).catch(() => {
await dbClient.execute('UPDATE plans SET data = ?, lastUpdated = ? WHERE id = ?', [body.data, new Date(), planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
@@ -185,14 +201,14 @@ Deno.serve({ port: config.api.port }, async (req) => {
if (body.planName.trim().length > 200) return genericResponse(STATUS_CODE.BadRequest, 'Name too long.');
const planId = path.replace('/rename/', '');
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE BINARY id = ?', [planId]).catch(() => {
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
await dbClient.execute('UPDATE plans SET name = ?, lastUpdated = ? WHERE BINARY id = ?', [body.planName, new Date(), planId]).catch(() => {
await dbClient.execute('UPDATE plans SET name = ?, lastUpdated = ? WHERE id = ?', [body.planName, new Date(), planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
@@ -201,14 +217,14 @@ Deno.serve({ port: config.api.port }, async (req) => {
if (body.folder.trim().length > 200) return genericResponse(STATUS_CODE.BadRequest, 'Folder name too long.');
const planId = path.replace('/move/', '');
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE BINARY id = ?', [planId]).catch(() => {
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
await dbClient.execute('UPDATE plans SET folder = ?, lastUpdated = ? WHERE BINARY id = ?', [body.folder, new Date(), planId]).catch(() => {
await dbClient.execute('UPDATE plans SET folder = ?, lastUpdated = ? WHERE id = ?', [body.folder, new Date(), planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
@@ -218,12 +234,12 @@ Deno.serve({ port: config.api.port }, async (req) => {
case 'DELETE':
if (path === '/unenroll') {
if (!hasEmail || body.deleteCode.trim() === deleteCode) {
await dbClient.execute('DELETE FROM plans WHERE BINARY ownerId = ?', [id]).catch(() => {
await dbClient.execute('DELETE FROM plans WHERE ownerId = ?', [id]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't delete plans.");
await dbClient.execute('DELETE FROM users WHERE BINARY id = ?', [id]).catch(() => {
await dbClient.execute('DELETE FROM users WHERE id = ?', [id]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't delete user.");
@@ -231,7 +247,7 @@ Deno.serve({ port: config.api.port }, async (req) => {
return genericResponse(STATUS_CODE.OK, 'Deleted user and plans.');
} else if (hasEmail && !deleteCode) {
const newDeleteCode = nanoid();
await dbClient.execute('UPDATE users SET deleteCode = ? WHERE BINARY id = ?', [newDeleteCode, id]).catch(() => {
await dbClient.execute('UPDATE users SET deleteCode = ? WHERE id = ?', [newDeleteCode, id]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't set deleteCode.");
@@ -258,7 +274,7 @@ Deno.serve({ port: config.api.port }, async (req) => {
fromAddress: config.email.address,
toAddress: email,
subject: 'XIVPlan+DB Delete Code',
content: `Notice: account deletion is permanent and will delete all plans saved under your account.<br/><br/>Please use the following Delete Code to delete your account:<br/><br/>${newDeleteCode}`,
content: `Notice: Account deletion is permanent and will permanently delete all plans saved under your account.<br/><br/>Please use the following Delete Code to delete your account:<br/><br/>${newDeleteCode}`,
}),
}).catch(() => {
failed = true;
@@ -282,21 +298,21 @@ Deno.serve({ port: config.api.port }, async (req) => {
}
} else if (path.startsWith('/delete/')) {
const planId = path.replace('/delete/', '');
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE BINARY id = ?', [planId]).catch(() => {
const planMatch = await dbClient.query('SELECT ownerId FROM plans WHERE id = ?', [planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
if (!planMatch.length) return genericResponse(STATUS_CODE.NotFound, 'Plan ID does not exist.');
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
await dbClient.execute('UPDATE plans SET deleted = 1, lastUpdated = ? WHERE BINARY id = ?', [new Date(), planId]).catch(() => {
await dbClient.execute('UPDATE plans SET deleted = 1, lastUpdated = ? WHERE id = ?', [new Date(), planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");
return genericResponse(STATUS_CODE.OK, 'Plan deleted.');
} else if (path.startsWith('/perm-delete/')) {
const planId = path.replace('/perm-delete/', '');
const planMatch = await dbClient.query('SELECT ownerId, deleted FROM plans WHERE BINARY id = ?', [planId]).catch(() => {
const planMatch = await dbClient.query('SELECT ownerId, deleted FROM plans WHERE id = ?', [planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't read DB.");
@@ -304,7 +320,7 @@ Deno.serve({ port: config.api.port }, async (req) => {
if (planMatch[0].ownerId !== id) return genericResponse(STATUS_CODE.Forbidden, "You don't own this plan.");
if (!planMatch[0].deleted) return genericResponse(STATUS_CODE.Forbidden, 'Plan must be marked as deleted to perm delete.');
await dbClient.execute('DELETE FROM plans WHERE BINARY id = ? AND deleted = 1', [planId]).catch(() => {
await dbClient.execute('DELETE FROM plans WHERE id = ? AND deleted = 1', [planId]).catch(() => {
failed = true;
});
if (failed) return genericResponse(STATUS_CODE.InternalServerError, "Couldn't update DB.");

View File

@@ -11,8 +11,8 @@ interface Plan {
const makePlanButtons = (planId: string, deleted: boolean) =>
deleted
? `<button onclick="doAction('undelete','${planId}')">restore</button><button onclick="doAction('perm-delete','${planId}')">perm delete</button>`
: `<button onclick="openPlan('${planId}')">open</button><button onclick="sharePlan('${planId}')">share</button><button onclick="doAction('rename','${planId}')">rename</button><button onclick="doAction('move','${planId}')">move</button><button onclick="doAction('delete','${planId}')">delete</button>`;
? `<button class="btn" onclick="doAction('undelete','${planId}')">restore</button><button class="btn" onclick="doAction('perm-delete','${planId}')">perm delete</button>`
: `<button class="btn" onclick="openPlan('${planId}')">open</button><button class="btn" onclick="sharePlan('${planId}')">share</button><button class="btn" onclick="doAction('rename','${planId}')">rename</button><button class="btn" onclick="doAction('move','${planId}')">move</button><button class="btn" onclick="doAction('delete','${planId}')">delete</button>`;
const makePlanItem = (plan: Plan, deleted: boolean) =>
`<li>${plan.folder}${plan.folder && '/'}${plan.name} - ${plan.lastUpdated.toLocaleString()} - ${makePlanButtons(plan.id, deleted)}</li>`;
@@ -21,18 +21,14 @@ export default async (userId: string, userName: string) => {
let failed = false;
const plans: Plan[] = await dbClient
.query('SELECT id, name, folder, lastUpdated FROM plans WHERE BINARY ownerId = ? AND deleted = 0 GROUP BY folder,name,id ORDER BY folder ASC,name ASC', [
userId,
])
.query('SELECT id, name, folder, lastUpdated FROM plans WHERE ownerId = ? AND deleted = 0 GROUP BY folder,name,id ORDER BY folder ASC,name ASC', [userId])
.catch((e) => {
failed = true;
});
if (failed) return "Couldn't read DB.";
const deletedPlans: Plan[] = await dbClient
.query('SELECT id, name, folder, lastUpdated FROM plans WHERE BINARY ownerId = ? AND deleted = 1 GROUP BY folder,name,id ORDER BY folder ASC,name ASC', [
userId,
])
.query('SELECT id, name, folder, lastUpdated FROM plans WHERE ownerId = ? AND deleted = 1 GROUP BY folder,name,id ORDER BY folder ASC,name ASC', [userId])
.catch(() => {
failed = true;
});
@@ -40,37 +36,123 @@ export default async (userId: string, userName: string) => {
return `<div>
<script>
const actionMethod = new Map([['rename','PUT'],['move','PUT'],['undelete','PUT'],['delete','DELETE'],['perm-delete','DELETE']]);
function doAction(action,planId){
let newName='';
if(action==='rename'||action==='move'){newName=prompt(\`Please provide a new \${action==='rename'?'plan':'folder'} name:\`);if(!newName){return;}}
let userName=localStorage.getItem('name');
if(!userName){userName=prompt('Please enter your username:');if(!userName){return;}}
const userPIN=prompt('Please enter your PIN:');
fetch(\`/api/\${action}/\${planId}\`,{method:actionMethod.get(action),body:JSON.stringify({name:userName,pin:userPIN,folder:newName,planName:newName})})
.catch((e)=>{e.text().then((text)=>{alert(text);});})
.then((r) => {
if(r.status===200){localStorage.setItem('name',userName);r.text().then((text)=>{alert(text);window.location.reload();});}
else{r.text().then((text)=>{alert(text);});}
});
const actionMethod = new Map([
['rename', 'PUT'],
['move', 'PUT'],
['undelete', 'PUT'],
['delete', 'DELETE'],
['perm-delete', 'DELETE'],
]);
function doAction(action, planId) {
let newName = '';
if (action === 'rename' || action === 'move') {
newName = prompt(\`Please provide a new \${action==='rename'?'plan':'folder'} name:\`);
if (!newName) {
return;
}
}
let userName = localStorage.getItem('name');
if (!userName) {
userName = prompt('Please enter your username:');
if (!userName) {
return;
}
}
const userPIN = prompt('Please enter your PIN:');
fetch(\`/api/\${action}/\${planId}\`, {
method: actionMethod.get(action),
body: JSON.stringify({ name: userName, pin: userPIN, folder: newName, planName: newName }),
})
.catch((e) => {
e.text().then((text) => {
alert(text);
});
})
.then((r) => {
if (r.status === 200) {
localStorage.setItem('name', userName);
r.text().then((text) => {
alert(text);
window.location.reload();
});
} else {
r.text().then((text) => {
alert(text);
});
}
});
}
function exportPlans(){
fetch('/api/export/${userId}')
.catch((e)=>{e.text().then((text)=>{alert(text);});})
.then((r)=>{r.text().then((text)=>{alert(text);});});
function openPlan(planId) {
window.open(\`${config.api.publicDomain}share#\${planId}\`);
}
function openPlan(planId){window.open(\`${config.api.publicDomain}share#\${planId}\`);}
async function sharePlan(planId){
const link=\`${config.api.publicDomain}share#\${planId}\`;
try{await navigator.clipboard.writeText(link);alert('Link copied to clipboard');}
catch (error){prompt('Failed to copy to clipboard, please select and copy the link below:',link);}
async function sharePlan(planId) {
const link = \`${config.api.publicDomain}share#\${planId}\`;
try {
await navigator.clipboard.writeText(link);
alert('Link copied to clipboard');
} catch (error) {
prompt('Failed to copy to clipboard, please select and copy the link below:', link);
}
}
function deleteAccount() {
const userName = prompt('Please enter your username:');
if (!userName.trim()) {
return;
}
const userPin = prompt('Please enter your PIN:');
if (!userPin.trim()) {
return;
}
if (!confirm('Are you sure you want to delete your account? This is permanent and irreversible, and will delete all plans saved to your account as well.')) {
return;
}
fetch('/api/auth', { method: 'POST', body: JSON.stringify({ name: userName.trim(), pin: userPin.trim() }) })
.catch((e) => {
e.text().then((text) => {
alert(text);
});
})
.then((r) => {
if (r.status === 200) {
r.json().then((j) => {
let deleteCode = '';
if (j.hasEmail && j.deleteCodeSet) {
deleteCode = prompt("Please enter the Delete Code emailed to you (if you don't see it, please check your spam folder):");
}
if (j.hasEmail && j.deleteCodeSet && !deleteCode) {
alert('Delete code required.');
return;
}
fetch('/api/unenroll', { method: 'DELETE', body: JSON.stringify({ name: userName.trim(), pin: userPin.trim(), deleteCode: deleteCode.trim() }) })
.catch((e) => {
e.text().then((text) => {
alert(text);
});
})
.then((r) => {
if (r.status === 200) {
alert('Account and plans deleted.');
window.location.reload();
} else {
r.text().then((text) => {
alert(text);
});
}
});
});
} else {
r.text().then((text) => {
alert(text);
});
}
});
}
</script>
<p>This is a very basic management page. Please excuse the number of alert/prompts that will come up when you click on things as it was the quickest way to build it out.</p>
<p>Please note: anything modifying data will require you to enter your PIN again as both the web view you are looking at and server behind it are completely stateless for simplicity.</p>
<p>DateTimeStamps on this page are all displayed in the US Eastern time zone. I don't care enough to make this extremely basic page dynamic.</p>
<h3>${userName}'s Plans:</h3>
<button onclick="exportPlans()" disabled style="color:black;cursor:not-allowed;">WIP: export all plans</button>
<a href="/api/export/${userId}" download="${userName}-plans.zip" class="btn">download all plans</a>
<ul>
${plans.map((plan) => makePlanItem(plan, false)).join('')}
</ul>
@@ -78,5 +160,6 @@ ${plans.map((plan) => makePlanItem(plan, false)).join('')}
<ul>
${deletedPlans.map((plan) => makePlanItem(plan, true)).join('')}
</ul>
<button class="btn" onclick="deleteAccount()">Delete my account</button>
</div>`;
};

View File

@@ -1,10 +1,10 @@
export default `<div>
<script>
function doLogin(){
fetch('/api/auth',{method:'POST',body:JSON.stringify({ name:document.getElementById('name').value.trim(), pin:document.getElementById('pin').value.trim() })})
fetch('/api/auth',{method:'POST',body:JSON.stringify({name:document.getElementById('name').value.trim(),pin:document.getElementById('pin').value.trim()})})
.catch((e)=>{e.text().then((text)=>{alert(text);});})
.then((r) => {
if(r.status===200){r.json().then((j)=>{localStorage.setItem('name', document.getElementById('name').value.trim());window.location.replace('/api/home/'+j.id);});}
if(r.status===200){r.json().then((j)=>{localStorage.setItem('name',document.getElementById('name').value.trim());window.location.replace('/api/home/'+j.id);});}
else{r.text().then((text)=>{alert(text);});}
});
}

View File

@@ -20,6 +20,15 @@ a {
text-decoration:none;
color:white;
}
.btn {
font: 13.333px Sans;
padding: 1px 6px;
border: 1px outset buttonborder;
border-radius: 3px;
color: buttontext;
background-color: buttonface;
text-decoration: none;
}
li {
margin:0.5rem 0;
}

View File

@@ -1 +1 @@
deno run --allow-net --allow-import mod.ts
deno run --allow-net --allow-import --allow-env mod.ts

19
xivplan-db.rc Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/sh
# PROVIDE: xivplan_db
. /etc/rc.subr
name="xivplan_db"
rcvar="xivplan_db_enable"
pidfile="/var/webapps/XIVPlan-DB/xivplan_db.pid"
xivplan_db_root="/var/webapps/XIVPlan-DB"
xivplan_db_log="/var/log/xivplan_db.log"
xivplan_db_chdir="${xivplan_db_root}"
command="/usr/sbin/daemon"
command_args="-f -R 5 -P ${pidfile} -o ${xivplan_db_log} /usr/local/bin/deno run --allow-net --allow-import --allow-env ${xivplan_db_root}/mod.ts"
load_rc_config xivplan-db
run_rc_command "$1"