feat: le guestbook has been added

This commit is contained in:
dusk 2024-08-22 23:54:03 +03:00
parent 755c578e98
commit 5fce840adc
Signed by: dusk
SSH Key Fingerprint: SHA256:Abmvag+juovVufZTxyWY8KcVgrznxvBjQpJesv071Aw
26 changed files with 479 additions and 6 deletions

7
.gitignore vendored
View File

@ -22,3 +22,10 @@ vite.config.ts.timestamp-*
# nix
/result
# scala
target
.metals
.bloop
guestbook/entries
guestbook/entries_size

View File

@ -5,5 +5,8 @@
},
"editor.quickSuggestions": {
"strings": "on"
},
"files.watcherExclude": {
"**/target": true
}
}

BIN
bun.lockb

Binary file not shown.

1
guestbook/.bsp/sbt.json Normal file
View File

@ -0,0 +1 @@
{"name":"sbt","version":"1.10.1","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["C:\\Users\\dusk\\scoop\\apps\\graalvm-oracle-jdk\\current/bin/java","-Xms100m","-Xmx100m","-classpath","C:\\Users\\dusk\\AppData\\Local\\Coursier\\cache\\arc\\https\\github.com\\sbt\\sbt\\releases\\download\\v1.10.1\\sbt-1.10.1.zip\\sbt\\bin\\sbt-launch.jar","-Dsbt.script=C:\\Users\\dusk\\AppData\\Local\\Coursier\\data\\bin\\sbt.bat","xsbt.boot.Boot","-bsp"]}

2
guestbook/.scalafmt.conf Normal file
View File

@ -0,0 +1,2 @@
version = "3.7.15"
runner.dialect = scala3

29
guestbook/build.sbt Normal file
View File

@ -0,0 +1,29 @@
val Http4sVersion = "0.23.27"
val CirceVersion = "0.14.9"
val MunitVersion = "1.0.0"
val LogbackVersion = "1.5.6"
val MunitCatsEffectVersion = "2.0.0"
lazy val root = (project in file("."))
.settings(
organization := "systems.gaze",
name := "guestbook",
version := "0.0.1-SNAPSHOT",
scalaVersion := "3.4.2",
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-ember-server" % Http4sVersion,
"org.http4s" %% "http4s-circe" % Http4sVersion,
"org.http4s" %% "http4s-dsl" % Http4sVersion,
"org.scalameta" %% "munit" % MunitVersion % Test,
"org.typelevel" %% "munit-cats-effect" % MunitCatsEffectVersion % Test,
"ch.qos.logback" % "logback-classic" % LogbackVersion % Runtime,
"com.lihaoyi" %% "os-lib" % "0.9.1",
"io.circe" %% "circe-core" % CirceVersion,
"io.circe" %% "circe-generic" % CirceVersion,
"io.circe" %% "circe-parser" % CirceVersion,
),
assembly / assemblyMergeStrategy := {
case "module-info.class" => MergeStrategy.discard
case x => (assembly / assemblyMergeStrategy).value.apply(x)
}
)

View File

@ -0,0 +1 @@
sbt.version=1.10.1

View File

@ -0,0 +1,8 @@
// format: off
// DO NOT EDIT! This file is auto-generated.
// This file enables sbt-bloop to create bloop config files.
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.6.0")
// format: on

View File

@ -0,0 +1,3 @@
addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.1")
addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0")
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.2.0")

View File

@ -0,0 +1,8 @@
// format: off
// DO NOT EDIT! This file is auto-generated.
// This file enables sbt-bloop to create bloop config files.
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.6.0")
// format: on

View File

@ -0,0 +1,8 @@
// format: off
// DO NOT EDIT! This file is auto-generated.
// This file enables sbt-bloop to create bloop config files.
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.6.0")
// format: on

View File

@ -0,0 +1,16 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- On Windows machines setting withJansi to true enables ANSI
color code interpretation by the Jansi library. This requires
org.fusesource.jansi:jansi:1.8 on the class path. Note that
Unix-based operating systems such as Linux and Mac OS X
support ANSI color codes by default. -->
<withJansi>true</withJansi>
<encoder>
<pattern>[%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>

View File

@ -0,0 +1,65 @@
package systems.gaze.guestbook
import cats.Applicative
import cats.syntax.all.*
import io.circe.Decoder
import io.circe.generic.auto._, io.circe.syntax._, io.circe._, io.circe.parser._
import os.Path
trait Guestbook[F[_]]:
def read(config: Guestbook.Config, from: Int, count: Int): F[Guestbook.Page]
def write(config: Guestbook.Config, entry: Guestbook.Entry): F[Unit]
object Guestbook:
private val logger = org.log4s.getLogger
final case class Config(
val entriesPath: Path,
val entryCountPath: Path,
)
final case class Entry(
val author: String,
val content: String,
val timestamp: Long
)
final case class Page(
val entries: List[Tuple2[Int, Entry]],
val hasNext: Boolean,
)
def impl[F[_]: Applicative]: Guestbook[F] = new Guestbook[F]:
def entriesSize(config: Config): Int =
os.read(config.entryCountPath).toInt
def read(config: Config, from: Int, count: Int): F[Page] =
val entryCount = entriesSize(config)
// limit from to 1 cuz duh lol
val startFrom = from.max(1)
// limit count to however many entries there are
val endAt = (startFrom + count - 1).min(entryCount).max(1)
// if it wants us to start from after entries just return empty
if startFrom > entryCount then
return Page(entries = List.empty, hasNext = false).pure[F]
logger.trace(s"want to read entries from $startFrom ($from) to $endAt ($from + $count)")
// actually get the entries
val entries = (startFrom to endAt)
.map((no) => // read the entries
val entryNo = entryCount - no + 1
logger.trace(s"reading entry at $entryNo")
entryNo -> decode[Entry](os.read(config.entriesPath / entryNo.toString)).getOrElse(
Entry(
author = "error",
content = "woops, this is an error!",
timestamp = 0
)
)
)
.toList
Page(entries, hasNext = entryCount > endAt).pure[F]
def write(config: Config, entry: Entry): F[Unit] =
val entryNo = entriesSize(config) + 1
val entryPath = config.entriesPath / entryNo.toString
// write entry
os.write.over(entryPath, entry.asJson.toString)
// update entry count
os.write.over(config.entryCountPath, entryNo.toString)
().pure[F]

View File

@ -0,0 +1,66 @@
package systems.gaze.guestbook
import io.circe.generic.auto._
import org.http4s.HttpRoutes
import org.http4s.dsl.Http4sDsl
import org.http4s.circe.CirceEntityEncoder.circeEntityEncoder
import org.http4s.UrlForm
import org.http4s.headers.Location
import cats.effect.IO
import cats.implicits.*
import org.http4s.server.middleware.Throttle
import scala.concurrent.duration.DurationInt
import cats.effect.unsafe.implicits.global
import scala.concurrent.duration.FiniteDuration
import org.http4s.Uri
class GuestbookRoutes(var guestbookConfig: Guestbook.Config, var websiteUri: Uri):
val dsl = new Http4sDsl[IO] {}
import dsl.*
def throttle(
ratelimited: String,
amount: Int,
per: FiniteDuration
)(routes: HttpRoutes[IO]): HttpRoutes[IO] =
Throttle
.httpRoutes[IO](amount, per)(routes)
.unsafeRunSync()
.map((resp) =>
// respond with SeeOther and put ratelimited query param
if (resp.status == TooManyRequests)
resp
.withStatus(SeeOther)
.withHeaders(
Location(
(websiteUri / "guestbook")
.withQueryParam("ratelimited", ratelimited)
)
)
else
resp
)
def routes(
G: Guestbook[IO]
): HttpRoutes[IO] =
val putEntry = HttpRoutes.of[IO] {
case req @ POST -> Root => for {
entry <- req.as[UrlForm].map { form =>
val author = form.getFirstOrElse("author", "error")
val content = form.getFirstOrElse("content", "error")
Guestbook
.Entry(author, content, timestamp = System.currentTimeMillis / 1000)
}
result <- G.write(guestbookConfig, entry)
resp <- SeeOther(Location(websiteUri / "guestbook"))
} yield resp
}
val getEntries = HttpRoutes.of[IO] {
case GET -> Root / IntVar(page) => for {
entries <- G.read(guestbookConfig, (page - 1).max(0) * 5, 5)
resp <- Ok(entries)
} yield resp
}
throttle("get", 10, 2.seconds)(getEntries)
<+> throttle("send", 5, 10.seconds)(putEntry)

View File

@ -0,0 +1,35 @@
package systems.gaze.guestbook
import com.comcast.ip4s.*
import fs2.io.net.Network
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.implicits.*
import org.http4s.server.middleware.Logger
import cats.effect.IO
import org.http4s.Uri
object GuestbookServer:
def run(
guestbookConfig: Guestbook.Config,
websiteUri: Uri,
): IO[Nothing] = {
val guestbookAlg = Guestbook.impl[IO]
// Combine Service Routes into an HttpApp.
// Can also be done via a Router if you
// want to extract segments not checked
// in the underlying routes.
val httpApp =
(GuestbookRoutes(guestbookConfig, websiteUri).routes(guestbookAlg)).orNotFound
// With Middlewares in place
val finalHttpApp = Logger.httpApp(true, true)(httpApp)
EmberServerBuilder
.default[IO]
.withHost(ipv4"0.0.0.0")
.withPort(port"8080")
.withHttpApp(finalHttpApp)
.build
}.useForever

View File

@ -0,0 +1,20 @@
package systems.gaze.guestbook
import cats.effect.IOApp
import org.http4s.Uri
object Main extends IOApp.Simple:
val websiteUri =
Uri
.fromString(sys.env("GUESTBOOK_WEBSITE_URI"))
.getOrElse(throw new Exception("write better uris lol"))
val guestbookConfig = Guestbook.Config(
entriesPath = os.pwd / "entries",
entryCountPath = os.pwd / "entries_size"
)
// make the entries path if it doesnt exist
os.makeDir.all(guestbookConfig.entriesPath)
// make the entry count path if it doesnt exist
if !os.exists(guestbookConfig.entryCountPath) then
os.write(guestbookConfig.entryCountPath, 0.toString)
val run = GuestbookServer.run(guestbookConfig, websiteUri)

View File

@ -16,8 +16,10 @@
"@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/kit": "^2.5.20",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.14",
"@types/eslint": "^8.56.11",
"@types/node": "^22.4.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.8.0",
"eslint-config-prettier": "^9.1.0",

25
src/lib/fetchHack.mjs Normal file
View File

@ -0,0 +1,25 @@
// hack/postSync.mjs
/**
,* @file POST some stuff to a URL.
,* Usage: one of
,* echo input | node postSync.mjs <url>
,* node postSync.mjs <url> <input>
,*/
// The argument count would break if called as a standalone script.
const url = process.argv[2] || process.exit(1);
const response = await fetch(url, {method:'GET',redirect:'manual'});
const json = await response.text().then(text => {
try {
const data = JSON.parse(text);
return data
} catch(err) {
return []
}
});
console.log(JSON.stringify({
location: response.headers.get('location'),
status: response.status,
body: json,
}));

View File

@ -1,7 +1,7 @@
const getTitle = (path: string) => {
let sl = path.split('/')
sl = sl.splice(1)
if (sl.length > 1) {
if (sl.length > 2) {
sl[0] = sl[0][0]
}
const newPath = sl.join('/')

View File

@ -14,7 +14,8 @@
let menuItems: MenuItem[] = [
{ href: '', name: 'home', iconUri: '/icons/home.png' },
{ href: 'entries', name: 'entries', iconUri: '/icons/entries.png' },
{ href: 'about', name: 'about', iconUri: '/icons/about.png' }
{ href: 'guestbook', name: 'guestbook', iconUri: '/icons/guestbook.png' },
{ href: 'about', name: 'about', iconUri: '/icons/about.png' },
];
const routeComponents = data.route.split('/');

View File

@ -0,0 +1,58 @@
import { GUESTBOOK_URL } from '$env/static/private'
import { redirect } from '@sveltejs/kit';
import {spawnSync} from 'node:child_process'
interface Entry {
author: String,
content: String,
timestamp: number,
}
interface FetchResult {
location: string,
status: number,
body: any,
}
function fetchBlocking(url: string): FetchResult | string {
const spawnResult = spawnSync("bun", ["src/lib/fetchHack.mjs", url]);
const out = spawnResult.stdout.toString();
try {
return JSON.parse(out)
} catch(err: any) {
return spawnResult.stderr.toString()
}
}
export function load({ url }) {
var data = {
entries: [] as [number, Entry][],
guestbook_url: GUESTBOOK_URL,
ratelimitedFeat: url.searchParams.get('ratelimited') as string || "",
page: parseInt(url.searchParams.get('page') || "1") || 1,
hasNext: false,
fetchError: "",
}
// handle cases where the page query might be a string so we just return back page 1 instead
data.page = isNaN(data.page) ? 1 : data.page
data.page = Math.max(data.page, 1)
if (data.ratelimitedFeat === "get") {
return data
}
const entriesResp = fetchBlocking(GUESTBOOK_URL + "/" + data.page)
if (typeof entriesResp === "string") {
data.fetchError = entriesResp
return data
}
const locationRaw = entriesResp.status === 303 ? entriesResp.location : null
if (locationRaw !== null && locationRaw.length > 0) {
const location = new URL(locationRaw)
data.ratelimitedFeat = location.searchParams.get('ratelimited') as string || ""
}
if (data.ratelimitedFeat === "get") {
return data
}
data.entries = entriesResp.body.entries
data.hasNext = entriesResp.body.hasNext
return data
}

View File

@ -0,0 +1,106 @@
<script lang="ts">
import Window from '../../components/window.svelte';
export let data;
const hasPreviousPage = data.page > 1;
const hasNextPage = data.hasNext;
</script>
<div class="flex flex-row flex-wrap">
<div class="fixed">
<Window title="guestbook">
{@const ratelimited = data.ratelimitedFeat === 'send'}
<div class="flex flex-col gap-4 w-[60ch]">
<p>hia, here is the guestbook if you wanna post anything :)</p>
<p>just be a good human bean pretty please</p>
<form action={data.guestbook_url} method="post">
<div class="entry">
<div class="flex flex-row">
<p class="place-self-start grow text-2xl font-monospace">###</p>
<p class="justify-end self-center text-sm font-monospace">...</p>
</div>
<textarea
class="text-lg ml-0.5 bg-inherit resize-none text-shadow-white placeholder-shown:[text-shadow:none] [field-sizing:content]"
name="content"
placeholder="say meow!"
required
/>
<p class="place-self-end text-sm font-monospace">
--- posted by <input
type="text"
name="author"
placeholder="author"
class="p-0 bg-inherit border-hidden max-w-[16ch] text-right text-sm text-shadow-white placeholder-shown:[text-shadow:none] [field-sizing:content]"
required
/>
</p>
</div>
<div class="flex gap-4 mt-4">
<input
type="submit"
value="post"
class="text-xl text-ralsei-green-light leading-5 motion-safe:hover:animate-bounce w-fit border-double border-4 p-1 pb-2"
/>
{#if ratelimited}
<p class="text-error self-center">you are ratelimited, try again in 30 seconds</p>
{/if}
</div>
</form>
</div>
</Window>
</div>
<div class="grow" />
<Window title="entries">
<div class="flex flex-col gap-4 w-[60ch] max-w-[60ch]">
{#if data.ratelimitedFeat === 'get'}
<p class="text-error">
woops, looks like you are being ratelimited, try again in like half a minute :3
</p>
{:else if data.fetchError}
<p class="text-error">got error trying to fetch entries, pls tell me about this</p>
<details>
<summary>error</summary>
<p>{data.fetchError}</p>
</details>
{:else}
{#each data.entries as [entry_id, entry] (entry_id)}
{@const date = new Date(entry.timestamp * 1e3).toLocaleString()}
<div class="entry">
<div class="flex flex-row">
<p class="place-self-start grow text-2xl font-monospace">
#{entry_id}
</p>
<p class="justify-end self-center text-sm font-monospace">{date}</p>
</div>
<p class="text-lg ml-0.5">{entry.content}</p>
<p class="place-self-end text-sm font-monospace">--- posted by {entry.author}</p>
</div>
{:else}
<p>looks like there are no entries :(</p>
{/each}
{/if}
{#if hasPreviousPage || hasNextPage}
<div class="flex flex-row w-full justify-center items-center font-monospace">
{#if hasPreviousPage}
<a href="/guestbook/?page={data.entries.length < 0 ? data.page - 1 : 1}"
>&lt;&lt; previous</a
>
{/if}
{#if hasNextPage && hasPreviousPage}
<div class="w-1/12" />
{/if}
{#if hasNextPage}
<a href="/guestbook/?page={data.page + 1}">next &gt;&gt;</a>
{/if}
</div>
{/if}
</div>
</Window>
</div>
<style lang="postcss">
.entry {
@apply flex flex-col gap-3 py-2 px-3 bg-ralsei-green-dark/70 border-ralsei-green-light/30 border-x-[3px] border-y-4;
}
</style>

View File

@ -35,10 +35,14 @@
text-shadow: 0 0 3px theme(colors.ralsei.black), 0 0 6px theme(colors.ralsei.pink.neon), 0 0 10px #fff3;
}
li,p {
li,p,summary,.text-shadow-white {
text-shadow: 0 0 1px theme(colors.ralsei.black), 0 0 5px theme(colors.ralsei.white);
}
.text-shadow-red {
text-shadow: 0 0 1px theme(colors.ralsei.black), 0 0 5px theme(colors.red.600);
}
li::marker {
text-shadow: 0 0 4px theme(colors.ralsei.pink.regular), 0 0 6px #fff9;
}
@ -47,7 +51,7 @@
text-shadow: 0 0 4px theme(colors.ralsei.pink.regular);
}
a {
a,button,input[type=submit] {
text-shadow: 0 0 2px theme(colors.ralsei.black), 0 0 5px theme(colors.ralsei.green.light);
cursor: url('/icons/gaze.png'), pointer;
}
@ -82,6 +86,10 @@
}
@layer utilities {
.text-error {
@apply text-xl text-red-600 text-shadow-red;
}
.border-groove {
border-style: groove;
}

BIN
static/icons/guestbook.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

View File

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-static';
import adapter from 'svelte-adapter-bun';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { mdsvex } from 'mdsvex'

View File

@ -52,7 +52,8 @@ export default {
}
},
plugins: [
require('@tailwindcss/typography')
require('@tailwindcss/typography'),
require('@tailwindcss/forms'),
],
}