Avatar

parkerdavis

6 public vals
Joined July 17, 2023

Comments (just add water)

A self-contained comments system Val. Just fork this val and you have a complete (but extremely minimal) comment system!

Call on the front-end using:

const MY_FORKED_VAL_URL = "https://vez-comments.web.val.run";

const getComments = async () => {
  const response = await fetch(MY_FORKED_VAL_URL);
  const json = await response.json();
  return json;
};

const addComment = async (str) => {
  try {
    const response = await fetch(MY_FORKED_VAL_URL, {
      method: "POST",
      body: JSON.stringify(str),
    });

    if (response.status >= 400 && response.status < 600) {
      /* error */
      return false;
    } else {
      /* success */
      return true;
    }
  } catch (e) {
    /* error */
    return false;
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { blob } from "https://esm.town/v/std/blob?v=10";
import { email } from "https://esm.town/v/std/email?v=9";
import { Hono } from "npm:hono@3.9.2";
const KEY = import.meta.url.split("?")[0];
export async function addComment(str) {
const comments = await blob.getJSON(KEY) as Array<string> ?? [];
comments.push(str);
await blob.setJSON(KEY, comments);
await email({ text: "New Comment Alert!: " + str });
}
export async function getComments() {
return await blob.getJSON(KEY) as Array<string> ?? [];
}
const app = new Hono();
app.get("/", async (c) => c.json(await getComments()));
app.post("/", async (c) => {
const str = await c.req.json();
await addComment(str);
return c.text("Added comment!", 200);
});
export default app.fetch;

SQLite Explorer

View and interact with your Val Town SQLite data. It's based off Steve's excellent SQLite Admin val, adding the ability to run SQLite queries directly in the interface. This new version has a revised UI and that's heavily inspired by LibSQL Studio by invisal. This is now more an SPA, with tables, queries and results showing up on the same page.

image.webp

Install

Install the latest stable version (v66) by forking this val:

Install v66

Authentication

Login to your SQLite Explorer with password authentication with your Val Town API Token as the password.

Todos / Plans

  • improve error handling
  • improve table formatting
  • sticky table headers
  • add codemirror
  • add loading indication to the run button (initial version shipped)
  • add ability to favorite queries
  • add saving of last query run for a table (started)
  • add visible output for non-query statements
  • add schema viewing
  • add refresh to table list sidebar after CREATE/DROP/ALTER statements
  • add automatic execution of initial select query on double click
  • add views to the sidebar
  • add triggers to sidebar
  • add upload from SQL, CSV and JSON
  • add ability to connect to a non-val town Turso database
  • fix wonky sidebar separator height problem (thanks to @stevekrouse)
  • make result tables scrollable
  • add export to CSV, and JSON (CSV and JSON helper functions written in this val. Thanks to @pomdtr for merging the initial version!)
  • add listener for cmd+enter to submit query
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/** @jsxImportSource https://esm.sh/hono@latest/jsx **/
import { modifyFetchHandler } from "https://esm.town/v/andreterron/codeOnValTown?v=50";
import { resetStyle } from "https://esm.town/v/nbbaier/resetStyle";
import { sqlToCSV, sqlToJSON } from "https://esm.town/v/nbbaier/sqliteExportHelpers";
import {
EditorSection,
MockTable,
Separator,
Table,
TablesList,
} from "https://esm.town/v/nbbaier/sqliteStudioComponents";
import { RunSVG, TableSVG } from "https://esm.town/v/nbbaier/sqliteStudioSVGs";
import { sqliteStyle } from "https://esm.town/v/nbbaier/sqliteStyle";
import { passwordAuth } from "https://esm.town/v/pomdtr/password_auth?v=70";
import { sqlite } from "https://esm.town/v/std/sqlite";
import { Hono } from "npm:hono";
import type { FC } from "npm:hono/jsx";
import { jsxRenderer } from "npm:hono/jsx-renderer";
const HTML: FC = ({ children }) => {
return (
<html>
<head>
<title>SQLite Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap"
rel="stylesheet"
/>
<style
dangerouslySetInnerHTML={{ __html: resetStyle }}
/>
<style
dangerouslySetInnerHTML={{ __html: sqliteStyle }}
/>
<script src="https://unpkg.com/htmx.org@1.9.9/dist/htmx.min.js"></script>
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
<script type="module" src="https://esm.town/v/nbbaier/resizeScript" />
<script type="module" src="https://esm.town/v/nbbaier/tableSelectScript" />
</head>
<body _="
on keydown[event.metaKey and key is 'Enter'] log 'command + enter' then send submitForm to #sql-editor
">
<div class="root-container">
<header>
<h1>sqlite explorer</h1>
</header>
{children}
</div>
<script type="module" src="https://esm.town/v/nbbaier/downloadScript" />
<script type="module" src="https://esm.town/v/nbbaier/enableButtonsScript" />
</body>
</html>
);
};
const app = new Hono();
app.use(
"*",
jsxRenderer(
({ children }) => {
return <HTML children={children} />;
},
{ docType: false },
),
);
app.get("/", async (c) => {
let data = await sqlite.execute(`
SELECT
name
FROM
sqlite_schema
WHERE
type ='table' AND
name NOT LIKE 'sqlite_%';`);
let tables = data.rows;
return c.render(
<main class="sidebar-layout">
<div class="sidebar">
<TablesList tables={tables}></TablesList>
</div>
<Separator direction="horizontal"></Separator>
<div class="not-sidebar">
<EditorSection />
<Separator direction="vertical" isResizer={true}></Separator>
<div class="bottom-container">
<div id="results-table" class="bottom">
</div>
</div>
</div>
</main>,
);
});
app.post("/query", async (c) => {
const query = (await c.req.parseBody()).query as string;

Password Auth Middleware

Protect your vals behind a password. Use session cookies to persist authentication.

6ed0648ae8813e958dbe79468572cb52f578239c0fae55857a13660beebdc5fd.png

Demo

See @pomdtr/password_auth_test

Usage

If you want to use an api token to authenticate:

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); });

Or if you prefer to use a string:

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); }, { password: Deno.env.get("VAL_PASSWORD") });

You can also set multiple ones

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); }, { password: [Deno.env.get("VAL_PASSWORD"), Deno.env.get("STEVE_PASSWORD")] });

Note that authenticating using your api token remain an option even after setting a password.

TODO

  • allow to authenticate using a val town token
  • add a way to send an email to ask a password from the val owner
  • automatically extend the session
  • automatically remove expired sessions

FAQ

How to sign out ?

Navigate to <your-site>/signout.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { deleteCookie, getCookies, setCookie } from "https://deno.land/std/http/cookie.ts";
import { inferRequestVal } from "https://esm.town/v/andreterron/inferRequestVal?v=2";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { html } from "https://esm.town/v/stevekrouse/html?v=5";
import { zip } from "npm:lodash-es";
import { nanoid } from "npm:nanoid";
type Session = {
id: string;
expiresAt: number;
};
async function createSessionTable(tableName: string) {
await sqlite.execute(`CREATE TABLE ${tableName} (
id TEXT NOT NULL PRIMARY KEY,
expires_at INTEGER NOT NULL,
val_slug STRING NOT NULL
);`);
}
async function createSession(tableName: string, valSlug: string): Promise<Session> {
try {
const expires_at = new Date();
expires_at.setDate(expires_at.getDate() + 7);
const session: Session = { id: nanoid(), expiresAt: expires_at.getTime() };
await sqlite.execute({
sql: `INSERT INTO ${tableName} (id, val_slug, expires_at) VALUES (?, ?, ?)`,
args: [session.id, valSlug, session.expiresAt],
});
return session;
} catch (e) {
if (e.message.includes("no such table")) {
await createSessionTable(tableName);
return createSession(tableName, valSlug);
}
throw e;
}
}
async function getSession(tableName: string, sessionID: string, valSlug: string): Promise<Session> {
try {
const { rows, columns } = await sqlite.execute({
sql: `SELECT * FROM ${tableName} WHERE id = ? AND val_slug = ?`,
args: [sessionID, valSlug],
});
if (rows.length == 0) {
return null;
}
return Object.fromEntries(zip(columns, rows.at(0))) as Session;
} catch (e) {
if (e.message.includes("no such table")) {
return null;
}
throw e;
}
}
async function fetchUser(token: string): Promise<{ id: string }> {
const resp = await fetch("https://api.val.town/v1/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (resp.status !== 200) {
throw new Error("Could not fetch user");
}
return resp.json();
}
async function verifyApiToken(token: string) {
try {
const [currentUser, requestUser] = await Promise.all([fetchUser(Deno.env.get("valtown")), fetchUser(token)]);
return currentUser.id == requestUser.id;
} catch (_) {
return false;
}
}
const loginPage = (handle) =>
`<html>
<head>
<link rel="icon" href="https://fav.farm/🔒" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
</head>
<body style="display: flex; justify-content: center; align-items: center;">
<article>
<p>This val website is <a href="https://www.val.town/v/pomdtr/password_auth">protected by a password</a>.</p>
<p>If you are <a href="https://val.town/u/${handle}">@${handle}</a>, you can access it using an <a href="https://www.val.town/settings/api">API token</a>.</p>
<p>If not, you'll need to contact the author for access.</p>
<footer>
<form method="POST" style="margin-block-end: 0em;">
<fieldset role="group" style="margin-bottom: 0em;">
<input id="password" placeholder="Password" name="password" type="password" />
<input type="submit" value="Sign In"/>

Blob Admin

This is a lightweight Blob Admin interface to view and debug your Blob data.

b7321ca2cd80899250589b9aa08bc3cae9c7cea276282561194e7fc537259b46.png

Use this button to install the val:

It uses basic authentication with your Val Town API Token as the password (leave the username field blank).

TODO

  • /new - render a page to write a new blob key and value
  • /edit/:blob - render a page to edit a blob (prefilled with the existing content)
  • /delete/:blob - delete a blob and render success
  • handle non-textual val properly
  • add upload/download buttons
  • merge edit and view pages
  • add client side navigation using htmx
  • use codemirror instead of a textarea for editing text blobs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/** @jsxImportSource https://esm.sh/hono@4.0.8/jsx **/
import { modifyFetchHandler } from "https://esm.town/v/andreterron/codeOnValTown?v=50";
import view_route from "https://esm.town/v/pomdtr/blob_admin_blob";
import create_route from "https://esm.town/v/pomdtr/blob_admin_create";
import delete_route from "https://esm.town/v/pomdtr/blob_admin_delete";
import edit_route from "https://esm.town/v/pomdtr/blob_admin_edit";
import { passwordAuth } from "https://esm.town/v/pomdtr/password_auth?v=74";
import { blob } from "https://esm.town/v/std/blob?v=11";
import { Hono } from "npm:hono@4.0.8";
import { jsxRenderer } from "npm:hono@4.0.8/jsx-renderer";
const app = new Hono();
app.use(
jsxRenderer(({ children }) => {
return (
<html>
<head>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
/>
<title>Blob Admin</title>
</head>
<body>
<main class="container">
{children}
</main>
</body>
</html>
);
}),
);
app.get("/", async (c) => {
let blobs = await blob.list();
return c.render(
<div class="overflow-auto">
<h1>Blob Admin</h1>
<a href="/create" style={{ marginBottom: "1em", display: "inline-block" }}>New Blob</a>
<div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Size (kb)</th>
<th>Last Modified</th>
<th>Edit</th>
<th>Delete</th>
<th>Download</th>
</tr>
</thead>
{blobs.map(b => (
<tr>
<td>
<a href={`/view/${encodeURIComponent(b.key)}`}>
{b.key}
</a>
</td>
<td>{b.size / 1000}</td>
<td>{new Date(b.lastModified).toLocaleString()}</td>
<td>
<a href={`/edit/${encodeURIComponent(b.key)}`}>✍️</a>
</td>
<td>
<a href={`/delete/${encodeURIComponent(b.key)}`}>🗑️</a>
</td>
<td>
<a href={`/download/${encodeURIComponent(b.key)}`}>💾</a>
</td>
</tr>
))}
</table>
</div>
</div>,
);
});
app.route("/create", create_route);
app.route("/view", view_route);
app.route("/edit", edit_route);
app.route("/delete", delete_route);
app.get("/download/:key", (c) => {
return blob.get(c.req.param("key"));
});
export default modifyFetchHandler(passwordAuth(app.fetch));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { html } from "https://esm.town/v/parkerdavis/html";
import { set } from "https://esm.town/v/std/set?v=11";
import { linksFormData } from "https://esm.town/v/parkerdavis/linksFormData";
import { email as email2 } from "https://esm.town/v/std/email?v=9";
export let linksForm = async (req: Request) => {
if (req.method === "POST") {
let formData = await req.formData();
let data = {
email: formData.get("email"),
message: formData.get("message"),
time: Date.now(),
};
await email2({
text: data,
subject: "Links form submission received!",
});
linksFormData.push(data);
await set("linksFormData", linksFormData);
return html(
`
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="refresh" content="3; url='https://links.parkerdavis.dev'" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
</head>
<body>
<p>Hey thanks for the message!</p>
<p>You will be redirected back to <a href="https://links.parkerdavis.dev">https://links.parkerdavis.dev</a> in a few seconds.
</body>
</html>
`,
{
headers: {
"Set-Cookie": `email=${formData.get("email")}`,
},
},
);
}
return html("Hello!");
};

Val Town Docs Feedback Form & Handler

This feedback form is linked on our docs site.

Screenshot 2023-09-07 at 14.24.25@2x.png

This val renders an HTML form, including pre-fills the user's email address if they've submitted the form in the past (via a cookie), and pre-fills the URL by grabbing it out of the query params.

It handles form submissions, including parsing the form, saving the data into @stevekrouse.docsFeedback, a private JSON val, and then returns a thank you message, and set's the user's email address as a cookie, to save them some keystrokes the next time they fill out the form.

Another val, @stevekrouse.formFeedbackAlert, polls on an interval for new form submissions, and if it finds any, forwards them on a private Val Town discord channel.

There are a number of subtleties to the way each of some features are implemented.

A user submitted three pieces of feedback in quick succession, so I thought it'd be nice if we remembered user's email addresses after their first form submissions. There are classically two ways to do this, cookies or localstorage. I choose cookies. It requires setting them in the response header and getting them out of the request header. I used a Deno library to parse the cookie but I set it manually because that seemed simpler.

You may be wondering about how I'm getting the referrer out of the query params instead of from the HTTP Referrer header. I tried that at first, but it's increasingly difficult to get path data from it due to more restrictive security policies. So instead I decided to include the URL data in a query param. I get it there via this script in my blog's site:

Create valfunction updateFeedback(ref) { let feedback = [...document.getElementsByTagName('a')].find(e => e.innerText == 'Feedback') feedback.setAttribute('href', "https://stevekrouse-docfeedbackform.web.val.run/?ref=" + ref) } setTimeout(() => updateFeedback(document.location.href), 100); navigation.addEventListener('navigate', e => updateFeedback(e.destination.url));

Finally, you may be wondering why I queue up feedback in @stevekrouse.docsFeedback, a private JSON val, and then process it via @stevekrouse.formFeedbackAlert instead of sending it along to Discord directly in this val. I tried that originally but it felt too slow to wait for the API call to Discord before returning the "Thanks for your feedback" message. This is where the context.waitUntil method (that Cloudflare workers and Vercel Edge Functions support) would really come in handy – those allow you to return a Response, and then continue to compute. Currently Val Town requires you to stop all compute with the returning of your Response, so the only way to compute afterwards is to queue it up for another val to take over, and that's what I'm doing here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { html } from "https://esm.town/v/parkerdavis/html";
import { set } from "https://esm.town/v/std/set?v=11";
import { feedback as feedback2 } from "https://esm.town/v/parkerdavis/feedback";
import { email as email2 } from "https://esm.town/v/std/email?v=9";
export let docFeedbackForm = async (req: Request) => {
if (req.method === "POST") {
let formData = await req.formData();
let data = {
feedback: formData.get("feedback"),
url: formData.get("url"),
email: formData.get("email"),
time: Date.now(),
};
await email2({
text: data,
subject: "Form submission received!",
});
feedback2.push(data);
await set("feedback", feedback2);
return html(
`
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
<meta http-equiv="refresh" content="7; url='https://parkerdavis.dev'" />
</head>
<body>
<p>Hey, thanks for the feedback!</p>
<p>You will be redirected to <a href="https://parkerdavis.dev">https://parkerdavis.dev</a> in a few seconds.
</body>
</html>
`,
{
headers: {
"Set-Cookie": `email=${formData.get("email")}`,
},
},
);
}
return html("Hello!");
};
Next