10 Commits

92 changed files with 990 additions and 4680 deletions

View File

@@ -1,9 +0,0 @@
AWS_ACCESS_KEY_ID=**************************
AWS_SECRET_ACCESS_KEY=****************************************************************
AWS_DEFAULT_REGION=******
AWS_ENDPOINT_URL=http://localhost:3900
DATABASE_URL=file:./libsql.db
S3_BUCKET="comicverse-pre-alpha"
# Keys should be encoded in base64url
PRIVATE_KEY=*******************************
PUBLIC_KEY=*******************************

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:opf="http://www.idpf.org/2007/opf" unique-identifier="unique-identifier" version="3.0">
<metadata>
<dc:identifier id="unique-identifier">2b982cb2-7144-4aa2-aa86-f9f6ba47fa0d</dc:identifier>
<dc:title>Unknown Title</dc:title>
<dc:creator>Unknown Author</dc:creator>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2025-07-31T15:14:16Z</meta>
<meta property="rendition:layout">pre-paginated</meta>
</metadata>
<manifest>
<item href="images/image0001.png" id="image0001" media-type="image/png"/>
<item href="images/image0002.png" id="image0002" media-type="image/png"/>
<item href="images/image0003.png" id="image0003" media-type="image/png"/>
<item href="images/image0004.png" id="image0004" media-type="image/png"/>
<item href="audios/audio0001.wav" id="audio0001" media-type="audio/wav"/>
<item href="styles/stylesheet.css" id="stylesheet.css" media-type="text/css"/>
<item href="scripts/ipub.js" id="ipub.js" media-type="application/javascript"/>
<item href="toc.ncx" id="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item href="toc.xhtml" id="toc.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item href="sections/section0001.xhtml" id="section0001" media-type="application/xhtml+xml"/>
</manifest>
<spine toc="toc.ncx">
<itemref idref="section0001"/>
</spine>
</package>

View File

@@ -1,201 +0,0 @@
"use strict";
/**
* @param {string} str
* @returns {string}
*/
function hashString(str) {
return Array.from(str).reduce(
(s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0,
0,
);
}
/**
* @class
* @implements {IPUBElementOnScreen}
*/
class IPUBBackground extends HTMLElement {
static elementName = "ipub-background";
static observedAttributes = ["sticky", "fade", "id"];
/**
* @private
*/
static #observer = (() => {
/** @type {Map<string, IPUBBackground>} */
const instancesOnScreen = new Map();
document.addEventListener("scroll", () => {
for (const [_, instance] of instancesOnScreen) {
const perc = getPercentageInView(
instance.querySelector("img") || instance,
);
instance.fade(perc);
}
});
return new IntersectionObserver((entries) =>
entries.forEach((e) => {
let instance = e.target.parentElement;
if (instance.tagName !== IPUBBackground.elementName) {
instance = instance.parentElement;
}
if (instance.tagName !== IPUBBackground.elementName) {
console.error(
"IPUBBackground: malformed <ipub-background> element",
e.target,
);
return;
}
if (e.intersectionRatio > 0) {
instancesOnScreen.set(instance.id, instance);
} else {
instancesOnScreen.delete(instance.id);
}
}),
);
})();
constructor() {
super();
}
connectedCallback() {
if (!this.id) {
console.warn(
`IPUB: ipub-background has not ID, assigning one based on innerHTML`,
this,
);
this.id = hashString(this.innerHTML);
}
console.debug(`IPUB: Added ipub-background#${this.id} to page`);
const image = this.querySelector("img");
if (this.hasAttribute("fade") && image) {
console.debug(`IPUB: Added ipub-background#${this.id} to observer`);
IPUBBackground.#observer.observe(image);
const perc = getPercentageInView(image);
if (perc > 0) {
this.fade(perc);
}
}
}
/**
* @param {number} perc
* @throws {Error}
* @returns {void | Promise<void>}
*/
fade(perc) {
console.debug(`${this.id} is ${perc} on screen`);
if (!this.style.getPropertyValue("--ipub-fade")) {
this.style.setProperty("--ipub-fade", `${perc}%`);
} else if (perc % 10 === 0) {
this.style.setProperty("--ipub-fade", `${perc}%`);
}
}
}
globalThis.addEventListener("load", () => {
console.log("IPUB SCRIPT LOADED");
customElements.define(IPUBBackground.elementName, IPUBBackground);
/** @type {Map<string, Element>} */
const onScreenMap = new Map();
const observer = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.intersectionRatio > 0) {
console.debug(
`IntersectionObserver: adding element #${e.target.id} to onScreenMap`,
);
onScreenMap.set(e.target.id, e.target);
} else {
console.debug(
`IntersectionObserver: removing element #${e.target.id} to onScreenMap`,
);
onScreenMap.delete(e.target.id);
}
});
});
for (const element of document.querySelectorAll(
`[data-ipub-trigger="on-screen"]`,
)) {
observer.observe(element);
}
document.addEventListener("scroll", async () => {
for (const [id, element] of onScreenMap) {
const perc = getPercentageInView(element);
console.debug(`Element #${id} is now ${perc}% on screen`);
const played = element.getAttribute("data-ipub-trigger-played") == "true";
if (perc >= 100 && !played) {
await playIpubElement(element);
element.setAttribute("data-ipub-trigger-played", "true");
}
}
});
});
/**
* @param {Element} element
*/
async function playIpubElement(element) {
switch (element.tagName) {
case "audio": {
/** @type {HTMLAudioElement} */
const audio = element;
await audio.play();
break;
}
default:
break;
}
}
/**
* @param {Element} element
* @returns {number}
*/
function getPercentageInView(element) {
const viewTop = globalThis.pageYOffset;
const viewBottom = viewTop + globalThis.innerHeight;
const rect = element.getBoundingClientRect();
const elementTop = rect.y + viewTop;
const elementBottom = rect.y + rect.height + viewTop;
if (viewTop > elementBottom || viewBottom < elementTop) {
return 0;
}
if (
(viewTop < elementTop && viewBottom > elementBottom) ||
(elementTop < viewTop && elementBottom > viewBottom)
) {
return 100;
}
let inView = rect.height;
if (elementTop < viewTop) {
inView = rect.height - (globalThis.pageYOffset - elementTop);
}
if (elementBottom > viewBottom) {
inView = inView - (elementBottom - viewBottom);
}
return Math.round((inView / globalThis.innerHeight) * 100);
}

View File

@@ -1,105 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="x-ipub-version" content="0.1" />
<link href="../styles/stylesheet.css" rel="stylesheet" type="text/css" />
<!-- <script type="module" src="../scripts/ipub.js" fetchpriority="high"></script> -->
<script defer="true" src="../scripts/ipub.js" fetchpriority="high">
<!---->
</script>
</head>
<body xmlns:epub="http://www.idpf.org/2007/ops" class="body">
<main data-ipub-element="content">
<ipub-background id="background0001" sticky="">
<img src="../images/background0001.jpg" width="100" height="100" />
</ipub-background>
<section data-ipub-element="page" id="page01">
<span data-ipub-element="image">
<img src="../images/image0001.png" />
</span>
<!--
This in the UI would be an "Point Interaction" or just "Interaction". The
editor can just place it on some point the page, and adjust it's size.
The action is "open link", this action should have a warning to the reader,
to make sure they don't open malicious links.
-->
<!--
The "rel" will have "nofollow", "noopener" and "noreferrer" when the link
is to a domain different from the project's one.
-->
<a data-ipub-element="interaction" data-ipub-variant="point"
style="--ipub-x:6%;--ipub-y:88.5%;--ipub-width:10%;--ipub-radius:100%;--ipub-origin-offset-x:-50%;--ipub-origin-offset-y:-50%;--ipub-ratio:1/1;"
id="int-httpsguzone" href="https://krita.org" target="_blank" referrerpolicy="same-origin"
rel="external nofollow noopener noreferrer">
<!--
This would be generated if the editor doesn't specify a accessibility text,
the in quotations text would be fetched from the site's title when the link is created
if possible.
-->
Go to "Krita | Digital Paiting. Creative Freedom"</a>
<!--
This in the UI would be an "Area Interaction". The editor would first place
the first top-left point, and then the bottom-right one, to select an area/size
of the interaction.
The action is "go to page".
-->
<a data-ipub-element="interaction" data-ipub-variant="area"
style="--ipub-x:76%;--ipub-y:90%;--ipub-width:11.5%;--ipub-height:8%;" id="int-httpsguzone"
href="section0001.xhtml#page03">
<!--
This would be generated if the editor doesn't specify a accessibility text.
The in quotations text would be the title of the page if it has one, otherwise
it's ID is used (RFC, we could just place the text as "Go to page", since the IDs.
may not be human-readable).
-->
Go to page "page03"</a>
<!--
TODO: Analyse if area and point interactions should be saved as the same type of element
and if the "data-ipub-variant" should be a thing. This pretty much depends on how much
we want the editor to "guess" what controls to provide the user with.
-->
</section>
<section data-ipub-element="page" id="page02">
<span data-ipub-element="image">
<img src="../images/image0002.png" />
</span>
<!--
This in the UI would be an "Area Interaction". The editor would first place
the first top-left point, and then the bottom-right one, to select an area/size
of the interaction.
The element wound not have a "action" per say, but would have a "on screen" trigger,
which in itself would have the action "play sound".
-->
<audio data-ipub-element="interaction" data-ipub-trigger="on-screen" controls="true"
volume="0" style="--ipub-x:20%;--ipub-y:25%;--ipub-width:50%;--ipub-height:50%;"
id="int-audio0001">
<source src="../audios/audio0001.wav.disable" />
</audio>
</section>
<ipub-background sticky="" fade="" id="background0002">
<picture>
<img src="../images/background0002.jpg" />
</picture>
</ipub-background>
<section data-ipub-element="page" id="page03">
<span data-ipub-element="image">
<img src="../images/image0003.png" />
</span>
</section>
<section data-ipub-element="page" id="page04">
<span data-ipub-element="image">
<img src="../images/image0004.png" />
</span>
</section>
<section data-ipub-element="page" id="page02">
<span data-ipub-element="image">
<img src="../images/image0002.png" />
</span>
</section>
</main>
</body>
</html>

View File

@@ -1,127 +0,0 @@
.body {
-epub-writing-mode: horizontal-tb;
-webkit-writing-mode: horizontal-tb;
direction: ltr;
/* direction: rtl; */
writing-mode: horizontal-tb;
position: relative;
margin: 0;
max-width: 100vw;
}
[data-ipub-element="content"] {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
}
[data-ipub-element="content"] > [data-ipub-element="page"] {
margin: 5% 10%;
}
ipub-background[sticky],
[data-ipub-element="sticky-background"] {
top: 0;
left: 0;
width: 0;
height: 0;
position: sticky;
align-self: start;
}
ipub-background {
--ipub-width: 100vw;
--ipub-height: 100vh;
&[sticky] {
top: 0;
left: 0;
width: 0;
height: 0;
position: sticky;
align-self: start;
}
&[fade] img {
/* For testing */
/* background-image: linear-gradient( */
/* rgba(266, 0, 0, 1) 0%, */
/* rgba(0, 266, 0, 1) calc(100% + calc(var(--ipub-fade, 100%) * -1)), */
/* rgba(266, 0, 266, 1) 100% */
/* ) !important; */
--mask: linear-gradient(
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) calc(100% + calc(var(--ipub-fade, 100%) * -1))
) !important;
/* background-image: var(--mask); */
mask-image: var(--mask);
-webkit-mask-image: var(--mask);
}
& > picture {
display: block;
width: var(--ipub-width);
height: var(--ipub-height);
& > img {
object-fit: cover;
width: 100%;
height: 100%;
}
}
/* Support standalone img element */
& > img {
display: block;
object-fit: cover;
width: var(--ipub-width);
height: var(--ipub-height);
}
}
position: relative;
}
[data-ipub-element="image"] {
width: var(--ipub-width, unset);
height: var(--ipub-height, unset);
background-image: var(--ipub-image, unset);
background-repeat: no-repeat;
background-size: cover;
display: block;
img {
max-width: 100%;
max-height: 100%;
}
}
[data-ipub-element="interaction"] {
position: absolute;
left: var(--ipub-x, 0%);
top: var(--ipub-y, 0%);
border-radius: var(--ipub-radius, unset);
width: var(--ipub-width, unset);
height: var(--ipub-height, unset);
transform: translate(
var(--ipub-origin-offset-x, 0%),
var(--ipub-origin-offset-y, 0%)
);
aspect-ratio: var(--ipub-ratio, unset);
/*
* The opacity would be, by default, zero. Here it is 0.3 for easier debugging and
* showing of the example ebook
*/
background-color: red;
opacity: 0.3;
}
a[data-ipub-element="interaction"] {
/* The text inside the interaction anchor are for accessibility purposes */
font-size: 0px;
}
}

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta content="" name="" scheme=""/>
</head>
<docTitle>
<text/>
</docTitle>
<navMap>
<navPoint class="document" id="section1" playOrder="1">
<navLabel>
<text>Section 1</text>
</navLabel>
<content src="sections/section0001.xhtml"/>
</navPoint>
</navMap>
</ncx>

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head/>
<body>
<nav epub:type="toc">
<ol>
<li>
<a href="sections/section0001.xhtml">Section 1</a>
</li>
</ol>
</nav>
</body>
</html>

View File

@@ -1 +0,0 @@
application/epub+zip

5
.example.env Normal file
View File

@@ -0,0 +1,5 @@
AWS_ACCESS_KEY_ID=**************************
AWS_SECRET_ACCESS_KEY=****************************************************************
AWS_DEFAULT_REGION=******
AWS_ENDPOINT_URL=localhost:3000
S3_DEFAULT_BUCKET=comicverse-test-bucket

View File

@@ -1,17 +0,0 @@
# name: TODO tracker
# on: [push, pull_request]
# jobs:
# build:
# runs-on: ubuntu-latest
# steps:
# - uses: https://forge.capytal.company/actions/checkout@v3
# - uses: https://forge.capytal.company/actions/tdg-forgejo-action@master
# with:
# TOKEN: ${{ secrets.FORGEJO_TOKEN }}
# REPO: ${{ github.repository }}
# SHA: ${{ github.sha }}
# REF: ${{ github.ref }}
# LABEL: status/todo
# DRY_RUN: true
# COMMENT_ON_ISSUES: 128
# ASSIGN_FROM_BLAME: 128

28
.gitignore vendored
View File

@@ -1,7 +1,23 @@
.dist
out.css
.tmp
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
*.db
*.epub
tmp
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
data.db

6
.gitmodules vendored
View File

@@ -1,6 +0,0 @@
[submodule "x"]
path = x
url = https://code.capytal.cc:loreddev/x
[submodule "smalltrip"]
path = smalltrip
url = https://code.capytal.cc/loreddev/smalltrip

View File

@@ -1,20 +0,0 @@
run:
timeout: 5m
modules-download-mode: readonly
linters:
disable-all: true
enable:
- errcheck
- goimports
- gofumpt
- revive # golint
- gosimple
- govet
- ineffassign
- staticcheck
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

20
.vscode/launch.json vendored
View File

@@ -1,20 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch APP",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/cmd.go"
},
{
"name": "Launch APP (Dev)",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/cmd.go",
"args": ["-dev", "-port", "8080", "-hostname", "0.0.0.0"]
}
]
}

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

View File

@@ -1,22 +0,0 @@
package assets
import (
"embed"
"io/fs"
)
//go:embed stylesheets/out.css
var files embed.FS
func Files(local ...bool) fs.FS {
var l bool
if len(local) > 0 {
l = local[0]
}
if !l {
return files
}
return files
}

View File

@@ -1 +0,0 @@
@import "tailwindcss";

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -1,207 +0,0 @@
package main
import (
"context"
"crypto/ed25519"
"database/sql"
"encoding/base64"
"errors"
"flag"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
comicverse "code.capytal.cc/capytal/comicverse"
"code.capytal.cc/capytal/comicverse/templates"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
_ "github.com/tursodatabase/go-libsql"
)
var (
hostname = flag.String("hostname", "localhost", "Host to listen to")
port = flag.Uint("port", 8080, "Port to be used for the server.")
templatesDir = flag.String("templates", "", "Templates directory to be used instead of built-in ones.")
verbose = flag.Bool("verbose", false, "Print debug information on logs")
dev = flag.Bool("dev", false, "Run the server in debug mode.")
)
var (
databaseURL = getEnv("DATABASE_URL", "file:./libsql.db")
awsAccessKeyID = os.Getenv("AWS_ACCESS_KEY_ID")
awsSecretAccessKey = os.Getenv("AWS_SECRET_ACCESS_KEY")
awsDefaultRegion = os.Getenv("AWS_DEFAULT_REGION")
awsEndpointURL = os.Getenv("AWS_ENDPOINT_URL")
s3Bucket = os.Getenv("S3_BUCKET")
privateKeyEnv = os.Getenv("PRIVATE_KEY")
publicKeyEnv = os.Getenv("PUBLIC_KEY")
)
func getEnv(key string, d string) string {
v := os.Getenv(key)
if v == "" {
return d
}
return v
}
func init() {
flag.Parse()
switch {
case databaseURL == "":
log.Fatal("DATABASE_URL should not be a empty value")
case awsAccessKeyID == "":
log.Fatal("AWS_ACCESS_KEY_ID should not be a empty value")
case awsDefaultRegion == "":
log.Fatal("AWS_DEFAULT_REGION should not be a empty value")
case awsEndpointURL == "":
log.Fatal("AWS_ENDPOINT_URL should not be a empty value")
case s3Bucket == "":
log.Fatal("S3_BUCKET should not be a empty value")
case privateKeyEnv == "":
log.Fatal("PRIVATE_KEY not be a empty value")
case publicKeyEnv == "":
log.Fatal("PUBLIC_KEY not be a empty value")
}
}
func main() {
ctx := context.Background()
level := slog.LevelError
if *dev {
level = slog.LevelDebug
} else if *verbose {
level = slog.LevelInfo
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
assertions := tinyssert.NewDisabled()
if *dev {
assertions = tinyssert.New(
tinyssert.WithPanic(),
tinyssert.WithLogger(log),
)
}
db, err := sql.Open("libsql", databaseURL)
if err != nil {
log.Error("Failed open connection to database", slog.String("error", err.Error()))
os.Exit(1)
}
credentials := aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {
return aws.Credentials{
AccessKeyID: awsAccessKeyID,
SecretAccessKey: awsSecretAccessKey,
CanExpire: false,
}, nil
})
storage := s3.New(s3.Options{
AppID: "comicverse-pre-alpha",
BaseEndpoint: &awsEndpointURL,
Region: awsDefaultRegion,
Credentials: &credentials,
})
opts := []comicverse.Option{
comicverse.WithContext(ctx),
comicverse.WithAssertions(assertions),
comicverse.WithLogger(log),
}
if *dev {
d := os.DirFS("./assets")
opts = append(opts, comicverse.WithAssets(d))
t := templates.NewHotTemplates(os.DirFS("./templates"))
opts = append(opts, comicverse.WithTemplates(t))
opts = append(opts, comicverse.WithDevelopmentMode())
}
// TODO: Move this to dedicated function
privateKeyStr, err := base64.URLEncoding.DecodeString(privateKeyEnv)
if err != nil {
log.Error("Failed to decode PRIVATE_KEY from base64", slog.String("error", err.Error()))
os.Exit(1)
}
publicKeyStr, err := base64.URLEncoding.DecodeString(publicKeyEnv)
if err != nil {
log.Error("Failed to decode PUBLIC_KEY from base64", slog.String("error", err.Error()))
os.Exit(1)
}
edPrivKey := ed25519.PrivateKey(privateKeyStr)
edPubKey := ed25519.PublicKey(publicKeyStr)
if len(edPrivKey) != ed25519.PrivateKeySize {
log.Error("PRIVATE_KEY is not of valid size", slog.Int("size", len(edPrivKey)))
os.Exit(1)
}
if len(edPubKey) != ed25519.PublicKeySize {
log.Error("PUBLIC_KEY is not of valid size", slog.Int("size", len(edPubKey)))
os.Exit(1)
}
if !edPubKey.Equal(edPrivKey.Public()) {
log.Error("PUBLIC_KEY is not equal from extracted public key",
slog.String("extracted", fmt.Sprintf("%x", edPrivKey.Public())),
slog.String("key", fmt.Sprintf("%x", edPubKey)),
)
os.Exit(1)
}
app, err := comicverse.New(comicverse.Config{
DB: db,
S3: storage,
PrivateKey: edPrivKey,
PublicKey: edPubKey,
Bucket: s3Bucket,
}, opts...)
if err != nil {
log.Error("Failed to initiate comicverse app", slog.String("error", err.Error()))
os.Exit(1)
}
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", *hostname, *port),
Handler: app,
}
c, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Info("Starting application",
slog.String("host", *hostname),
slog.Uint64("port", uint64(*port)),
slog.Bool("verbose", *verbose),
slog.Bool("development", *dev))
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("Failed to start application server", slog.String("error", err.Error()))
os.Exit(1)
}
}()
<-c.Done()
log.Info("Stopping application gracefully")
if err := srv.Shutdown(ctx); err != nil {
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
os.Exit(1)
}
log.Info("FINAL")
os.Exit(0)
}

View File

@@ -1,196 +0,0 @@
package comicverse
import (
"context"
"crypto/ed25519"
"database/sql"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"code.capytal.cc/capytal/comicverse/assets"
"code.capytal.cc/capytal/comicverse/internals/joinedfs"
"code.capytal.cc/capytal/comicverse/repository"
"code.capytal.cc/capytal/comicverse/router"
"code.capytal.cc/capytal/comicverse/service"
"code.capytal.cc/capytal/comicverse/templates"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func New(cfg Config, opts ...Option) (http.Handler, error) {
app := &app{
db: cfg.DB,
s3: cfg.S3,
bucket: cfg.Bucket,
privateKey: cfg.PrivateKey,
publicKey: cfg.PublicKey,
assets: assets.Files(),
templates: templates.Templates(),
developmentMode: false,
ctx: context.Background(),
assert: tinyssert.New(),
logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})),
}
for _, opt := range opts {
opt(app)
}
if app.db == nil {
return nil, errors.New("database interface must not be nil")
}
if app.s3 == nil {
return nil, errors.New("s3 client must not be nil")
}
if app.privateKey == nil || len(app.privateKey) == 0 {
return nil, errors.New("private key client must not be nil")
}
if app.publicKey == nil || len(app.publicKey) == 0 {
return nil, errors.New("public key client must not be nil")
}
if app.bucket == "" {
return nil, errors.New("bucket must not be a empty string")
}
if app.assets == nil {
return nil, errors.New("static files must not be a nil interface")
}
if app.templates == nil {
return nil, errors.New("templates must not be a nil interface")
}
if app.ctx == nil {
return nil, errors.New("context must not be a nil interface")
}
if app.logger == nil {
return nil, errors.New("logger must not be a nil interface")
}
if app.assert == nil {
return nil, errors.New("assertions must not be a nil interface")
}
return app, app.setup()
}
type Config struct {
DB *sql.DB
S3 *s3.Client
Bucket string
PrivateKey ed25519.PrivateKey // TODO: Put this inside a service so we can easily rotate keys
PublicKey ed25519.PublicKey
}
type Option func(*app)
func WithContext(ctx context.Context) Option {
return func(app *app) { app.ctx = ctx }
}
func WithAssets(f fs.FS) Option {
return func(app *app) { app.assets = joinedfs.Join(f, app.assets) }
}
func WithTemplates(t templates.ITemplate) Option {
return func(app *app) { app.templates = t }
}
func WithAssertions(a tinyssert.Assertions) Option {
return func(app *app) { app.assert = a }
}
func WithLogger(l *slog.Logger) Option {
return func(app *app) { app.logger = l }
}
func WithDevelopmentMode() Option {
return func(app *app) { app.developmentMode = true }
}
type app struct {
db *sql.DB
s3 *s3.Client
bucket string
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
ctx context.Context
assets fs.FS
templates templates.ITemplate
developmentMode bool
handler http.Handler
assert tinyssert.Assertions
logger *slog.Logger
}
func (app *app) setup() error {
app.assert.NotNil(app.db)
app.assert.NotNil(app.s3)
app.assert.NotZero(app.bucket)
app.assert.NotNil(app.ctx)
app.assert.NotNil(app.assets)
app.assert.NotNil(app.logger)
userRepository, err := repository.NewUser(app.ctx, app.db, app.logger.WithGroup("repository.user"), app.assert)
if err != nil {
return fmt.Errorf("app: failed to start user repository: %w", err)
}
tokenRepository, err := repository.NewToken(app.ctx, app.db, app.logger.WithGroup("repository.token"), app.assert)
if err != nil {
return fmt.Errorf("app: failed to start token repository: %w", err)
}
projectRepository, err := repository.NewProject(app.ctx, app.db, app.logger.WithGroup("repository.project"), app.assert)
if err != nil {
return fmt.Errorf("app: failed to start project repository: %w", err)
}
permissionRepository, err := repository.NewPermissions(app.ctx, app.db, app.logger.WithGroup("repository.permission"), app.assert)
if err != nil {
return fmt.Errorf("app: failed to start permission repository: %w", err)
}
userService := service.NewUser(userRepository, app.logger.WithGroup("service.user"), app.assert)
tokenService := service.NewToken(service.TokenConfig{
PrivateKey: app.privateKey,
PublicKey: app.publicKey,
Repository: tokenRepository,
Logger: app.logger.WithGroup("service.token"),
Assertions: app.assert,
})
projectService := service.NewProject(projectRepository, permissionRepository, app.logger.WithGroup("service.project"), app.assert)
app.handler, err = router.New(router.Config{
UserService: userService,
TokenService: tokenService,
ProjectService: projectService,
Templates: app.templates,
DisableCache: app.developmentMode,
Assets: app.assets,
Assertions: app.assert,
Logger: app.logger.WithGroup("router"),
})
if err != nil {
return errors.Join(errors.New("unable to initiate router"), err)
}
return err
}
func (app *app) ServeHTTP(w http.ResponseWriter, r *http.Request) {
app.assert.NotNil(app.handler)
app.handler.ServeHTTP(w, r)
}

33
eslint.config.js Normal file
View File

@@ -0,0 +1,33 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
];

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1742069588,
"narHash": "sha256-C7jVfohcGzdZRF6DO+ybyG/sqpo1h6bZi9T56sxLy+k=",
"lastModified": 1726243404,
"narHash": "sha256-sjiGsMh+1cWXb53Tecsm4skyFNag33GPbVgCdfj3n9I=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c80f6a7e10b39afcc1894e02ef785b1ad0b0d7e5",
"rev": "345c263f2f53a3710abe117f28a5cb86d0ba4059",
"type": "github"
},
"original": {

View File

@@ -3,7 +3,10 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {nixpkgs, ...}: let
outputs = {
self,
nixpkgs,
}: let
systems = [
"x86_64-linux"
"aarch64-linux"
@@ -18,41 +21,12 @@
in {
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
CGO_ENABLED = "1";
hardeningDisable = ["fortify"];
GOPRIVATE = "code.capytal.cc/*";
shellHook = ''
set -a
source .env
set +a
'';
buildInputs = with pkgs; [
# Go tools
go
golangci-lint
gofumpt
gotools
delve
# TailwindCSS
tailwindcss_4
# Sqlite tools
sqlite
lazysql
litecli
# S3
awscli
# ePUB
http-server
calibre
zip
unzip
awscli2
bun
eslint
nodejs_22
nodePackages_latest.prettier
];
};
});

29
go.mod
View File

@@ -1,29 +0,0 @@
module code.capytal.cc/capytal/comicverse
go 1.24.8
require (
code.capytal.cc/loreddev/smalltrip v0.0.0-20251013182121-3d201d21226c
code.capytal.cc/loreddev/x v0.0.0-20251013175605-6ea200aa6442
github.com/aws/aws-sdk-go-v2 v1.36.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92
golang.org/x/crypto v0.38.0
)
require (
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
)

48
go.sum
View File

@@ -1,48 +0,0 @@
code.capytal.cc/loreddev/smalltrip v0.0.0-20251013182121-3d201d21226c h1:Ith3zqoEl0o8mCFdzBemk/8YgVfEaNPYFsbpu/hssAE=
code.capytal.cc/loreddev/smalltrip v0.0.0-20251013182121-3d201d21226c/go.mod h1:CjzhmbQIf4PlnsCF5gK/5e4qDP7JeT+7CcVvbx+DtUg=
code.capytal.cc/loreddev/x v0.0.0-20251013175605-6ea200aa6442 h1:YyfSJhrDz9PLf5snD5gV+T8dvBmDlXFkT8tx8p5l6K4=
code.capytal.cc/loreddev/x v0.0.0-20251013175605-6ea200aa6442/go.mod h1:o9HsngwSWEAETuvFoOqlKj431Ri3cOL0g8Li2M49DAo=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1 h1:1M0gSbyP6q06gl3384wpoKPaH9G16NPqZFieEhLboSU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92 h1:IYI1S1xt4WdQHjgVYzMa+Owot82BqlZfQV05BLnTcTA=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

View File

@@ -1,7 +0,0 @@
go 1.24.8
use (
./.
./smalltrip
./x
)

View File

@@ -1,47 +0,0 @@
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1 h1:1M0gSbyP6q06gl3384wpoKPaH9G16NPqZFieEhLboSU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92 h1:IYI1S1xt4WdQHjgVYzMa+Owot82BqlZfQV05BLnTcTA=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

View File

@@ -1,25 +0,0 @@
package joinedfs
import "io/fs"
func Join(fsys ...fs.FS) fs.FS {
return &joinedFS{fsys}
}
type joinedFS struct {
fsys []fs.FS
}
var _ fs.FS = (*joinedFS)(nil)
func (j *joinedFS) Open(name string) (fs.File, error) {
var err error
var f fs.File
for _, fsys := range j.fsys {
f, err = fsys.Open(name)
if err == nil {
return f, nil
}
}
return f, err
}

View File

@@ -1,75 +0,0 @@
// This file has code copied from the "randstr" Go module, which can be found at
// https://github.com/thanhpk/randsr. The original code is licensed under the MIT
// license, which a copy can be found at https://github.com/thanhpk/randstr/blob/master/LICENSE
// and is provided below:
//
// # The MIT License
//
// Copyright (c) 2010-2018 Google, Inc. http://angularjs.org
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// Package randstr provides basic functions for generating random bytes, string
package randstr
import (
"bytes"
"crypto/rand"
"encoding/binary"
)
// HexChars holds a string containing all characters used in a hexadecimal value.
const HexChars = "0123456789abcdef"
// NewHex generates a new Hexadecimal string with length of n
//
// Example: 67aab2d956bd7cc621af22cfb169cba8
func NewHex(n int) (string, error) { return New(n, HexChars) }
// New generates a random string using only letters provided in the letters parameter.
//
// If the letters parameter is omitted, this function will use HexChars instead.
func New(n int, chars ...string) (string, error) {
runes := []rune(HexChars)
if len(chars) > 0 {
runes = []rune(chars[0])
}
var b bytes.Buffer
b.Grow(n)
l := uint32(len(runes))
for range n {
by, err := Bytes(4)
if err != nil {
return "", err
}
b.WriteRune(runes[binary.BigEndian.Uint32(by)%l])
}
return b.String(), nil
}
// Bytes generates n random bytes
func Bytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return []byte{}, err
}
return b, nil
}

View File

@@ -1,195 +0,0 @@
package ast
import (
"fmt"
)
type Node interface {
Kind() NodeKind
NextSibling() Node
SetNextSibling(Node)
PreviousSibling() Node
SetPreviousSibling(Node)
Parent() Node
SetParent(Node)
HasChildren() bool
ChildCount() uint
FirstChild() Node
LastChild() Node
AppendChild(self, v Node)
RemoveChild(self, v Node)
RemoveChildren(self Node)
ReplaceChild(self, v1, insertee Node)
InsertBefore(self, v1, insertee Node)
InsertAfter(self, v1, insertee Node)
}
type BaseNode struct {
next Node
prev Node
parent Node
fisrtChild Node
lastChild Node
childCount uint
}
func (e *BaseNode) NextSibling() Node {
return e.next
}
func (e *BaseNode) SetNextSibling(v Node) {
e.next = v
}
func (e *BaseNode) PreviousSibling() Node {
return e.prev
}
func (e *BaseNode) SetPreviousSibling(v Node) {
e.prev = v
}
func (e *BaseNode) Parent() Node {
return e.parent
}
func (e *BaseNode) SetParent(v Node) {
e.parent = v
}
func (e *BaseNode) HasChildren() bool {
return e.fisrtChild != nil
}
func (e *BaseNode) ChildCount() uint {
return e.childCount
}
func (e *BaseNode) FirstChild() Node {
return e.fisrtChild
}
func (e *BaseNode) LastChild() Node {
return e.lastChild
}
func (e *BaseNode) AppendChild(self, v Node) {
ensureIsolated(v)
if e.fisrtChild == nil {
e.fisrtChild = v
v.SetNextSibling(nil)
v.SetPreviousSibling(nil)
} else {
l := e.lastChild
l.SetNextSibling(v)
v.SetPreviousSibling(l)
}
v.SetParent(self)
e.lastChild = v
e.childCount++
}
func (e *BaseNode) RemoveChild(self, v Node) {
if v.Parent() != self {
return
}
if e.childCount <= 0 {
e.childCount--
}
prev := v.PreviousSibling()
next := v.NextSibling()
if prev != nil {
prev.SetNextSibling(next)
} else {
e.fisrtChild = next
}
if next != nil {
next.SetNextSibling(prev)
} else {
e.lastChild = prev
}
v.SetParent(nil)
v.SetNextSibling(nil)
v.SetPreviousSibling(nil)
}
func (e *BaseNode) RemoveChildren(_ Node) {
for c := e.fisrtChild; c != nil; {
c.SetParent(nil)
c.SetPreviousSibling(nil)
next := c.NextSibling()
c.SetNextSibling(nil)
c = next
}
e.fisrtChild = nil
e.lastChild = nil
e.childCount = 0
}
func (e *BaseNode) ReplaceChild(self, v1, insertee Node) {
e.InsertBefore(self, v1, insertee)
e.RemoveChild(self, v1)
}
func (e *BaseNode) InsertAfter(self, v1, insertee Node) {
e.InsertBefore(self, v1.NextSibling(), insertee)
}
func (e *BaseNode) InsertBefore(self, v1, insertee Node) {
e.childCount++
if v1 == nil {
e.AppendChild(self, insertee)
return
}
ensureIsolated(insertee)
if v1.Parent() == self {
c := v1
prev := c.PreviousSibling()
if prev != nil {
prev.SetNextSibling(insertee)
insertee.SetPreviousSibling(prev)
} else {
e.fisrtChild = insertee
insertee.SetPreviousSibling(nil)
}
insertee.SetNextSibling(c)
c.SetPreviousSibling(insertee)
insertee.SetParent(self)
}
}
func ensureIsolated(e Node) {
if p := e.Parent(); p != nil {
p.RemoveChild(p, e)
}
}
type NodeKind string
func NewNodeKind(kind string, e Node) NodeKind {
k := NodeKind(kind)
if _, ok := elementKindList[k]; ok {
panic(fmt.Sprintf("Node kind %q is already registered", k))
}
elementKindList[k] = e
return k
}
var elementKindList = make(map[NodeKind]Node)

View File

@@ -1,77 +0,0 @@
package ast_test
import (
_ "embed"
"encoding/xml"
"io"
"testing"
"code.capytal.cc/capytal/comicverse/ipub/ast"
"code.capytal.cc/loreddev/x/tinyssert"
)
//go:embed test.xml
var test []byte
func TestMarshal(t *testing.T) {
b := &ast.Body{}
c := &ast.Content{}
i := &ast.Image{}
i.SetSource("https://hello.com/world.png")
c.AppendChild(c, i)
b.AppendChild(b, c)
s := ast.Section{
Body: b,
}
by, err := xml.Marshal(s)
if err != nil && err != io.EOF {
t.Error(err.Error())
t.FailNow()
}
// t.Logf("%#v", s.Body)
//
// t.Logf("%#v", f)
t.Logf("%#v", string(by))
}
func TestUnmarshal(t *testing.T) {
assert := tinyssert.New(tinyssert.WithTest(t), tinyssert.WithPanic())
s := []byte(`
<html>
<body data-ipub-element="body">
<section data-ipub-element="content">
<img data-ipub-element="image" src="https://hello.com/world.png"/>
</section>
</body>
</html>
`)
var data ast.Section
err := xml.Unmarshal(s, &data)
if err != nil && err != io.EOF {
t.Error(err.Error())
t.FailNow()
}
body := data.Body
assert.Equal(ast.KindBody, body.Kind())
t.Logf("%#v", body)
content := body.FirstChild()
assert.Equal(ast.KindContent, content.Kind())
t.Logf("%#v", content)
img := content.FirstChild().(*ast.Image)
assert.Equal(ast.KindImage, img.Kind())
assert.Equal("https://hello.com/world.png", img.Source())
t.Logf("%#v", img)
}

View File

@@ -1,31 +0,0 @@
package ast
type Content struct {
BaseNode
}
var KindContent = NewNodeKind("content", &Content{})
func (e Content) Kind() NodeKind {
return KindContent
}
type Image struct {
src string
BaseNode
}
var KindImage = NewNodeKind("image", &Image{})
func (e *Image) Kind() NodeKind {
return KindImage
}
func (e Image) Source() string {
return e.src
}
func (e *Image) SetSource(src string) {
e.src = src
}

View File

@@ -1,11 +0,0 @@
package ast
type Package struct {
BaseNode
}
var KindPackage = NewNodeKind("package", &Package{})
func (e Package) Kind() NodeKind {
return KindPackage
}

View File

@@ -1,20 +0,0 @@
package ast
import (
"encoding/xml"
)
type Section struct {
XMLName xml.Name `xml:"html"`
Body *Body `xml:"body"`
}
type Body struct {
BaseNode
}
var KindBody = NewNodeKind("body", &Body{})
func (e Body) Kind() NodeKind {
return KindBody
}

View File

@@ -1,29 +0,0 @@
package attr
import (
"encoding/xml"
"fmt"
)
type Attribute interface {
xml.MarshalerAttr
xml.UnmarshalerAttr
fmt.Stringer
}
type BaseAttribute string
func (a BaseAttribute) MarshalXMLAttr(n xml.Name) (xml.Attr, error) {
return xml.Attr{Name: n, Value: a.String()}, nil
}
func (a *BaseAttribute) UnmarshalXMLAttr(attr xml.Attr) error {
*a = BaseAttribute(attr.Value)
return nil
}
func (a BaseAttribute) String() string {
return string(a)
}

View File

@@ -1,36 +0,0 @@
package attr
import (
"encoding/xml"
"fmt"
)
type ErrInvalidName struct {
Actual xml.Name
Expected xml.Name
}
var _ error = ErrInvalidName{}
func (err ErrInvalidName) Error() string {
return fmt.Sprintf("attribute %q has invalid name, expected %q", FmtXMLName(err.Actual), FmtXMLName(err.Expected))
}
type ErrInvalidValue struct {
Attr xml.Attr
Message string
}
var _ error = ErrInvalidValue{}
func (err ErrInvalidValue) Error() string {
return fmt.Sprintf("attribute %q's value %q is invalid: %s", FmtXMLName(err.Attr.Name), err.Attr.Value, err.Message)
}
func FmtXMLName(n xml.Name) string {
s := n.Local
if n.Space != "" {
s = fmt.Sprintf("%s:%s", n.Space, n.Local)
}
return s
}

View File

@@ -1,3 +0,0 @@
package attr
type DataElement = BaseAttribute

View File

@@ -1,112 +0,0 @@
package element
import (
"encoding/xml"
"errors"
"fmt"
"io"
"reflect"
"slices"
"strings"
"code.capytal.cc/capytal/comicverse/ipub/element/attr"
)
type Element interface {
Kind() ElementKind
}
type ElementChildren []Element
func (ec *ElementChildren) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
elErr := fmt.Errorf("unable to unsmarshal element %q", attr.FmtXMLName(start.Name))
i := slices.IndexFunc(start.Attr, func(a xml.Attr) bool {
return a.Name == elementKindAttrName
})
if i == -1 {
return errors.Join(elErr, fmt.Errorf("element kind not specified"))
}
var k ElementKind
if err := k.UnmarshalXMLAttr(start.Attr[i]); err != nil {
return err
}
ks := elementKindList[k]
// Get a pointer of a new instance of the underlying implementation so we can
// change it without manipulating the value inside the elementKindList.
ep := reflect.New(reflect.TypeOf(ks))
if ep.Elem().Kind() == reflect.Pointer {
// If the implementation is a pointer, we need the underlying value so we can
// manipulate it.
ep = reflect.New(reflect.TypeOf(ks).Elem())
}
if err := d.DecodeElement(ep.Interface(), &start); err != nil && err != io.EOF {
return errors.Join(elErr, err)
}
if ec == nil {
c := ElementChildren{}
ec = &c
}
s := *ec
s = append(s, ep.Interface().(Element))
*ec = s
return nil
}
type ElementKind string
// NewElementKind registers a new Element implementation to a private list which is
// consumed bu [ElementChildren] to properly find what underlying type is a children
// of another element struct.
func NewElementKind(n string, s Element) ElementKind {
k := ElementKind(n)
if _, ok := elementKindList[k]; ok {
panic(fmt.Sprintf("element kind %q already registered", n))
}
elementKindList[k] = s
return k
}
func (k ElementKind) MarshalXMLAttr(n xml.Name) (xml.Attr, error) {
if n != elementKindAttrName {
return xml.Attr{}, attr.ErrInvalidName{Actual: n, Expected: elementKindAttrName}
}
return xml.Attr{Name: elementKindAttrName, Value: k.String()}, nil
}
func (k *ElementKind) UnmarshalXMLAttr(a xml.Attr) error {
ak := ElementKind(a.Value)
if _, ok := elementKindList[ak]; !ok {
v := make([]string, 0, len(elementKindList))
for k := range elementKindList {
v = append(v, k.String())
}
return attr.ErrInvalidValue{
Attr: a,
Message: fmt.Sprintf("must be a registered element (%q)", strings.Join(v, `", "`)),
}
}
*k = ak
return nil
}
func (k ElementKind) String() string {
return string(k)
}
var (
elementKindList = make(map[ElementKind]Element)
elementKindAttrName = xml.Name{Local: "data-ipub-element"}
)

View File

@@ -1,47 +0,0 @@
package element_test
import (
"encoding/xml"
"testing"
"code.capytal.cc/capytal/comicverse/ipub/element"
)
func Test(t *testing.T) {
d := element.Section{
Body: element.Body{
Test: "helloworld",
Children: []element.Element{
&element.Paragraph{
DataElement: element.ParagraphKind,
Text: "hello world",
Test: "testvalue",
},
&element.Paragraph{
DataElement: element.ParagraphKind,
Text: "hello world 2",
},
},
},
}
b, err := xml.Marshal(d)
if err != nil {
t.Error(err.Error())
t.FailNow()
}
t.Logf("%#v", string(b))
var ud element.Section
err = xml.Unmarshal(b, &ud)
if err != nil {
t.Error(err)
t.FailNow()
}
t.Logf("%#v", ud)
t.Logf("%#v", ud.Body.Children[0])
t.Logf("%#v", ud.Body.Children[1])
}

View File

@@ -1,41 +0,0 @@
package element
import "encoding/xml"
type Section struct {
XMLName xml.Name `xml:"html"`
Body Body `xml:"body"`
}
var KindSection = NewElementKind("section", Section{})
func (Section) Kind() ElementKind {
return KindSection
}
type Body struct {
XMLName xml.Name `xml:"body"`
Test string `xml:"test,attr"`
Children ElementChildren `xml:",any"`
}
var KindBody = NewElementKind("body", Body{})
func (Body) Kind() ElementKind {
return KindBody
}
type Paragraph struct {
XMLName xml.Name `xml:"p"`
DataElement ElementKind `xml:"data-ipub-element,attr"`
Test string `xml:"test,attr"`
Text string `xml:",chardata"`
}
var KindParagraph = NewElementKind("paragraph", Paragraph{})
func (Paragraph) Kind() ElementKind {
return KindParagraph
}

View File

@@ -1,69 +0,0 @@
PORT?=8080
lint:
golangci-lint run .
fmt:
go fmt .
golangci-lint run --fix .
dev/server:
go run github.com/joho/godotenv/cmd/godotenv@v1.5.1 \
go run github.com/air-verse/air@v1.52.2 \
--build.cmd "go build -o tmp/bin/main ./cmd" \
--build.bin "tmp/bin/main" \
--build.exclude_dir "node_modules" \
--build.include_ext "go" \
--build.stop_on_error "false" \
--misc.clean_on_exit true \
-- -dev -port $(PORT) -hostname 0.0.0.0
dev/assets:
tailwindcss \
-i ./assets/stylesheets/tailwind.css \
-o ./assets/stylesheets/out.css \
--watch
dev:
$(MAKE) -j2 dev/assets dev/server
dev/debug:
$(MAKE) -j2 debug dev/assets
debug:
dlv debug -l 127.0.0.1:38697 \
--continue \
--accept-multiclient \
--headless \
./cmd -- -dev -port $(PORT) -hostname 0.0.0.0
build/assets:
tailwindcss \
-i ./assets/stylesheets/tailwind.css \
-o ./assets/stylesheets/out.css \
--minify
build: build/assets
go build -o ./.dist/app .
run: build
./.dist/app
epub/example:
cd ./.epub/example; zip ./example.epub ./META-INF/container.xml ./OEBPS/* ./OEBPS/**/* ./mimetype
epub/example/server:
cd ./.epub/example; http-server
calibre:
mkdir -p ./tmp/calibre-library
calibre \
--no-update-check \
--with-library=./tmp/calibre-library \
./.epub/example/example.epub
clean:
# Remove generated directories
if [[ -d ".dist" ]]; then rm -r ./.dist; fi
if [[ -d "tmp" ]]; then rm -r ./tmp; fi
if [[ -d "bin" ]]; then rm -r ./bin; fi

View File

@@ -1,66 +0,0 @@
package model
import (
"fmt"
)
type Model interface {
Validate() error
}
type ErrInvalidModel struct {
Name string
Errors []error
}
var _ error = ErrInvalidModel{}
func (err ErrInvalidModel) Error() string {
return fmt.Sprintf("model %q is invalid", err.Name)
}
type ErrInvalidValue struct {
Name string
Actual any
Expected []any
}
var _ error = ErrInvalidValue{}
func (err ErrInvalidValue) Error() string {
var msg string
if err.Name != "" {
msg = fmt.Sprintf("%q has ", err.Name)
}
msg = msg + "incorrect value"
if err.Actual != nil {
msg = msg + fmt.Sprintf(" %q", err.Actual)
}
if len(err.Expected) == 0 || err.Expected == nil {
return msg
}
msg = fmt.Sprintf("%s, expected %q", msg, err.Expected[0])
if len(err.Expected) > 1 {
if len(err.Expected) == 2 {
msg = msg + fmt.Sprintf(" or %q", err.Expected[1])
} else {
for v := range err.Expected[1 : len(err.Expected)-1] {
msg = msg + fmt.Sprintf(", %q", v)
}
msg = msg + fmt.Sprintf(", or %q", err.Expected[len(err.Expected)-1])
}
}
return msg
}
type ErrZeroValue ErrInvalidValue
func (err ErrZeroValue) Error() string {
return fmt.Sprintf("%q has incorrect value, expected non-zero/non-empty value", err.Name)
}

View File

@@ -1,145 +0,0 @@
package model
import (
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"strconv"
"strings"
)
type Permissions int64
var (
_ sql.Scanner = (*Permissions)(nil)
_ driver.Value = Permissions(0)
_ fmt.Stringer = Permissions(0)
)
func (p Permissions) Has(perm ...Permissions) bool {
// Bitwise AND to compare if p has a permission
//
// If for example, p is 0x0010 ("edit.accessibility") and perm is
// 0x0001 ("read"): 0x0010 AND 0x0001 = 0x0000, which is not equal
// to 0x0001, return false.
//
// If p is 0x0011 ("edit.accessibility" and "read") and perm is
// 0x0001 ("read"): 0x0011 AND 0x0001 results in 0x0001, which
// is equal to 0x0001 ("read").
if len(perm) == 0 {
return false
}
if len(perm) == 1 {
return p&perm[0] == perm[0]
}
for _, pe := range perm {
if p&pe != pe {
return false
}
}
return true
}
func (p *Permissions) Add(perm ...Permissions) {
if p == nil {
t := Permissions(0)
p = &t
}
// Bitwise OR to add permissions.
//
// If p is 0x0001 ("read") and pe is 0x0010 ("edit.accessibility"):
// 0x0001 OR 0x0010 results in 0x0011, which means we added the "edit.accessibility" bit.
for _, pe := range perm {
*p = *p | pe
}
}
func (p *Permissions) Remove(perm ...Permissions) {
if p == nil {
return
}
// Bitwise NOT AND
//
// If p is 0x0011 ("read" + "edit.accessibility"), and perm is 0x0010 ("edit.accessibility"):
// we first convert perm to a bit-mask using NOT, so it becomes 0x1101; then we use AND to
// remove the "edit.accessibility", since 0x0011 AND 0x1101 results in 0x0001 ("read").
for _, pe := range perm {
*p = *p & (^pe)
}
}
func (p *Permissions) Scan(src any) error {
switch src := src.(type) {
case nil:
return nil
case int64:
*p = Permissions(src)
case string:
if strings.HasPrefix(src, "0x") {
i, err := strconv.ParseInt(strings.TrimPrefix(src, "0x"), 2, 64)
if err != nil {
return errors.Join(errors.New("Scan: unable to scan binary Permissions"), err)
}
return p.Scan(i)
}
i, err := strconv.ParseInt(src, 10, 64)
if err != nil {
return errors.Join(errors.New("Scan: unable to scan base10 Permissions"), err)
}
return p.Scan(i)
case []byte:
return p.Scan(string(src))
default:
return fmt.Errorf("Scan: unable to scan type %T into Permissions", src)
}
return nil
}
func (p Permissions) Value() (driver.Value, error) {
return int64(p), nil
}
func (p Permissions) String() string {
if p.Has(PermissionAuthor) {
return "author"
}
labels := []string{}
for perm, l := range PermissionLabels {
if p.Has(perm) {
labels = append(labels, l)
}
}
return strings.Join(labels, ",")
}
const (
PermissionAuthor Permissions = 0x1111111111111111 // "author"
PermissionAdminDelete Permissions = 0x1000000000000000 // "admin.delete" -----
PermissionAdminAll Permissions = 0x0111110000000001 // "admin.all"
PermissionAdminProject Permissions = 0x0100000000000000 // "admin.project"
PermissionAdminMembers Permissions = 0x0010000000000000 // "admin.members"
PermissionEditAll Permissions = 0x0000001111111111 // "edit.all" ---------
PermissionEditPages Permissions = 0x0000000100000000 // "edit.pages"
PermissionEditInteractions Permissions = 0x0000000010000000 // "edit.interactions"
PermissionEditDialogs Permissions = 0x0000000000001000 // "edit.dialogs"
PermissionEditTranslations Permissions = 0x0000000000000100 // "edit.translations"
PermissionEditAccessibility Permissions = 0x0000000000000010 // "edit.accessibility"
PermissionRead Permissions = 0x0000000000000001 // "read"
)
var PermissionLabels = map[Permissions]string{
PermissionAuthor: "author",
PermissionAdminDelete: "admin.delete",
PermissionAdminProject: "admin.project",
PermissionAdminMembers: "admin.members",
PermissionEditPages: "edit.pages",
PermissionEditInteractions: "edit.interactions",
PermissionEditDialogs: "edit.dialogs",
PermissionEditTranslations: "edit.translations",
PermissionEditAccessibility: "edit.accessibility",
PermissionRead: "read",
}

View File

@@ -1,38 +0,0 @@
package model
import (
"time"
"github.com/google/uuid"
)
type Project struct {
ID uuid.UUID // Must be unique, represented as base64 string in URLs
Title string // Must not be empty
DateCreated time.Time
DateUpdated time.Time
}
var _ Model = (*Project)(nil)
func (p Project) Validate() error {
errs := []error{}
if len(p.ID) == 0 {
errs = append(errs, ErrZeroValue{Name: "UUID"})
}
if p.Title == "" {
errs = append(errs, ErrZeroValue{Name: "Title"})
}
if p.DateCreated.IsZero() {
errs = append(errs, ErrZeroValue{Name: "DateCreated"})
}
if p.DateUpdated.IsZero() {
errs = append(errs, ErrZeroValue{Name: "DateUpdated"})
}
if len(errs) > 0 {
return ErrInvalidModel{Name: "Project", Errors: errs}
}
return nil
}

View File

@@ -1,34 +0,0 @@
package model
import (
"time"
"github.com/google/uuid"
)
type Token struct {
ID uuid.UUID
UserID uuid.UUID
DateCreated time.Time
DateExpires time.Time
}
func (t Token) Validate() error {
errs := []error{}
if len(t.ID) == 0 {
errs = append(errs, ErrZeroValue{Name: "ID"})
}
if len(t.UserID) == 0 {
errs = append(errs, ErrZeroValue{Name: "User"})
}
if t.DateCreated.IsZero() {
errs = append(errs, ErrZeroValue{Name: "DateCreated"})
}
if t.DateExpires.IsZero() {
errs = append(errs, ErrZeroValue{Name: "DateExpires"})
}
if len(errs) > 0 {
return ErrInvalidModel{Name: "Token", Errors: errs}
}
return nil
}

View File

@@ -1,41 +0,0 @@
package model
import (
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"` // Must be unique
Password []byte `json:"password"`
DateCreated time.Time `json:"date_created"`
DateUpdated time.Time `json:"date_updated"`
}
func (u User) Validate() error {
errs := []error{}
if len(u.ID) == 0 {
errs = append(errs, ErrZeroValue{Name: "ID"})
}
if u.Username == "" {
errs = append(errs, ErrZeroValue{Name: "Username"})
}
if len(u.Password) == 0 {
errs = append(errs, ErrZeroValue{Name: "Password"})
}
if u.DateCreated.IsZero() {
errs = append(errs, ErrZeroValue{Name: "DateCreated"})
}
if u.DateUpdated.IsZero() {
errs = append(errs, ErrZeroValue{Name: "DateUpdated"})
}
if len(errs) > 0 {
return ErrInvalidModel{Name: "User", Errors: errs}
}
return nil
}

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "comicverse",
"version": "0.0.1",
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^9.6.0",
"@types/node": "^22.6.0",
"blob-util": "^2.0.2",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"minio": "^8.0.1",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"svelte": "^4.2.7",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.3",
"xml-js": "^1.6.11"
},
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"type": "module"
}

View File

@@ -1,283 +0,0 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Permissions struct {
baseRepostiory
}
// Must be initiated after [User] and [Project]
func NewPermissions(
ctx context.Context,
db *sql.DB,
log *slog.Logger,
assert tinyssert.Assertions,
) (*Permissions, error) {
b := newBaseRepostiory(ctx, db, log, assert)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
q := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS project_permissions (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permissions_value INTEGER NOT NULL DEFAULT '0',
_permissions_text TEXT NOT NULL DEFAULT '', -- For display purposes only, may not always be up-to-date
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(project_id, user_id)
FOREIGN KEY(project_id)
REFERENCES projects (id)
ON DELETE CASCADE
ON UPDATE RESTRICT,
FOREIGN KEY(user_id)
REFERENCES users (id)
ON DELETE CASCADE
ON UPDATE RESTRICT
)
`)
_, err = tx.ExecContext(ctx, q)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, errors.Join(errors.New("unable to create project tables"), err)
}
return &Permissions{baseRepostiory: b}, nil
}
func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permissions) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return errors.Join(ErrDatabaseConn, err)
}
q := `
INSERT INTO project_permissions (project_id, user_id, permissions_value, _permissions_text, created_at, updated_at)
VALUES (:project_id, :user_id, :permissions_value, :permissions_text, :created_at, :updated_at)
`
now := time.Now()
log := repo.log.With(slog.String("project_id", project.String()),
slog.String("user_id", user.String()),
slog.String("permissions", fmt.Sprintf("%d", permissions)),
slog.String("permissions_text", permissions.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Inserting new project permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("project_id", project),
sql.Named("user_id", user),
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
sql.Named("created_at", now.Format(dateFormat)),
sql.Named("updated_at", now.Format(dateFormat)),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert project permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}
func (repo Permissions) GetByID(project uuid.UUID, user uuid.UUID) (model.Permissions, error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
q := `
SELECT permissions_value FROM project_permissions
WHERE project_id = :project_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("projcet_id", project.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Getting by ID")
row := repo.db.QueryRowContext(repo.ctx, q,
sql.Named("project_id", user),
sql.Named("user_id", user))
var p model.Permissions
if err := row.Scan(&p); err != nil {
log.ErrorContext(repo.ctx, "Failed to get permissions by ID", slog.String("error", err.Error()))
return model.Permissions(0), errors.Join(ErrExecuteQuery, err)
}
return p, nil
}
// GetByUserID returns a project_id-to-permissions map containing all projects and permissions that said userID
// has relation to.
func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]model.Permissions, err error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
// Begin tx so we don't read rows as they are being updated
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return nil, errors.Join(ErrDatabaseConn, err)
}
q := `
SELECT project_id, permissions_value FROM project_permissions
WHERE user_id = :user_id
`
log := repo.log.With(slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Getting by user ID")
rows, err := tx.QueryContext(repo.ctx, q, sql.Named("user_id", user))
if err != nil {
log.ErrorContext(repo.ctx, "Failed to get permissions by user ID", slog.String("error", err.Error()))
return nil, errors.Join(ErrExecuteQuery, err)
}
defer func() {
err = rows.Close()
if err != nil {
err = errors.Join(ErrCloseConn, err)
}
}()
ps := map[uuid.UUID]model.Permissions{}
for rows.Next() {
var project uuid.UUID
var permissions model.Permissions
err := rows.Scan(&project, &permissions)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan permissions of user id", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
ps[project] = permissions
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return nil, errors.Join(ErrCommitQuery, err)
}
return ps, nil
}
func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permissions) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return errors.Join(ErrDatabaseConn, err)
}
q := `
UPDATE project_permissions
SET permissions_value = :permissions_value
_permissions_text = :permissions_text
updated_at = :updated_at
WHERE project_uuid = :project_uuid
AND user_uuid = :user_uuid
`
log := repo.log.With(slog.String("project_id", project.String()),
slog.String("user_id", user.String()),
slog.String("permissions", fmt.Sprintf("%d", permissions)),
slog.String("permissions_text", permissions.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Updating project permissions")
now := time.Now()
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
sql.Named("updated_at", now.Format(dateFormat)),
sql.Named("project_id", project),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to update project permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}
func (repo Permissions) Delete(project, user uuid.UUID) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return err
}
q := `
DELETE FROM project_permissions
WHERE project_id = :project_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("project_id", project.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting project permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("project_id", project),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete project permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}

View File

@@ -1,282 +0,0 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Project struct {
baseRepostiory
}
func NewProject(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Project, error) {
b := newBaseRepostiory(ctx, db, log, assert)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
_, err = tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS projects (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, errors.Join(errors.New("unable to create project tables"), err)
}
return &Project{baseRepostiory: b}, nil
}
func (repo Project) Create(p model.Project) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
if err := p.Validate(); err != nil {
return errors.Join(ErrInvalidInput, err)
}
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return errors.Join(ErrDatabaseConn, err)
}
q := `
INSERT INTO projects (id, title, created_at, updated_at)
VALUES (:id, :title, :created_at, :updated_at)
`
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Inserting new project")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("id", p.ID),
sql.Named("title", p.Title),
sql.Named("created_at", p.DateCreated.Format(dateFormat)),
sql.Named("updated_at", p.DateUpdated.Format(dateFormat)),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert project", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}
func (repo Project) GetByID(projectID uuid.UUID) (project model.Project, err error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
q := `
SELECT id, title, created_at, updated_at FROM projects
WHERE id = :id
`
log := repo.log.With(slog.String("query", q), slog.String("id", projectID.String()))
log.DebugContext(repo.ctx, "Getting project by ID")
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("id", projectID))
var id uuid.UUID
var title string
var dateCreatedStr, dateUpdatedStr string
err = row.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return model.Project{}, errors.Join(ErrInvalidOutput, err)
}
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return model.Project{}, errors.Join(ErrInvalidOutput, err)
}
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return model.Project{}, errors.Join(ErrInvalidOutput, err)
}
return model.Project{
ID: id,
Title: title,
DateCreated: dateCreated,
DateUpdated: dateUpdated,
}, nil
}
func (repo Project) GetByIDs(ids []uuid.UUID) (projects []model.Project, err error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
// Begin tx so we don't read rows as they are being updated
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return nil, errors.Join(ErrDatabaseConn, err)
}
c := make([]string, len(ids))
for i, id := range ids {
c[i] = fmt.Sprintf("id = '%s'", id.String())
}
q := fmt.Sprintf(`
SELECT id, title, created_at, updated_at FROM projects
WHERE %s
`, strings.Join(c, " OR "))
log := repo.log.With(slog.String("query", q))
log.DebugContext(repo.ctx, "Getting projects by IDs")
rows, err := tx.QueryContext(repo.ctx, q)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to get projects by IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrExecuteQuery, err)
}
defer func() {
err = rows.Close()
if err != nil {
err = errors.Join(ErrCloseConn, err)
}
}()
ps := []model.Project{}
for rows.Next() {
var id uuid.UUID
var title string
var dateCreatedStr, dateUpdatedStr string
err := rows.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
ps = append(ps, model.Project{
ID: id,
Title: title,
DateCreated: dateCreated,
DateUpdated: dateUpdated,
})
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return nil, errors.Join(ErrCommitQuery, err)
}
return ps, nil
}
func (repo Project) Update(p model.Project) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
if err := p.Validate(); err != nil {
return errors.Join(ErrInvalidInput, err)
}
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return errors.Join(ErrDatabaseConn, err)
}
q := `
UPDATE projects
SET title = :title
updated_at = :updated_at
WHERE id = :id
`
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Updating project")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("title", p.Title),
sql.Named("updated_at", p.DateUpdated.Format(dateFormat)),
sql.Named("id", p.ID),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert project", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}
func (repo Project) DeleteByID(id uuid.UUID) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return err
}
q := `
DELETE FROM projects WHERE id = :id
`
log := repo.log.With(slog.String("id", id.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting project")
_, err = tx.ExecContext(repo.ctx, q, sql.Named("id", id))
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete project", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}

View File

@@ -1,51 +0,0 @@
package repository
import (
"context"
"database/sql"
"errors"
"log/slog"
"time"
"code.capytal.cc/loreddev/x/tinyssert"
)
// TODO: Add rowback to all return errors, or use context to cancel operations
type baseRepostiory struct {
db *sql.DB
ctx context.Context
log *slog.Logger
assert tinyssert.Assertions
}
func newBaseRepostiory(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) baseRepostiory {
assert.NotNil(db)
assert.NotNil(ctx)
assert.NotNil(log)
return baseRepostiory{
db: db,
ctx: ctx,
log: log,
assert: assert,
}
}
var (
// TODO: Change all ErrDatabaseConn to ErrCloseConn
ErrDatabaseConn = errors.New("repository: failed to begin transaction/connection with database")
ErrCloseConn = errors.New("repository: failed to close/commit connection")
ErrExecuteQuery = errors.New("repository: failed to execute query")
ErrCommitQuery = errors.New("repository: failed to commit transaction")
ErrInvalidInput = errors.New("repository: data sent to save is invalid")
ErrInvalidOutput = errors.New("repository: data found is not valid")
ErrNotFound = sql.ErrNoRows
)
var dateFormat = time.RFC3339
type scan interface {
Scan(dest ...any) error
}

View File

@@ -1,245 +0,0 @@
package repository
import (
"context"
"database/sql"
"errors"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Token struct {
baseRepostiory
}
// Must be initiated after [User]
func NewToken(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Token, error) {
b := newBaseRepostiory(ctx, db, log, assert)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
_, err = tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS tokens (
id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
PRIMARY KEY(id, user_id),
FOREIGN KEY(user_id)
REFERENCES users (id)
ON DELETE CASCADE
ON UPDATE RESTRICT
)`)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, errors.Join(errors.New("unable to create project tables"), err)
}
return &Token{baseRepostiory: b}, nil
}
func (repo Token) Create(token model.Token) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
if err := token.Validate(); err != nil {
return errors.Join(ErrInvalidInput, err)
}
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return errors.Join(ErrDatabaseConn, err)
}
q := `
INSERT INTO tokens (id, user_id, created_at, expires_at)
VALUES (:id, :user_id, :created_at, :expires_at)
`
log := repo.log.With(slog.String("id", token.ID.String()),
slog.String("user_id", token.UserID.String()),
slog.String("expires", token.DateExpires.Format(dateFormat)),
slog.String("query", q))
log.DebugContext(repo.ctx, "Inserting new user token")
// TODO: Check rows affected
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("id", token.ID),
sql.Named("user_id", token.UserID),
sql.Named("created_at", token.DateCreated.Format(dateFormat)),
sql.Named("expired_at", token.DateExpires.Format(dateFormat)),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert token", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}
func (repo Token) Get(tokenID, userID uuid.UUID) (model.Token, error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
q := `
SELECT (id, user_id, created_at, expired_at) FROM tokens
WHERE id = :id
AND user_id = :user_id
`
log := repo.log.With(slog.String("id", tokenID.String()),
slog.String("user_id", userID.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Getting token")
row := repo.db.QueryRowContext(repo.ctx, q,
sql.Named("id", tokenID),
sql.Named("user_id", userID),
)
token, err := repo.scan(row)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan token", slog.String("error", err.Error()))
return model.Token{}, err
}
return token, nil
}
func (repo Token) GetByUserID(userID uuid.UUID) (tokens []model.Token, err error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
q := `
SELECT (id, user_id, created_at, expired_at) FROM tokens
WHERE user_id = :user_id
`
log := repo.log.With(
slog.String("user_id", userID.String()),
slog.String("query", q),
)
log.DebugContext(repo.ctx, "Getting users tokens")
rows, err := repo.db.QueryContext(repo.ctx, q,
sql.Named("user_id", userID),
)
defer func() {
err = rows.Close()
if err != nil {
err = errors.Join(ErrCloseConn, err)
}
}()
if err != nil {
log.ErrorContext(repo.ctx, "Failed to get user tokens", slog.String("error", err.Error()))
return []model.Token{}, errors.Join(ErrExecuteQuery, err)
}
tokens = []model.Token{}
for rows.Next() {
t, err := repo.scan(rows)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan token", slog.String("error", err.Error()))
return []model.Token{}, err
}
tokens = append(tokens, t)
}
if err := rows.Err(); err != nil {
log.ErrorContext(repo.ctx, "Failed to scan token rows", slog.String("error", err.Error()))
return []model.Token{}, errors.Join(ErrExecuteQuery, err)
}
return tokens, err
}
func (repo Token) scan(row scan) (model.Token, error) {
repo.assert.NotNil(repo.ctx)
var token model.Token
var createdStr, expiresStr string
err := row.Scan(&token.ID, &token.UserID, &createdStr, &expiresStr)
if err != nil {
return model.Token{}, errors.Join(ErrExecuteQuery, err)
}
dateCreated, err := time.Parse(dateFormat, createdStr)
if err != nil {
return model.Token{}, errors.Join(ErrInvalidOutput, err)
}
dateExpires, err := time.Parse(dateFormat, createdStr)
if err != nil {
return model.Token{}, errors.Join(ErrInvalidOutput, err)
}
token.DateCreated = dateCreated
token.DateExpires = dateExpires
if err := token.Validate(); err != nil {
return model.Token{}, errors.Join(ErrInvalidOutput, err)
}
return token, nil
}
func (repo Token) Delete(token, user uuid.UUID) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return err
}
q := `
DELETE FROM tokens
WHERE id = :id
AND user_id = :user_id
`
log := repo.log.With(slog.String("id", token.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting token")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("id", token),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete token", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}

View File

@@ -1,211 +0,0 @@
package repository
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type User struct {
baseRepostiory
}
func NewUser(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
assert tinyssert.Assertions,
) (*User, error) {
assert.NotNil(ctx)
assert.NotNil(db)
assert.NotNil(logger)
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS users (
id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
if err != nil {
return nil, err
}
b := newBaseRepostiory(ctx, db, logger, assert)
return &User{
baseRepostiory: b,
}, nil
}
func (repo *User) Create(u model.User) (model.User, error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.log)
repo.assert.NotNil(repo.ctx)
if err := u.Validate(); err != nil {
return model.User{}, errors.Join(ErrInvalidInput, err)
}
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return model.User{}, errors.Join(ErrDatabaseConn, err)
}
q := `
INSERT INTO users (id, username, password_hash, created_at, updated_at)
VALUES (:id, :username, :password_hash, :created_at, :updated_at)
`
log := repo.log.With(
slog.String("id", u.ID.String()),
slog.String("username", u.Username),
slog.String("query", q))
log.DebugContext(repo.ctx, "Inserting new user")
t := time.Now()
passwd := base64.URLEncoding.EncodeToString(u.Password)
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("id", u.ID),
sql.Named("username", u.Username),
sql.Named("password_hash", passwd),
sql.Named("created_at", t.Format(dateFormat)),
sql.Named("updated_at", t.Format(dateFormat)))
if err != nil {
log.ErrorContext(repo.ctx, "Failed to create user", slog.String("error", err.Error()))
return model.User{}, errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return model.User{}, errors.Join(ErrCommitQuery, err)
}
return u, nil
}
func (repo *User) GetByID(id uuid.UUID) (model.User, error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.log)
repo.assert.NotNil(repo.ctx)
q := `
SELECT id, username, password_hash, created_at, updated_at FROM users
WHERE id = :id
`
log := repo.log.With(
slog.String("id", id.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Querying user")
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("username", id))
user, err := repo.scan(row)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to query user", slog.String("error", err.Error()))
return model.User{}, err
}
return user, nil
}
func (repo *User) GetByUsername(username string) (model.User, error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.log)
repo.assert.NotNil(repo.ctx)
q := `
SELECT id, username, password_hash, created_at, updated_at FROM users
WHERE username = :username
`
log := repo.log.With(
slog.String("username", username),
slog.String("query", q))
log.DebugContext(repo.ctx, "Querying user")
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("username", username))
user, err := repo.scan(row)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to query user", slog.String("error", err.Error()))
return model.User{}, err
}
return user, nil
}
func (repo *User) scan(row scan) (model.User, error) {
var user model.User
var password_hashStr, createdStr, updatedStr string
err := row.Scan(&user.ID, &user.Username, &password_hashStr, &createdStr, &updatedStr)
if err != nil {
return model.User{}, errors.Join(ErrExecuteQuery, err)
}
passwd, err := base64.URLEncoding.DecodeString(password_hashStr)
if err != nil {
return model.User{}, errors.Join(ErrInvalidOutput, err)
}
created, err := time.Parse(dateFormat, createdStr)
if err != nil {
return model.User{}, errors.Join(ErrInvalidOutput, err)
}
updated, err := time.Parse(dateFormat, updatedStr)
if err != nil {
return model.User{}, errors.Join(ErrInvalidOutput, err)
}
user.Password = passwd
user.DateCreated = created
user.DateUpdated = updated
if err := user.Validate(); err != nil {
return model.User{}, errors.Join(ErrInvalidOutput, err)
}
return user, nil
}
func (repo *User) DeleteByID(id uuid.UUID) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.log)
repo.assert.NotNil(repo.ctx)
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return err
}
q := `
DELETE FROM users WHERE id = :id
`
log := repo.log.With(slog.String("id", id.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting user")
_, err = tx.ExecContext(repo.ctx, q, sql.Named("id", id))
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete user", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}

View File

@@ -1,132 +0,0 @@
package router
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"code.capytal.cc/capytal/comicverse/service"
"code.capytal.cc/capytal/comicverse/templates"
"code.capytal.cc/loreddev/smalltrip/problem"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type projectController struct {
projectSvc *service.Project
templates templates.ITemplate
assert tinyssert.Assertions
}
func newProjectController(
projectService *service.Project,
templates templates.ITemplate,
assertions tinyssert.Assertions,
) *projectController {
return &projectController{
projectSvc: projectService,
templates: templates,
assert: assertions,
}
}
func (ctrl projectController) dashboard(w http.ResponseWriter, r *http.Request) {
userCtx := NewUserContext(r.Context())
userID, ok := userCtx.GetUserID()
if !ok {
userCtx.Unathorize(w, r)
return
}
projects, err := ctrl.projectSvc.GetUserProjects(userID)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
ps := make([]struct {
ID string
Title string
}, len(projects))
for i, project := range projects {
ps[i] = struct {
ID string
Title string
}{
ID: base64.URLEncoding.EncodeToString([]byte(project.ID.String())),
Title: project.Title,
}
}
err = ctrl.templates.ExecuteTemplate(w, "dashboard", ps)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
}
}
func (ctrl projectController) getProject(w http.ResponseWriter, r *http.Request) {
// TODO: Handle private projects
shortProjectID := r.PathValue("projectID")
id, err := base64.URLEncoding.DecodeString(shortProjectID)
if err != nil {
problem.NewBadRequest(fmt.Sprintf("Incorrectly encoded project ID: %s", err.Error())).ServeHTTP(w, r)
return
}
projectID, err := uuid.ParseBytes(id)
if err != nil {
problem.NewBadRequest("Project ID is not a valid UUID").ServeHTTP(w, r)
return
}
project, err := ctrl.projectSvc.GetProject(projectID)
if errors.Is(err, service.ErrNotFound) {
problem.NewNotFound().ServeHTTP(w, r)
return
} else if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
// TODO: Return project template
b, err := json.Marshal(project)
w.Header().Add("Content-Type", "application/json")
if _, err := w.Write(b); err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
}
func (ctrl projectController) createProject(w http.ResponseWriter, r *http.Request) {
userCtx := NewUserContext(r.Context())
userID, ok := userCtx.GetUserID()
if !ok {
userCtx.Unathorize(w, r)
return
}
title := r.FormValue("title")
if title == "" {
problem.NewBadRequest(`Missing "title" parameter`).ServeHTTP(w, r)
return
}
project, err := ctrl.projectSvc.Create(title, userID)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
path := fmt.Sprintf("/p/%s/", base64.URLEncoding.EncodeToString([]byte(project.ID.String())))
http.Redirect(w, r, path, http.StatusSeeOther)
}

View File

@@ -1,156 +0,0 @@
package router
import (
"errors"
"io/fs"
"log/slog"
"net/http"
"strings"
"code.capytal.cc/capytal/comicverse/service"
"code.capytal.cc/capytal/comicverse/templates"
"code.capytal.cc/loreddev/smalltrip"
"code.capytal.cc/loreddev/smalltrip/middleware"
"code.capytal.cc/loreddev/smalltrip/multiplexer"
"code.capytal.cc/loreddev/smalltrip/problem"
"code.capytal.cc/loreddev/x/tinyssert"
)
type router struct {
userService *service.User
tokenService *service.Token
projectService *service.Project
templates templates.ITemplate
assets fs.FS
cache bool
assert tinyssert.Assertions
log *slog.Logger
}
func New(cfg Config) (http.Handler, error) {
if cfg.UserService == nil {
return nil, errors.New("user service is nil")
}
if cfg.TokenService == nil {
return nil, errors.New("token service is nil")
}
if cfg.ProjectService == nil {
return nil, errors.New("project service is nil")
}
if cfg.Templates == nil {
return nil, errors.New("templates is nil")
}
if cfg.Assets == nil {
return nil, errors.New("static files is nil")
}
if cfg.Assertions == nil {
return nil, errors.New("assertions is nil")
}
if cfg.Logger == nil {
return nil, errors.New("logger is nil")
}
r := &router{
userService: cfg.UserService,
tokenService: cfg.TokenService,
projectService: cfg.ProjectService,
templates: cfg.Templates,
assets: cfg.Assets,
cache: !cfg.DisableCache,
assert: cfg.Assertions,
log: cfg.Logger,
}
return r.setup(), nil
}
type Config struct {
UserService *service.User
TokenService *service.Token
ProjectService *service.Project
Templates templates.ITemplate
Assets fs.FS
DisableCache bool
Assertions tinyssert.Assertions
Logger *slog.Logger
}
func (router *router) setup() http.Handler {
router.assert.NotNil(router.log)
router.assert.NotNil(router.assets)
log := router.log
log.Debug("Initializing router")
r := smalltrip.NewRouter(
smalltrip.WithAssertions(router.assert),
smalltrip.WithLogger(log.WithGroup("smalltrip")),
)
r.Use(middleware.Logger(log.WithGroup("requests")))
if router.cache {
r.Use(middleware.Cache())
} else {
r.Use(middleware.DisableCache())
}
r.Use(problem.PanicMiddleware())
// TODO: when the HandlerDevpage is completed on the problem package, we
// will provide it a custom template here:
// r.Use(problem.Middleware())
userController := newUserController(userControllerCfg{
UserService: router.userService,
TokenService: router.tokenService,
LoginPath: "/login/",
RedirectPath: "/",
Templates: router.templates,
Assert: router.assert,
})
projectController := newProjectController(router.projectService, router.templates, router.assert)
r.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.Use(userController.userMiddleware)
r.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
// TODO: Add a way to the user to bypass this check and see the landing page.
// Probably a query parameter to bypass like "?landing=true"
if _, ok := NewUserContext(r.Context()).GetUserID(); ok {
projectController.dashboard(w, r)
return
}
err := router.templates.ExecuteTemplate(w, "landing", nil)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
}
})
r.HandleFunc("/login/{$}", userController.login)
r.HandleFunc("/register/{$}", userController.register)
// TODO: Provide/redirect short project-id paths to long paths with the project title as URL /projects/title-of-the-project-<start of uuid>
r.HandleFunc("GET /p/{projectID}/{$}", projectController.getProject)
r.HandleFunc("POST /p/{$}", projectController.createProject)
return r
}
// getMethod is a helper function to get the HTTP method of request, tacking precedence
// the "x-method" argument sent by requests via form or query values.
func getMethod(r *http.Request) string {
m := r.FormValue("x-method")
if m != "" {
return strings.ToUpper(m)
}
return strings.ToUpper(r.Method)
}

View File

@@ -1,293 +0,0 @@
package router
import (
"context"
"errors"
"net/http"
"code.capytal.cc/capytal/comicverse/service"
"code.capytal.cc/capytal/comicverse/templates"
"code.capytal.cc/loreddev/smalltrip/middleware"
"code.capytal.cc/loreddev/smalltrip/problem"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
)
type userController struct {
userSvc *service.User
tokenSvc *service.Token
loginPath string
redirectPath string
templates templates.ITemplate
assert tinyssert.Assertions
}
func newUserController(cfg userControllerCfg) userController {
cfg.Assert.NotNil(cfg.UserService)
cfg.Assert.NotNil(cfg.TokenService)
cfg.Assert.NotZero(cfg.LoginPath)
cfg.Assert.NotZero(cfg.RedirectPath)
cfg.Assert.NotNil(cfg.Templates)
return userController{
userSvc: cfg.UserService,
tokenSvc: cfg.TokenService,
loginPath: cfg.LoginPath,
redirectPath: cfg.RedirectPath,
templates: cfg.Templates,
assert: cfg.Assert,
}
}
type userControllerCfg struct {
UserService *service.User
TokenService *service.Token
LoginPath string
RedirectPath string
Templates templates.ITemplate
Assert tinyssert.Assertions
}
func (ctrl userController) login(w http.ResponseWriter, r *http.Request) {
ctrl.assert.NotNil(ctrl.templates) // TODO?: Remove these types of assertions, since golang will panic anyway
ctrl.assert.NotNil(ctrl.userSvc) // when the methods of these functions are called
if r.Method == http.MethodGet {
err := ctrl.templates.ExecuteTemplate(w, "login", nil)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
}
return
}
if r.Method != http.MethodPost {
problem.NewMethodNotAllowed([]string{http.MethodGet, http.MethodPost}).ServeHTTP(w, r)
return
}
username, passwd := r.FormValue("username"), r.FormValue("password")
if username == "" {
problem.NewBadRequest(`Missing "username" form value`).ServeHTTP(w, r)
return
}
if passwd == "" {
problem.NewBadRequest(`Missing "password" form value`).ServeHTTP(w, r)
return
}
// TODO: Move token issuing to it's own service, make UserService.Login just return the user
user, err := ctrl.userSvc.Login(username, passwd)
if errors.Is(err, service.ErrNotFound) {
problem.NewNotFound().ServeHTTP(w, r)
return
} else if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
token, err := ctrl.tokenSvc.Issue(user)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
// TODO: harden the cookie policy to the same domain
cookie := &http.Cookie{
Path: "/",
HttpOnly: true,
Name: "authorization",
Value: token,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, ctrl.redirectPath, http.StatusSeeOther)
}
func (ctrl userController) register(w http.ResponseWriter, r *http.Request) {
ctrl.assert.NotNil(ctrl.templates)
ctrl.assert.NotNil(ctrl.userSvc)
if r.Method == http.MethodGet {
err := ctrl.templates.ExecuteTemplate(w, "register", nil)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
}
return
}
if r.Method != http.MethodPost {
problem.NewMethodNotAllowed([]string{http.MethodGet, http.MethodPost}).ServeHTTP(w, r)
return
}
username, passwd := r.FormValue("username"), r.FormValue("password")
if username == "" {
problem.NewBadRequest(`Missing "username" form value`).ServeHTTP(w, r)
return
}
if passwd == "" {
problem.NewBadRequest(`Missing "password" form value`).ServeHTTP(w, r)
return
}
user, err := ctrl.userSvc.Register(username, passwd)
if errors.Is(err, service.ErrUsernameAlreadyExists) || errors.Is(err, service.ErrPasswordTooLong) {
problem.NewBadRequest(err.Error()).ServeHTTP(w, r)
return
} else if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
token, err := ctrl.tokenSvc.Issue(user)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
// TODO: harden the cookie policy to the same domain
cookie := &http.Cookie{
Path: "/",
HttpOnly: true,
Name: "authorization",
Value: token,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (ctrl userController) userMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var token string
if t := r.Header.Get("Authorization"); t != "" {
token = t
} else if cs := r.CookiesNamed("authorization"); len(cs) > 0 {
token = cs[0].Value // TODO: Validate cookie
}
if token == "" {
next.ServeHTTP(w, r)
return
}
// TODO: Create some way to show the user what error occurred with the token,
// not just the Unathorize method of UserContext. Maybe a web socket to send
// the message? Or maybe a custom Header? A header can be intercepted via a
// listener in the HTMX framework probably.
ctx := r.Context()
t, err := ctrl.tokenSvc.Parse(token)
if err != nil {
ctx = context.WithValue(ctx, "x-comicverse-user-token-error", err)
} else {
ctx = context.WithValue(ctx, "x-comicverse-user-token", t)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
var _ middleware.Middleware = userController{}.userMiddleware
type UserContext struct {
context.Context
}
func NewUserContext(ctx context.Context) UserContext {
if uctxp, ok := ctx.(*UserContext); ok && uctxp != nil {
return *uctxp
} else if uctx, ok := ctx.(UserContext); ok {
return uctx
}
return UserContext{Context: ctx}
}
func (ctx UserContext) Unathorize(w http.ResponseWriter, r *http.Request) {
// TODO: Add a way to redirect to the login page in case of a incorrect token.
// Since we use HTMX, we can't just return a redirect response probably,
// the framework will just get the login page html and not redirect the user to the page.
var p problem.Problem
if err, ok := ctx.GetTokenErr(); ok {
p = problem.NewUnauthorized(problem.AuthSchemeBearer, problem.WithError(err))
} else {
p = problem.NewUnauthorized(problem.AuthSchemeBearer)
}
p.ServeHTTP(w, r)
}
func (ctx UserContext) GetUserID() (uuid.UUID, bool) {
claims, ok := ctx.GetClaims()
if !ok {
return uuid.UUID{}, false
}
sub, ok := claims["sub"]
if !ok {
return uuid.UUID{}, false
}
s, ok := sub.(string)
if !ok {
return uuid.UUID{}, false
}
id, err := uuid.Parse(s)
if err != nil {
// TODO?: Add error to error context
return uuid.UUID{}, false
}
return id, true
}
func (ctx UserContext) GetClaims() (jwt.MapClaims, bool) {
token, ok := ctx.GetToken()
if !ok {
return jwt.MapClaims{}, false
}
// TODO: Make claims type be registered in the user service
// TODO: Structure claims type
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return jwt.MapClaims{}, false
}
return claims, true
}
func (ctx UserContext) GetToken() (*jwt.Token, bool) {
t := ctx.Value("x-comicverse-user-token")
if t == nil {
return nil, false
}
token, ok := t.(*jwt.Token)
if !ok {
return nil, false
}
return token, true
}
func (ctx UserContext) GetTokenErr() (error, bool) {
e := ctx.Value("x-comicverse-user-token-error")
if e == nil {
return nil, false
}
err, ok := e.(error)
if !ok {
return nil, false
}
return err, true
}

View File

@@ -1,124 +0,0 @@
package service
import (
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/capytal/comicverse/repository"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Project struct {
projectRepo *repository.Project
permissionRepo *repository.Permissions
log *slog.Logger
assert tinyssert.Assertions
}
func NewProject(
project *repository.Project,
permissions *repository.Permissions,
logger *slog.Logger,
assertions tinyssert.Assertions,
) *Project {
return &Project{
projectRepo: project,
permissionRepo: permissions,
log: logger,
assert: assertions,
}
}
func (svc Project) Create(title string, ownerUserID ...uuid.UUID) (model.Project, error) {
log := svc.log.With(slog.String("title", title))
log.Info("Creating project")
defer log.Info("Finished creating project")
id, err := uuid.NewV7()
if err != nil {
return model.Project{}, fmt.Errorf("service: failed to generate id: %w", err)
}
now := time.Now()
p := model.Project{
ID: id,
Title: title,
DateCreated: now,
DateUpdated: now,
}
err = svc.projectRepo.Create(p)
if err != nil {
return model.Project{}, fmt.Errorf("service: failed to create project: %w", err)
}
if len(ownerUserID) > 0 {
err := svc.SetAuthor(p.ID, ownerUserID[0])
if err != nil {
return model.Project{}, err
}
}
return p, nil
}
func (svc Project) SetAuthor(projectID uuid.UUID, userID uuid.UUID) error {
log := svc.log.With(slog.String("project", projectID.String()), slog.String("user", userID.String()))
log.Info("Setting project owner")
defer log.Info("Finished setting project owner")
if _, err := svc.permissionRepo.GetByID(projectID, userID); err == nil {
err := svc.permissionRepo.Update(projectID, userID, model.PermissionAuthor)
if err != nil {
return fmt.Errorf("service: failed to update project author: %w", err)
}
}
p := model.PermissionAuthor
err := svc.permissionRepo.Create(projectID, userID, p)
if err != nil {
return fmt.Errorf("service: failed to set project owner: %w", err)
}
return nil
}
func (svc Project) GetUserProjects(userID uuid.UUID) ([]model.Project, error) {
perms, err := svc.permissionRepo.GetByUserID(userID)
if err != nil {
return nil, fmt.Errorf("service: failed to get user permissions: %w", err)
}
ids := []uuid.UUID{}
for project, permissions := range perms {
if permissions.Has(model.PermissionRead) {
ids = append(ids, project)
}
}
if len(ids) == 0 {
return []model.Project{}, nil
}
projects, err := svc.projectRepo.GetByIDs(ids)
if err != nil {
return nil, fmt.Errorf("service: failed to get projects: %w", err)
}
return projects, nil
}
func (svc Project) GetProject(projectID uuid.UUID) (model.Project, error) {
p, err := svc.projectRepo.GetByID(projectID)
if err != nil {
return model.Project{}, fmt.Errorf("service: failed to get project: %w", err)
}
return p, nil
}

View File

@@ -1,5 +0,0 @@
package service
import "code.capytal.cc/capytal/comicverse/repository"
var ErrNotFound = repository.ErrNotFound

View File

@@ -1,188 +0,0 @@
package service
import (
"crypto/ed25519"
"errors"
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/capytal/comicverse/repository"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
)
type Token struct {
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
repo *repository.Token
log *slog.Logger
assert tinyssert.Assertions
}
func NewToken(cfg TokenConfig) *Token {
cfg.Assertions.NotZero(cfg.PrivateKey)
cfg.Assertions.NotZero(cfg.PublicKey)
cfg.Assertions.NotZero(cfg.Repository)
cfg.Assertions.NotZero(cfg.Logger)
return &Token{
privateKey: cfg.PrivateKey,
publicKey: cfg.PublicKey,
repo: cfg.Repository,
log: cfg.Logger,
assert: cfg.Assertions,
}
}
type TokenConfig struct {
PrivateKey ed25519.PrivateKey
PublicKey ed25519.PublicKey
Repository *repository.Token
Logger *slog.Logger
Assertions tinyssert.Assertions
}
func (svc *Token) Issue(user model.User) (string, error) { // TODO: Return a refresh token
svc.assert.NotNil(svc.privateKey)
svc.assert.NotNil(svc.log)
svc.assert.NotZero(user)
log := svc.log.With(slog.String("user_id", user.ID.String()))
log.Info("Issuing new token")
defer log.Info("Finished issuing token")
jti, err := uuid.NewV7()
if err != nil {
return "", fmt.Errorf("service: failed to generate token UUID: %w", err)
}
now := time.Now()
expires := now.Add(30 * 24 * time.Hour) // TODO: Make the JWT short lived and use refresh tokens to create new JWTs
t := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.RegisteredClaims{
Issuer: "comicverse", // TODO: Make application ID and Name be a parameter
Subject: user.ID.String(),
Audience: jwt.ClaimStrings{"comicverse"}, // TODO: When we have third-party apps integration, this should be the name/URI/id of the app
ExpiresAt: jwt.NewNumericDate(expires),
NotBefore: jwt.NewNumericDate(now),
IssuedAt: jwt.NewNumericDate(now),
ID: jti.String(),
})
signed, err := t.SignedString(svc.privateKey)
if err != nil {
return "", fmt.Errorf("service: failed to sign token: %w", err)
}
// TODO: Store refresh tokens in repo
err = svc.repo.Create(model.Token{
ID: jti,
UserID: user.ID,
DateCreated: now,
DateExpires: expires,
})
if err != nil {
return "", fmt.Errorf("service: failed to save token: %w", err)
}
return signed, nil
}
func (svc Token) Parse(tokenStr string) (*jwt.Token, error) {
svc.assert.NotNil(svc.publicKey)
svc.assert.NotNil(svc.log)
log := svc.log.With(slog.String("preview_token", tokenStr[0:5]))
log.Info("Parsing token")
defer log.Info("Finished parsing token")
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
return svc.publicKey, nil
}, jwt.WithValidMethods([]string{(&jwt.SigningMethodEd25519{}).Alg()}))
if err != nil {
log.Error("Invalid token", slog.String("error", err.Error()))
return nil, fmt.Errorf("service: invalid token: %w", err)
}
// TODO: Check issuer and if the token was issued at the correct date
// TODO: Structure token claims type
_, ok := token.Claims.(jwt.MapClaims)
if !ok {
log.Error("Invalid claims type", slog.String("claims", fmt.Sprintf("%#v", token.Claims)))
return nil, fmt.Errorf("service: invalid claims type")
}
return token, nil
}
func (svc Token) Revoke(token *jwt.Token) error {
svc.assert.NotNil(svc.log)
svc.assert.NotNil(svc.repo)
svc.assert.NotNil(token)
claims, ok := token.Claims.(jwt.RegisteredClaims)
if !ok {
return errors.New("service: invalid claims type")
}
log := svc.log.With(slog.String("token_id", claims.ID))
log.Info("Revoking token")
defer log.Info("Finished revoking token")
jti, err := uuid.Parse(claims.ID)
if err != nil {
return fmt.Errorf("service: invalid token UUID: %w", err)
}
user, err := uuid.Parse(claims.Subject)
if err != nil {
return fmt.Errorf("service: invalid token subject UUID: %w", err)
}
// TODO: Mark tokens as revoked instead of deleting them
err = svc.repo.Delete(jti, user)
if err != nil {
return fmt.Errorf("service: failed to delete token: %w", err)
}
return nil
}
func (svc Token) IsRevoked(token *jwt.Token) (bool, error) {
svc.assert.NotNil(svc.log)
svc.assert.NotNil(svc.repo)
svc.assert.NotNil(token)
claims, ok := token.Claims.(jwt.RegisteredClaims)
if !ok {
return false, errors.New("service: invalid claims type")
}
log := svc.log.With(slog.String("token_id", claims.ID))
log.Info("Checking if token is revoked")
defer log.Info("Finished checking if token is revoked")
jti, err := uuid.Parse(claims.ID)
if err != nil {
return false, fmt.Errorf("service: invalid token UUID: %w", err)
}
user, err := uuid.Parse(claims.Subject)
if err != nil {
return false, fmt.Errorf("service: invalid token subject UUID: %w", err)
}
_, err = svc.repo.Get(jti, user)
if errors.Is(err, repository.ErrNotFound) {
return true, nil
} else if err != nil {
return false, fmt.Errorf("service: failed to get token: %w", err)
}
return false, nil
}

View File

@@ -1,95 +0,0 @@
package service
import (
"errors"
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/capytal/comicverse/repository"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type User struct {
repo *repository.User
assert tinyssert.Assertions
log *slog.Logger
}
func NewUser(repo *repository.User, logger *slog.Logger, assert tinyssert.Assertions) *User {
assert.NotNil(repo)
assert.NotNil(logger)
return &User{repo: repo, assert: assert, log: logger}
}
func (svc *User) Register(username, password string) (model.User, error) {
svc.assert.NotNil(svc.repo)
svc.assert.NotNil(svc.log)
log := svc.log.With(slog.String("username", username))
log.Info("Registering user")
defer log.Info("Finished registering user")
if _, err := svc.repo.GetByUsername(username); err == nil {
return model.User{}, ErrUsernameAlreadyExists
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return model.User{}, errors.New("service: unable to generate password hash")
}
id, err := uuid.NewV7()
if err != nil {
return model.User{}, fmt.Errorf("service: unable to create user id", err)
}
now := time.Now()
u := model.User{
ID: id,
Username: username,
Password: hash,
DateCreated: now,
DateUpdated: now,
}
u, err = svc.repo.Create(u)
if err != nil {
return model.User{}, fmt.Errorf("service: failed to create user model: %w", err)
}
return u, nil
}
func (svc *User) Login(username, password string) (user model.User, err error) {
svc.assert.NotNil(svc.repo)
svc.assert.NotNil(svc.log)
log := svc.log.With(slog.String("username", username))
log.Info("Logging in user")
defer log.Info("Finished logging in user")
user, err = svc.repo.GetByUsername(username)
if err != nil {
return model.User{}, fmt.Errorf("service: unable to find user: %w", err)
}
err = bcrypt.CompareHashAndPassword(user.Password, []byte(password))
if err != nil {
return model.User{}, fmt.Errorf("service: unable to compare passwords: %w", err)
}
return user, nil
}
var (
ErrUsernameAlreadyExists = errors.New("service: username already exists")
ErrPasswordTooLong = bcrypt.ErrPasswordTooLong
ErrIncorrectPassword = bcrypt.ErrMismatchedHashAndPassword
)

Submodule smalltrip deleted from 3d201d2122

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

3
src/lib/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { default as s3 } from './s3';
export { default as db } from './sqlite';
export * from './sqlite'

19
src/lib/s3.ts Normal file
View File

@@ -0,0 +1,19 @@
import {
AWS_ENDPOINT_URL,
AWS_ACCESS_KEY_ID,
AWS_DEFAULT_REGION,
AWS_SECRET_ACCESS_KEY
} from '$env/static/private';
import * as Minio from 'minio';
const client = new Minio.Client({
endPoint: AWS_ENDPOINT_URL.split(':')[0],
port: Number(AWS_ENDPOINT_URL.split(':')[1]),
useSSL: false,
region: AWS_DEFAULT_REGION,
accessKey: AWS_ACCESS_KEY_ID,
secretKey: AWS_SECRET_ACCESS_KEY
});
export default client;

37
src/lib/sqlite.ts Normal file
View File

@@ -0,0 +1,37 @@
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
const db = await open({
filename: 'data.db',
driver: sqlite3.cached.Database
});
await db.exec(`
CREATE TABLE IF NOT EXISTS projects (
ID text NOT NULL,
Name text NOT NULL,
PRIMARY KEY(ID)
)
`);
type Project = {
id: string;
title: string;
pages: Page[];
};
type Page = {
title: string;
src: string;
background: string;
iteraction: Iteraction[];
};
type Iteraction = {
x: number;
y: number;
link: string;
};
export type { Project, Iteraction, Page };
export default db;

21
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,21 @@
<svelte:head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.colors.min.css"
/>
<meta name="color-scheme" content="dark" />
</svelte:head>
<main>
<slot></slot>
</main>
<style>
main {
width: 100vw;
height: 100vh;
}
</style>

View File

@@ -0,0 +1,40 @@
import { fail, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db, s3, type Project } from '$lib';
import { AWS_S3_DEFAULT_BUCKET } from '$env/static/private';
export const load = (async ({}) => {
const res = await db.all<Project[]>('SELECT ID, Name FROM projects');
return { projects: res };
}) as PageServerLoad;
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const name = data.get('project-name');
if (!name) return fail(400, { name, missing: true });
const uuid = crypto.randomUUID().split('-')[0];
const res = await db.run('INSERT OR IGNORE INTO projects (ID, Name) VALUES (:id, :name)', {
':id': uuid,
':name': name
});
const project: Project = {
id: uuid,
title: name.toString(),
pages: []
};
await s3.putObject(AWS_S3_DEFAULT_BUCKET, `${uuid}/project.json`, JSON.stringify(project));
if (res.changes == undefined) {
return fail(500, { reason: 'Failed to insert project into database' });
}
return { success: true };
}
};

46
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { Project } from '$lib';
import type { PageData } from './$types';
export let data: PageData;
if ((data.projects.length + 1) % 3 !== 0) {
data.projects.push({ Name: '', ID: '' });
data.projects.push({ Name: '', ID: '' });
}
</script>
<section>
{#each data.projects as p}
<article>
<h1><a data-sveltekit-reload href={`/projects/${p.ID}`}>{p.Name}</a></h1>
<p class="id">{p.ID}</p>
</article>
{/each}
<article>
<form method="POST">
<fieldset role="group">
<input type="text" name="project-name" placeholder="Project Name" required />
<input type="submit" value="Create" />
</fieldset>
</form>
</article>
</section>
<style>
section {
display: grid;
@media (min-width: 768px) {
grid-template-columns: repeat(3, minmax(0%, 1fr));
}
grid-column-gap: var(--pico-grid-column-gap);
grid-row-gap: var(--pico-grid-row-gap);
padding: 1rem var(--pico-grid-row-gap);
}
.id {
font-size: 0.7rem;
opacity: 0.3;
}
</style>

View File

@@ -0,0 +1,41 @@
import { type RequestHandler } from '@sveltejs/kit';
import stream from 'node:stream/promises';
import { db, s3, type Project } from '$lib';
import { AWS_S3_DEFAULT_BUCKET } from '$env/static/private';
import { extname } from 'node:path';
export const GET = (async ({ params }) => {
const file = await s3.getObject(AWS_S3_DEFAULT_BUCKET, `${params.project}/${params.file}`);
file.on('error', (err: any) => {
console.log(err);
});
let chunks: Buffer[] = [];
let buf;
file.on('data', (chunk) => {
chunks.push(Buffer.from(chunk));
});
file.on('end', () => {
buf = Buffer.concat(chunks);
});
await stream.finished(file)
let res = new Response(buf);
res.headers.set(
'Content-Type',
(() => {
switch (extname(params.file!)) {
case '.png':
return 'image/png';
case '.json':
return 'application/json';
}
return 'text/plain';
})()
);
res.headers.set('Cache-Control', 'max-age=604800')
return res;
}) as RequestHandler;

View File

@@ -0,0 +1,101 @@
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import stream from 'node:stream/promises';
import { db, s3, type Project } from '$lib';
import { AWS_S3_DEFAULT_BUCKET } from '$env/static/private';
import { extname } from 'node:path';
export const prerender = false;
export const ssr = false;
export const load = (async ({ params }) => {
const res = await db.get<{ id: string; name: string }>(
'SELECT ID, Name FROM projects WHERE ID = ?',
params.id
);
if (res === undefined) {
return fail(404, { reason: 'Failed to find project into database' });
}
const project = await s3.getObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`);
project.on('error', (err: any) => {
console.log(err);
});
let p: string = '';
project.on('data', (chunk: any) => {
p += chunk;
});
await stream.finished(project);
let proj = JSON.parse(p) as Project;
return { project: proj };
}) as PageServerLoad;
export const actions = {
delete: async ({ params }) => {
const res = await db.run('DELETE FROM projects WHERE ID = ?', params.id);
await s3.removeObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`);
if (res === undefined) {
return fail(500, { reason: 'Failed to delete project' });
}
redirect(303, '/');
},
'delete-file': async ({ params, request }) => {
const form = await request.formData();
const file = form?.get('file') as string;
const project = await s3.getObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`);
project.on('error', (err: any) => {
console.log(err);
});
let p: string = '';
project.on('data', (chunk: any) => {
p += chunk;
});
await stream.finished(project);
let proj = JSON.parse(p) as Project;
proj.pages = proj.pages.filter((p) => p.src != file);
await s3.removeObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/${file}`);
await s3.putObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`, JSON.stringify(proj));
},
addpage: async ({ request, params }) => {
const form = await request.formData();
const file = form?.get('file') as File;
const title = form?.get('title') as string;
const color = form?.get('color') as string;
const iteractions = form?.get('iteractions') as string;
console.log(file);
const project = await s3.getObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`);
project.on('error', (err: any) => {
console.log(err);
});
let p: string = '';
project.on('data', (chunk: any) => {
p += chunk;
});
await stream.finished(project);
let proj = JSON.parse(p) as Project;
const filename = `${crypto.randomUUID().split('-')[0]}${extname(file?.name)}`;
proj.pages.push({
title: title,
src: filename,
background: color,
iteraction: JSON.parse(iteractions)
});
const buf = Buffer.from(await file.arrayBuffer());
await s3.putObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`, JSON.stringify(proj));
await s3.putObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/${filename}`, buf);
}
} as Actions;

View File

@@ -0,0 +1,384 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { arrayBufferToBlob } from 'blob-util';
import IImage from './IteractiveImage.svelte';
export let data: PageData;
const pages = data.project.pages;
let modal = false;
let reader: Element;
let scroll: number;
let color = hexToRgb(pages[0]?.background ?? '#181818');
let currentPage = 0;
let colorPerc = 0;
let currentColor = color;
let nextColor = hexToRgb(pages[1]?.background ?? pages[0]?.background ?? '#181818');
let currentChunk = 0;
let nextChunk = 0;
let browser = false;
let maxScroll = 0;
let chunk = 0;
let chunks: number[] = [];
onMount(() => {
browser = true;
maxScroll = Math.max(reader.scrollHeight - reader.clientHeight);
});
function hexToRgb(color: string): number[] {
return [
parseInt(color.substring(1, 3), 16) / 255,
parseInt(color.substring(3, 5), 16) / 255,
parseInt(color.substring(5, 7), 16) / 255
];
}
function rgbToHex(rgb: number[]): string {
return `#${
Math.round(rgb[0] * 255)
.toString(16)
.padStart(2, '0') +
Math.round(rgb[1] * 255)
.toString(16)
.padStart(2, '0') +
Math.round(rgb[2] * 255)
.toString(16)
.padStart(2, '0')
}`;
}
function blendRgbColors(c1: number[], c2: number[], ratio: number): number[] {
return [
c1[0] * (1 - ratio) + c2[0] * ratio,
c1[1] * (1 - ratio) + c2[1] * ratio,
c1[2] * (1 - ratio) + c2[2] * ratio
];
}
let fileInput: Element;
let blobUrl: string | undefined = undefined;
let currentIteraction: { x: number; y: number; link: string };
let iteractionUrl = '';
let iteractions: { x: number; y: number; link: string }[] = [];
let imageElement: Element;
let imageX = 0;
let imageY = 0;
let imageWidth = 0;
let imageHeight = 0;
function readFile(file: Blob) {
let reader = new FileReader();
reader.onloadend = function (e) {
let buf = e.target?.result;
let blob = arrayBufferToBlob(buf as ArrayBuffer, file.type);
blobUrl = window.URL.createObjectURL(blob);
};
reader.readAsArrayBuffer(file);
}
let temp: any;
let images: Map<string, { width: number; height: number }> = new Map();
</script>
{#if browser}
<pre style="position: fixed; bottom: 0; font-size: 0.6rem;">
<code
>{JSON.stringify(
{
page: currentPage,
color: {
background: rgbToHex(color),
current: rgbToHex(currentColor),
next: rgbToHex(nextColor),
percentage: Math.round(colorPerc)
},
scroll: {
current: scroll,
max: maxScroll,
chunks: chunks,
currentChunk: currentChunk,
nextChunk: nextChunk
}
},
null,
2
)}
</code>
</pre>
<details style="position: fixed; bottom: 0; font-size: 0.6rem;">
<pre>
<code>{JSON.stringify(pages)}</code>
</pre>
</details>
{/if}
<dialog open={modal}>
<article>
<header>
<button
aria-label="Close"
rel="prev"
on:click={() => {
modal = false;
}}
></button>
<p>
<strong>Add new page</strong>
</p>
</header>
<form method="POST" action="?/addpage" enctype="multipart/form-data">
<input type="text" required placeholder="Page title" name="title" />
<input type="color" required placeholder="Background color" name="color" />
{#if blobUrl}
<div class="blob-image">
<div class="blob-image-image">
<div
class="iteraction-box"
style={`${[
`margin-left:${Math.round((imageX / 100) * imageWidth)}px;`,
`margin-top:${Math.round((imageY / 100) * imageHeight)}px;`
].join('')} pointer-events: none;`}
></div>
{#each iteractions as i}
<a
class="iteraction-box"
href={i.link}
style={[
`margin-left:${Math.round((i.x / 100) * imageWidth)}px;`,
`margin-top:${Math.round((i.y / 100) * imageHeight)}px;`
].join('')}
></a>
{/each}
<img
style="margin: auto 0;"
src={blobUrl}
bind:this={imageElement}
on:mousemove={(e) => {
let rect = imageElement.getBoundingClientRect();
imageX = Math.round(((e.clientX - rect.left) / rect.width) * 100);
imageY = Math.round(((e.clientY - rect.top) / rect.height) * 100);
imageWidth = rect.width;
imageHeight = rect.height;
}}
on:click={() => {
currentIteraction ||= { x: 0, y: 0, link: '' };
currentIteraction.x = imageX;
currentIteraction.y = imageY;
}}
alt=""
/>
</div>
<fieldset role="group">
<input
type="url"
placeholder="Iteraction url"
bind:value={iteractionUrl}
on:change={() => {
currentIteraction ||= { x: 0, y: 0, link: '' };
currentIteraction.link = iteractionUrl;
}}
on:mouseout={() => {
currentIteraction ||= { x: 0, y: 0, link: '' };
currentIteraction.link = iteractionUrl;
}}
/>
<input
type="button"
value="Add"
on:click|preventDefault={() => {
iteractions.push({
x: currentIteraction.x,
y: currentIteraction.y,
link: currentIteraction.link
});
iteractions = iteractions;
}}
/>
</fieldset>
<div>
<code>{imageX} {imageY}</code>
<code>
Iteraction: {JSON.stringify(currentIteraction)}
</code>
<input
style="display:hidden;"
type="text"
value={JSON.stringify(iteractions.filter(Boolean))}
name="iteractions"
/>
</div>
</div>
{/if}
<input
type="file"
required
name="file"
bind:this={fileInput}
on:change={() => {
// @ts-ignore
readFile(fileInput.files[0]);
}}
/>
<input type="submit" value="Add page" />
</form>
</article>
</dialog>
<section class="project">
<aside>
<a data-sveltekit-reload href="/" style="font-size: 0.5rem;">Return home</a>
<section>
<h1>{data.project.title}</h1>
<p class="id">{data.project.id}</p>
<button
class="add"
on:click={() => {
modal = true;
}}>Add page</button
>
<form method="POST">
<input type="submit" formaction="?/delete" value="Delete" class="pico-background-red-500" />
</form>
</section>
</aside>
{#key maxScroll}
{#if browser}
<article
class="reader"
style={`--bg-color: rgba(${color.map((c) => c * 255).join(',')}, 0.8)`}
bind:this={reader}
on:scroll={() => {
scroll = reader.scrollTop;
if (maxScroll === 0) {
maxScroll = Math.max(reader.scrollHeight - reader.clientHeight);
chunk = Math.round(maxScroll / pages.length);
for (let i = 0; i < pages.length; i++) {
chunks = [...chunks, chunk * i];
}
}
let i = chunks.findIndex((c) => c > scroll - chunk);
currentColor = hexToRgb(pages[i]?.background);
nextColor = pages[i + 1]?.background ? hexToRgb(pages[i + 1]?.background) : currentColor;
currentChunk = chunks[i];
nextChunk = chunks[i + 1] ?? maxScroll;
colorPerc = ((scroll - currentChunk) / (nextChunk - currentChunk)) * 100;
color = blendRgbColors(currentColor, nextColor, colorPerc / 100);
currentPage = i;
}}
>
<div class="pages">
{#each pages as page, key}
{@const coord = key * chunk}
<div class="page" style={`background-color:${page.background}`}>
<IImage {page} projectId={data.project.id} />
<form method="POST" action="?/delete-file" class="delete-file">
<fieldset role="group">
<input type="text" value={`${page.src}`} name="file" />
<input type="submit" value="Delete page" class="pico-background-red-500" />
</fieldset>
</form>
</div>
<code>{coord}</code>
{/each}
</div>
</article>
{/if}
{/key}
</section>
<style>
h1 {
font-size: 1.5rem;
}
.id {
font-size: 0.5rem;
opacity: 0.3;
}
.iteraction-box {
width: 30px;
height: 30px;
display: block;
background-color: #ff0000;
opacity: 0.3;
position: absolute;
z-index: 100;
}
.blob-image-image {
position: relative;
}
.reader {
display: flex;
width: 80vw;
height: 100vh;
justify-content: center;
padding-top: 5rem;
padding-bottom: 5rem;
margin-bottom: 0;
background-color: var(--bg-color);
overflow-y: scroll;
}
.page {
width: calc(1080px / 3.5);
min-height: calc(1920px / 3.5);
@media (min-width: 1024px) {
width: calc(1080px / 2.5);
min-height: calc(1920px / 2.5);
}
background-color: #fff;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0;
box-shadow: 0rem 1rem 1rem 0rem rgba(0, 0, 0, 0.5);
& form {
margin: 1rem;
margin-bottom: 0;
}
}
.pages {
display: flex;
flex-direction: column;
gap: 1rem;
}
.project {
display: flex;
margin-bottom: 0;
}
.add {
width: 100%;
margin-bottom: 0.5rem;
}
aside {
padding: 1rem;
width: 20vw;
& * {
font-size: 0.8rem !important;
@media (min-width: 1024px) {
font-size: unset;
}
}
}
</style>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { onMount } from 'svelte';
type Page = {
title: string;
src: string;
background: string;
iteraction: Iteraction[];
};
type Iteraction = {
x: number;
y: number;
link: string;
};
export let page: Page;
export let projectId: string;
let image: Element;
let width: number;
let height: number;
let browser = false;
function setCoords() {
let rect = image.getBoundingClientRect();
width = rect.width;
height = rect.height;
}
onMount(() => {
setCoords();
browser = true;
});
</script>
<div style="position: relative;" on:resize={() => setCoords()}>
{#if page.iteraction !== undefined && browser}
{#each page.iteraction as i}
<a
class="iteraction-box"
href={i.link}
target="_blank"
style={[
`margin-left:${(i.x / 100) * width}px;`,
`margin-top:${(i.y / 100) * height}px;`
].join('')}
></a>
{/each}
{/if}
<img bind:this={image} width="1080" height="1920" src={`/files/${projectId}/${page.src}`} />
</div>
<style>
.iteraction-box {
width: 30px;
height: 30px;
display: block;
background-color: #ff0000;
opacity: 0.3;
position: absolute;
z-index: 100;
}
</style>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
svelte.config.js Normal file
View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

1
temp.json Normal file
View File

@@ -0,0 +1 @@
{"id":"88ba21ea","title":"test project","pages":[]}

View File

@@ -1,59 +0,0 @@
{{define "dashboard"}} {{template "layout-page-start" (args "Title"
"Dashboard")}}
<main class="h-full w-full justify-center px-5 py-10 align-middle">
{{if and (ne . nil) (ne (len .) 0)}}
<section class="flex h-64 flex-col gap-5">
<div class="flex justify-between">
<h2 class="text-2xl">Projects</h2>
<form action="/p/" method="post">
<button
class="rounded-full bg-slate-700 p-1 px-3 text-sm text-slate-100"
>
New project
</button>
</form>
</div>
<div
class="grid h-full grid-flow-col grid-rows-1 justify-start gap-5 overflow-scroll"
>
{{range .}}
<div class="w-38 grid h-full grid-rows-2 bg-slate-500">
<div class="bg-blue-500 p-2">Image</div>
<div class="p-2">
<a href="/p/{{.ID}}/">
<h3>{{.Title}}</h3>
<p class="hidden">{{.ID}}</p>
</a>
<form action="/p/{{.ID}}/" method="post">
<input type="hidden" name="x-method" value="delete" />
<button
class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100"
>
Delete
</button>
</form>
</div>
</div>
{{end}}
</div>
</section>
{{else}}
<div
class="fixed flex h-screen w-full items-center justify-center top-0 left-0"
>
<form action="/p/" method="post" class="bg-slate-300 rounded-full">
<input
type="text"
name="title"
placeholder="Project title"
required
class="pl-5"
/>
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
New project
</button>
</form>
</div>
{{end}}
</main>
{{template "layout-page-end"}} {{end}}

View File

@@ -1,20 +0,0 @@
{{define "landing"}} {{template "layout-page-start" (args "Title"
"ComicVerse")}}
<main class="h-full w-full justify-center px-5 py-10 align-middle">
<div
class="fixed flex flex-col gap-5 h-screen w-full items-center justify-center top-0 left-0"
>
<h1 class="text-3xl font-bold">Welcome back</h1>
<a
href="/login/"
hx-get="/login/"
hx-swap="outerHTML"
hx-select="#login-form"
>
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
Login
</button>
</a>
</div>
</main>
{{template "layout-page-end"}} {{end}}

View File

@@ -1,18 +0,0 @@
{{define "layout-base-start"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link href="/assets/stylesheets/out.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.js"
integrity="sha384-oeUn82QNXPuVkGCkcrInrS1twIxKhkZiFfr2TdiuObZ3n3yIeMiqcRzkIcguaof1"
crossorigin="anonymous"></script>
</head>
{{end}}
{{define "layout-base-end"}}
</html>
{{end}}

View File

@@ -1,16 +0,0 @@
{{define "layout-page-start"}}
{{template "layout-base-start" (args "Title" .Title)}}
<body class="bg-slate-200 text-slate-950 m-0 min-w-screen min-h-screen relative">
<header class="w-full h-7 bg-slate-700 text-slate-50 px-5 flex justify-between top-0 sticky z-100">
<h1>Comicverse</h1>
<ul>
<a href="/dashboard/">Dashboard</a>
</ul>
</header>
{{end}}
{{define "layout-page-end"}}
</body>
{{template "layout-base-end"}}
{{end}}

View File

@@ -1,32 +0,0 @@
{{define "login"}} {{template "layout-page-start" (args "Title" "Login")}}
<main>
<div
class="w-full h-screen fixed top-0 left-0 flex justify-center items-center"
>
<form
action="/login/"
method="post"
enctype="multipart/form-data"
class="h-fit bg-slate-500 grid grid-cols-1 grid-rows-3 p-5 gap-3"
id="login-form"
>
<h1>Login</h1>
<input
type="text"
name="username"
required
class="bg-slate-200 p-1"
placeholder="Username"
/>
<input
type="password"
name="password"
required
class="bg-slate-200 p-1"
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
</div>
</main>
{{template "layout-page-end"}} {{end}}

View File

@@ -1,17 +0,0 @@
{{define "partials-status"}}
{{template "layout-page-start" (args "Title" .Title)}}
<main class="justify-center align-middle w-full h-full">
<div class="text-center">
<h1>{{.StatusCode}}</h1>
<p>{{.Message}}</p>
<a href="{{.Redirect}}">
{{if .RedirectMessage}}
{{.RedirectMessage}}
{{else}}
Go back
{{end}}
</a>
</div>
</main>
{{template "layout-page-end"}}
{{end}}

View File

@@ -1,75 +0,0 @@
{{define "project"}}
{{template "layout-page-start" (args "Title" .Title)}}
<div class="fixed w-full h-full bg-green-500 grid grid-cols-4 grid-rows-1">
<nav class="bg-red-500 h-full">
<h1>{{.Title}}</h1>
<p>{{.ID}}</p>
</nav>
<main class="overflow-y-scroll flex justify-center col-span-3 py-20">
<div class="flex flex-col gap-10 h-fit">
{{range $page := .Pages}}
<section id="{{$page.ID}}" class="w-fit">
<!--
INFO: The interaction form could be another page that is shown
when "Add Interaction" is clicked. Said page could be also a partial
than can replace the current image using htmx, so it is
compatible with JavaScript enabled or not.
-->
<div class="flex flex-row">
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/interactions/" method="post" class="w-100">
<div class="flex">
{{if (gt (len $page.Interactions) 0)}}
<div class="relative flex">
<div class="absolute z-2 w-full h-full top-0 left-0">
{{range $interactionID, $interaction := $page.Interactions}}
<a class="absolute" href="{{$interaction.URL}}"
style="top:{{$interaction.Y}}%;left:{{$interaction.X}}%;">
<span
class="bg-red-200 opacity-10 block w-10 h-10 transform -translate-x-[50%] -translate-y-[50%]"></span>
</a>
{{end}}
</div>
<img src="/projects/{{$.ID}}/pages/{{$page.ID}}/" class="z-1 relative">
</div>
{{else}}
<img src="/projects/{{$.ID}}/pages/{{$page.ID}}/" class="z-1 relative">
{{end}}
<input type="range" min="0" max="100" name="y" style="writing-mode: vertical-lr;">
</div>
<input type="range" min="0" max="100" name="x" class="w-full">
<input type="url" required name="link" class="bg-slate-300" placeholder="url of interaction">
<button class="rounded-full bg-blue-700 p-1 px-3 text-sm text-slate-100">
Add interaction
</button>
</form>
{{if (gt (len $page.Interactions) 0)}}
<div class="flex flex-col gap-2">
{{range $interactionID, $interaction := $page.Interactions}}
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/interactions/{{$interactionID}}/"
method="post">
<input type="hidden" name="x-method" value="delete">
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
&#x1F5D1;&#xFE0F;{{$interaction.URL}}
</button>
</form>
{{end}}
</div>
{{end}}
</div>
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/" method="post">
<input type="hidden" name="x-method" value="delete">
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
Delete
</button>
</form>
</section>
{{end}}
<form action="/projects/{{.ID}}/pages/" method="post" enctype="multipart/form-data">
<input type="file" name="image" required>
<button>Add new page</button>
</form>
</div>
</main>
</div>
{{template "layout-page-end"}}
{{end}}

View File

@@ -1,32 +0,0 @@
{{define "register"}} {{template "layout-page-start" (args "Title" "Login")}}
<main>
<div
class="w-full h-screen fixed top-0 left-0 flex justify-center items-center"
>
<form
action="/register/"
method="post"
enctype="multipart/form-data"
class="h-fit bg-slate-500 grid grid-cols-1 grid-rows-3 p-5 gap-3"
id="login-form"
>
<h1>Login</h1>
<input
type="text"
name="username"
required
class="bg-slate-200 p-1"
placeholder="Username"
/>
<input
type="password"
name="password"
required
class="bg-slate-200 p-1"
placeholder="Password"
/>
<button type="submit">Register</button>
</form>
</div>
</main>
{{template "layout-page-end"}} {{end}}

View File

@@ -1,77 +0,0 @@
package templates
// INFO: This will probably become a new lib in loreddev/x at some point
import (
"embed"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
)
var (
patterns = []string{"*.html", "layouts/*.html", "partials/*.html"}
functions = template.FuncMap{
"args": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, errors.New("misaligned map in template arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("cannot use type %T as map key", pairs[i])
}
m[key] = pairs[i+1]
}
return m, nil
},
}
)
//go:embed *.html layouts/*.html partials/*.html
var embedded embed.FS
var temps = template.Must(template.New("templates").Funcs(functions).ParseFS(embedded, patterns...))
func Templates() *template.Template {
return temps // TODO: Support for local templates/hot-reloading without rebuild
}
func NewHotTemplates(fsys fs.FS) *HotTemplate {
return &HotTemplate{
fs: fsys,
}
}
type HotTemplate struct {
fs fs.FS
template *template.Template
}
func (t *HotTemplate) Execute(wr io.Writer, data any) error {
te, err := template.New("hot-templates").Funcs(functions).ParseFS(t.fs, patterns...)
if err != nil {
return err
}
return te.Execute(wr, data)
}
func (t *HotTemplate) ExecuteTemplate(wr io.Writer, name string, data any) error {
te, err := template.New("hot-templates").Funcs(functions).ParseFS(t.fs, patterns...)
if err != nil {
return err
}
return te.ExecuteTemplate(wr, name, data)
}
type ITemplate interface {
Execute(wr io.Writer, data any) error
ExecuteTemplate(wr io.Writer, name string, data any) error
}

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 3000,
host: '192.168.1.7'
}
});

1
x

Submodule x deleted from 6ea200aa64