diff --git a/.gitignore b/.gitignore index 2228764..98b8382 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,10 @@ vite.config.ts.timestamp-* # nix /result + +# scala +target +.metals +.bloop +guestbook/entries +guestbook/entries_size \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 223cce8..cd64238 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,8 @@ }, "editor.quickSuggestions": { "strings": "on" + }, + "files.watcherExclude": { + "**/target": true } } \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index ac191c8..7ff8555 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/guestbook/.bsp/sbt.json b/guestbook/.bsp/sbt.json new file mode 100644 index 0000000..c61f66e --- /dev/null +++ b/guestbook/.bsp/sbt.json @@ -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"]} \ No newline at end of file diff --git a/guestbook/.scalafmt.conf b/guestbook/.scalafmt.conf new file mode 100644 index 0000000..8134e97 --- /dev/null +++ b/guestbook/.scalafmt.conf @@ -0,0 +1,2 @@ +version = "3.7.15" +runner.dialect = scala3 \ No newline at end of file diff --git a/guestbook/build.sbt b/guestbook/build.sbt new file mode 100644 index 0000000..283dc77 --- /dev/null +++ b/guestbook/build.sbt @@ -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) + } + ) diff --git a/guestbook/project/build.properties b/guestbook/project/build.properties new file mode 100644 index 0000000..ee4c672 --- /dev/null +++ b/guestbook/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.1 diff --git a/guestbook/project/metals.sbt b/guestbook/project/metals.sbt new file mode 100644 index 0000000..8d78f40 --- /dev/null +++ b/guestbook/project/metals.sbt @@ -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 diff --git a/guestbook/project/plugins.sbt b/guestbook/project/plugins.sbt new file mode 100644 index 0000000..e8b42c0 --- /dev/null +++ b/guestbook/project/plugins.sbt @@ -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") diff --git a/guestbook/project/project/metals.sbt b/guestbook/project/project/metals.sbt new file mode 100644 index 0000000..8d78f40 --- /dev/null +++ b/guestbook/project/project/metals.sbt @@ -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 diff --git a/guestbook/project/project/project/metals.sbt b/guestbook/project/project/project/metals.sbt new file mode 100644 index 0000000..8d78f40 --- /dev/null +++ b/guestbook/project/project/project/metals.sbt @@ -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 diff --git a/guestbook/src/main/resources/logback.xml b/guestbook/src/main/resources/logback.xml new file mode 100644 index 0000000..8ea8412 --- /dev/null +++ b/guestbook/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + true + + [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n + + + + + + diff --git a/guestbook/src/main/scala/systems/gaze/guestbook/Guestbook.scala b/guestbook/src/main/scala/systems/gaze/guestbook/Guestbook.scala new file mode 100644 index 0000000..214b7ca --- /dev/null +++ b/guestbook/src/main/scala/systems/gaze/guestbook/Guestbook.scala @@ -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] diff --git a/guestbook/src/main/scala/systems/gaze/guestbook/GuestbookRoutes.scala b/guestbook/src/main/scala/systems/gaze/guestbook/GuestbookRoutes.scala new file mode 100644 index 0000000..dc0c608 --- /dev/null +++ b/guestbook/src/main/scala/systems/gaze/guestbook/GuestbookRoutes.scala @@ -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) diff --git a/guestbook/src/main/scala/systems/gaze/guestbook/GuestbookServer.scala b/guestbook/src/main/scala/systems/gaze/guestbook/GuestbookServer.scala new file mode 100644 index 0000000..0a83a9a --- /dev/null +++ b/guestbook/src/main/scala/systems/gaze/guestbook/GuestbookServer.scala @@ -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 diff --git a/guestbook/src/main/scala/systems/gaze/guestbook/Main.scala b/guestbook/src/main/scala/systems/gaze/guestbook/Main.scala new file mode 100644 index 0000000..3ef23b7 --- /dev/null +++ b/guestbook/src/main/scala/systems/gaze/guestbook/Main.scala @@ -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) diff --git a/package.json b/package.json index 43f6519..f5eb99b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/fetchHack.mjs b/src/lib/fetchHack.mjs new file mode 100644 index 0000000..e8d25c2 --- /dev/null +++ b/src/lib/fetchHack.mjs @@ -0,0 +1,25 @@ +// hack/postSync.mjs +/** + ,* @file POST some stuff to a URL. + ,* Usage: one of + ,* echo input | node postSync.mjs + ,* node postSync.mjs + ,*/ + +// 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, +})); \ No newline at end of file diff --git a/src/lib/getTitle.ts b/src/lib/getTitle.ts index 76a8a14..f2c51a9 100644 --- a/src/lib/getTitle.ts +++ b/src/lib/getTitle.ts @@ -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('/') diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 1af81d3..d4d9acd 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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('/'); diff --git a/src/routes/guestbook/+page.server.ts b/src/routes/guestbook/+page.server.ts new file mode 100644 index 0000000..abb7b83 --- /dev/null +++ b/src/routes/guestbook/+page.server.ts @@ -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 +} \ No newline at end of file diff --git a/src/routes/guestbook/+page.svelte b/src/routes/guestbook/+page.svelte new file mode 100644 index 0000000..f024858 --- /dev/null +++ b/src/routes/guestbook/+page.svelte @@ -0,0 +1,106 @@ + + +
+
+ + {@const ratelimited = data.ratelimitedFeat === 'send'} +
+

hia, here is the guestbook if you wanna post anything :)

+

just be a good human bean pretty please

+
+
+
+

###

+

...

+
+