152 Commits

Author SHA1 Message Date
965ea28884 feat(router,template): creation of new publications via dashboard 2025-11-20 15:22:29 -03:00
daf4844bcb feat(internals): randname library to generate random names 2025-11-20 15:21:58 -03:00
e438c0c850 feat(editor,router,cmd): router and server setup to run editor 2025-11-20 15:16:23 -03:00
8d75630f5c feat(editor): new editor struct for acessing epub containers 2025-11-20 15:13:04 -03:00
aecb142a26 feat(editor,epub): epub definitions for golang encoding/xml 2025-11-20 15:09:35 -03:00
b0dce3c29f feat(editor,storage): storage library to abstract writabble file system 2025-11-20 15:07:53 -03:00
ff0ab4c2c9 feat(editor): init editor submodule 2025-11-18 16:13:42 -03:00
77975c8f9d feat(publication): random name for publication on creation 2025-11-18 13:31:45 -03:00
6daaaaa6fd refactor(publications,projects): renames projects to publications 2025-11-18 13:31:44 -03:00
bf817a14c7 feat(router): use new smalltrip APIs 2025-11-13 14:53:46 -03:00
3e4380d9f8 chore(deps): update to go 1.25.2 2025-11-13 14:18:36 -03:00
898da7c206 chore(ci): remove unused workflow 2025-11-13 14:14:50 -03:00
d90cebb478 feat(ipub,soundtrck): on screen threshold trigger to start playing 2025-11-13 14:13:58 -03:00
f76be67247 feat(ipub): debug attribute and overlay 2025-11-04 18:40:00 -03:00
c90bff53a3 chore(ipub): move IPUBBody and IPUBCover definitions to top 2025-11-04 18:38:07 -03:00
7d1a21430c feat(ipub): soundtrack support with fade transition 2025-11-04 10:54:36 -03:00
a3c2efd5b0 feat(ipub): make ipub-background containerized into ipub-body 2025-11-04 10:54:36 -03:00
77631f2a6c feat(ipub): ipub-cover, forcing user to interact to enable autoplay 2025-11-03 15:24:21 -03:00
185001308d feat(ipub): use ipub-body to handle custom elements defining 2025-11-03 15:24:20 -03:00
bbb9ad0e35 feat(ipub): ipub-body element
this replaces the ipub-content element, and makes ipub publications
limited/containerized into one element.
2025-11-03 15:24:20 -03:00
6a7abdea6f feat(ipub): make #ensureID private and remove old code 2025-10-30 09:42:34 -03:00
b52e7f165f feat(ipub): change all section images to ipub-images 2025-10-30 09:42:34 -03:00
d775301567 feat(ipub): make sticky and fade backgrounds the default 2025-10-17 19:23:41 -03:00
ba7ca52ed2 feat(ipub): ipub-interaction element 2025-10-17 18:05:12 -03:00
185ca863fe feat(ipub): base elements on IPUBElement
This makes the random hex ID logic shared between all elements
2025-10-17 18:05:11 -03:00
11456db9c4 feat(ipub): ipub-image element 2025-10-17 18:05:11 -03:00
d556b0eefe feat(ipub): ipub-content element 2025-10-17 18:05:11 -03:00
007de6b9f1 feat(ipub): sticky background implementation via web components 2025-10-17 18:01:53 -03:00
60c9d3624a chore: update module definition 2025-10-13 15:26:31 -03:00
d0463ee0c0 chore: use new repository for loreddev/smalltrip 2025-10-13 14:58:48 -03:00
306a9c9adc chore: update loreddev/x submodule origin 2025-10-13 14:58:30 -03:00
caf43ad920 chore: ignore *.epub files 2025-08-12 17:19:58 -03:00
20274ffdf2 chore(ipub,example): remove .epub file from repository 2025-08-12 17:19:23 -03:00
f0d6207fd9 fix(ipub,example): add audio and script to content spine 2025-08-12 17:18:33 -03:00
b5949413d9 fix(ipub,example): make layout of epub pre-paginated 2025-08-12 17:18:17 -03:00
eeb4d2b9b3 feat(ipub): on-screen interaction trigger 2025-08-12 17:17:30 -03:00
fc6afa28d7 chore(ipub): formatting using html tidy 2025-08-12 17:17:15 -03:00
ea04b14751 feat(ipub): point interaction aspect ratio 2025-08-12 17:16:26 -03:00
ef0c5b0266 chore(ipub): example ipub file initial commit 2025-07-31 19:18:28 -03:00
642ac17c7a feat(router,deps): update x package and use smalltrip/problem instead of smalltrip/exception 2025-07-30 19:15:19 -03:00
99606f65f3 feat(router): project controller and routes 2025-06-26 19:11:17 -03:00
09dc059630 style(templates): format dashboard template 2025-06-26 19:11:17 -03:00
17dee3141b feat(router,app): pass project service to router 2025-06-26 19:11:16 -03:00
58a02dd90c feat(service,project): project service to manage project and project's permissions
Probably in the future, permissions will be separated into their own
service.
2025-06-26 19:11:16 -03:00
aeda9be57c fix(app): token repository logging group 2025-06-26 19:11:14 -03:00
bdc99c103a refactor(service): use Errorf instead of errors.Join 2025-06-26 19:11:14 -03:00
3e5095428e feat(repo,project): get by ID and IDs methods 2025-06-26 19:11:13 -03:00
9ca8b9ff42 fix(repo,project): projects table initiation 2025-06-26 19:11:12 -03:00
e8b429720b feat(model,repo,permission): permissions repository and model 2025-06-26 19:11:11 -03:00
8ad87ea2e3 refactor(user,router): return uuid in UserContext.GetUserID 2025-06-16 07:09:15 -03:00
46540e6482 feat(user,router): UserContext struct
this is intended to better structure ways to get information about the
user and the session context values
2025-06-16 07:08:55 -03:00
3554d3f3ad feat(user,router): userMiddleware to provide context of what user is logged in 2025-06-16 07:08:54 -03:00
07785992c3 feat(user,router): use token service to issue tokens 2025-06-16 07:08:54 -03:00
c14f44e81c refactor(user,router): move arguments to struct cfg 2025-06-16 07:08:52 -03:00
2e673c8c75 feat(router): add token service to router 2025-06-16 07:08:50 -03:00
c02ab731b7 feat(app): provide public and private keys to comicverse app 2025-06-16 07:08:50 -03:00
2df6cd14fb feat(cmd): parse public and private ed256 keys env variables 2025-06-16 07:08:50 -03:00
826ea4088a refactor(service,token): provide arguments via cfg struct 2025-06-16 07:08:47 -03:00
5d23372bd4 fix(service,token): unable to cast claims type (jwt always return MapClaims) 2025-06-16 07:08:47 -03:00
bbfeb08265 feat(service,token): add logs to token parsing method 2025-06-16 07:08:46 -03:00
492bbfd653 fix(service,token): incorrect algorithm being used to parse 2025-06-16 07:08:46 -03:00
efd7867d61 fix(service,token): missing userID pass to repository model 2025-06-13 19:16:35 -03:00
c40f3cc9f0 fix(service,user): update UsernameExists error 2025-06-13 19:16:34 -03:00
8a014f617c fix(service,user): missing logger value pass to struct 2025-06-13 19:16:34 -03:00
5be4378aff fix(repo,token): create table query using old uuid column 2025-06-13 19:16:33 -03:00
a9b74b5d95 fix(repo,token): properly close rows in case of error 2025-06-13 19:16:32 -03:00
935b0874e3 chore(ci): disable todo tracker 2025-06-13 19:13:22 -03:00
3cf79b047c fix(ci): use ubuntu-latest instead of alpine 2025-06-13 19:11:23 -03:00
66e37831fc fix(ci): use local instance actions 2025-06-13 19:11:21 -03:00
320cfecc58 fix(ci): tdg-forgejo-action uri 2025-06-13 19:11:17 -03:00
7d80cac994 feat(ci): add TODOs tracker 2025-06-13 19:11:15 -03:00
e5e9f1dea6 test 2025-06-12 19:17:51 -03:00
cd4acd5a98 feat(service,token): token.IsRevoke method 2025-06-10 19:06:32 -03:00
fbb4b1da53 feat(service,token): token.Revoke method 2025-06-10 19:06:25 -03:00
7bc60988c2 feat(service,token): token.Parse method 2025-06-10 19:06:15 -03:00
c81d9824cd feat(service,token): properly implement token.issue method 2025-06-10 19:06:01 -03:00
05e1b4b84d refactor(service,user): move user-service specific errors to user.go 2025-06-10 19:05:31 -03:00
9a110a814b feat(service,user): add logging to methods 2025-06-10 19:05:00 -03:00
4975a65406 feat(model,token): token model for repository 2025-06-10 19:04:13 -03:00
8e3152159f feat(repository,token): delete token method 2025-06-10 19:01:56 -03:00
27b2e37704 feat(repository,token): get token by user id method 2025-06-10 19:01:46 -03:00
aac89dc604 feat(repository,token): get token method 2025-06-10 19:01:18 -03:00
05eb5f79cc feat(repository,token): create token method 2025-06-10 19:01:01 -03:00
08ba62e469 feat(repository,token): new Token repository 2025-06-10 18:59:52 -03:00
9e87966e35 feat(service,user): generate ID for users on creation 2025-06-10 18:31:25 -03:00
b33b82b272 feat(service,user): update repository type 2025-06-10 18:31:07 -03:00
6357af3aa2 feat(service,user): add better context for errors 2025-06-10 18:30:39 -03:00
9b158f7b01 refactor(service,user): rename method receiver from s to svc 2025-06-10 18:30:13 -03:00
f2c0fba4b4 refactor(service,user): remove jwt token generation 2025-06-10 18:29:15 -03:00
72e227ac40 fix(repo,project): update queries 2025-06-10 15:08:32 -03:00
db876a9a17 feat(model,project): rename method receiver from m to p 2025-06-10 15:05:42 -03:00
97429ab7cf feat(repo,model,project): rename UUID field and row to just ID 2025-06-10 15:04:42 -03:00
d3589d2c63 feat(repo,model,project): rename UUID field and row to just ID 2025-06-10 15:04:13 -03:00
2d3afd2ad6 feat(repo,project): change Delete method to DeleteByID for clarity 2025-06-10 15:02:18 -03:00
baf602a811 feat(repo,user): rename ProjectRepository to Project (since the package is already named repository) 2025-06-10 15:01:25 -03:00
1189770e55 feat(repo,user): change Delete method to DeleteByID for clarity 2025-06-10 15:00:21 -03:00
1391e5fe9d feat(repo,user): wrap validate check error with ErrInvalidInput 2025-06-10 14:59:40 -03:00
114c00d3e2 feat(repo,user): rename UserRepository to User (since the package is already named repository) 2025-06-10 14:56:54 -03:00
dc2f769f93 feat(repo,user): query by id method 2025-06-10 14:56:03 -03:00
691472071e feat(repo): prefix errors to add context 2025-06-10 14:54:30 -03:00
f73d5918e5 refactor(repo,user): move scan logic to unexported method 2025-06-10 14:54:09 -03:00
5b4978b0ac feat(model,user): user fields validation 2025-06-10 14:53:33 -03:00
3690a4046b feat(repo,user): add IDs to users 2025-06-10 14:46:09 -03:00
00441f9844 feat(repo,user): dont use transactions on select queries 2025-06-10 14:40:07 -03:00
dc7e3aaf57 feat(repo,user): return more structured and contextualized errors 2025-06-10 14:39:47 -03:00
5b1dac140a refactor(repo,user): rename method receiver from r to repo 2025-06-10 14:38:39 -03:00
910b6cef1e refactor(repo,user): use baseRepository 2025-06-10 14:37:31 -03:00
39689ab702 refactor: rename files to their singular form 2025-06-10 10:34:14 -03:00
395f627e33 refactor(router): rename c receiver to ctrl 2025-06-09 19:27:46 -03:00
adf32c1666 feat(model): validate function and Model interface 2025-06-09 19:25:53 -03:00
9caf46ec9f feat(repo): delete projects 2025-06-09 19:25:20 -03:00
8f62d64ae0 feat(repo): update projects 2025-06-09 19:25:10 -03:00
991db9ea7a feat(repo): create projects 2025-06-09 19:24:59 -03:00
0c87bcbf3d feat(repo): projects repository 2025-06-09 19:24:45 -03:00
074ea2fdbc feat(repo): base repository to share a common constructor and logic 2025-06-09 19:24:06 -03:00
347a734df9 feat(model): project model 2025-06-09 19:23:21 -03:00
41a764939b feat: landing page template 2025-06-09 19:21:29 -03:00
dc61ed91d0 feat: update assertions contructor function call 2025-06-09 19:21:19 -03:00
3f767299e2 chore: new debugger make job 2025-06-09 19:20:46 -03:00
d5f13b563e chore: ignore air's tmp directory 2025-06-09 19:20:31 -03:00
7308097c61 chore(service): delete old all-service service 2025-06-09 19:20:06 -03:00
e3ce651288 refactor(router): rename c receiver to ctrl 2025-06-09 19:19:27 -03:00
0e7198f918 feat(router): set session token cookie 2025-06-09 19:19:00 -03:00
f4a971bdae feat(router): show landing page if user is not logged in 2025-06-09 19:18:29 -03:00
8403459cc8 fix(repo): send error value on user insert query exec 2025-06-09 19:17:26 -03:00
4e90fa0063 refactor(repo): rename repositoryDateFormat to dateFormat 2025-06-09 19:16:40 -03:00
f622f774e4 refactor: move shared variables to repository.go file 2025-06-09 19:14:28 -03:00
29f1e8cc8a chore: use fortify for hardeningDisable 2025-06-06 16:36:12 -03:00
0cea250fa4 chore: set environment variables on direnv enter 2025-06-06 16:35:53 -03:00
c3a0be5ec5 feat(templates): register page and form 2025-06-06 16:35:23 -03:00
72b884c2b3 feat(templates): login page and form 2025-06-06 16:35:16 -03:00
d38097a616 feat(router,users): register method for creating a new user 2025-06-06 16:34:59 -03:00
f7396dc12b feat(router,users): return token cookie on login 2025-06-06 16:34:41 -03:00
149823a5fc fix(router,users): correct username form value name on error 2025-06-06 16:34:16 -03:00
56e2214311 feat(router): handle /login and /register routes 2025-06-06 16:33:29 -03:00
a52caf6580 feat(router): provide UserService to router 2025-06-06 16:32:49 -03:00
30eb1a0065 feat(user,service): return signed token of user 2025-06-06 16:32:02 -03:00
106c612e63 feat(user,service): return error on incorect construct parameter 2025-06-06 16:31:31 -03:00
06807b0623 chore(router,service): remove editor and projects endpoint and services
They will be reimplemented later
2025-06-06 16:30:50 -03:00
12844eafee chore: format launch dev debug profile 2025-06-06 16:29:41 -03:00
d5668af2df fix(repo,users): incorrect syntax for columns in select 2025-06-06 16:29:25 -03:00
4bb32f9757 feat(repo,users): add assertions check for struct values 2025-06-06 16:29:11 -03:00
52ac9ed3bc fix(repo,users): missing context value on struct initiation 2025-06-06 16:28:45 -03:00
2bce92e51c fix(repo,users): trailing comma in create table query 2025-06-06 16:28:11 -03:00
28ed7379de feat: user controller 2025-05-30 18:05:40 -03:00
16322b3afd revert: remove database abstraction 2025-05-30 18:05:24 -03:00
5fbe9cd1ad chore: update submodule 2025-05-30 18:05:01 -03:00
f7f2a7fbb8 chore: update deps 2025-05-30 18:04:46 -03:00
b29bfdd1df feat(templates): login page 2025-05-30 18:04:39 -03:00
deaf9089b2 feat(users): init token service 2025-05-30 18:04:28 -03:00
ffad82b32c feat(users): user service 2025-05-30 18:04:16 -03:00
dbf30a9908 feat(users): user repository 2025-05-30 18:03:56 -03:00
85 changed files with 7562 additions and 1406 deletions

View File

@@ -3,3 +3,7 @@ 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

@@ -0,0 +1,7 @@
<?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

@@ -0,0 +1,26 @@
<?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

View File

@@ -0,0 +1,882 @@
"use strict";
class IPUBElement extends HTMLElement {
/**
* @protected
* @type {Readonly<string[]>}
*/
static observedAttributes = ["id", "debug"];
connectedCallback() {
this.#ensureID();
}
attributeChangedCallback(name, _oldValue, _newValue) {
switch (name) {
case "id":
this.#ensureID();
break;
case "debug":
if (this.hasAttribute("debug")) {
this.setDebug(true);
} else {
this.setDebug(false);
}
break;
}
}
/**
* @private
*/
#ensureID() {
if (!this.id) {
this.id = hashFromHTML(this);
}
}
/**
* @returns {boolean}
*/
getDebug() {
return this.hasAttribute("debug");
}
/**
* @param {boolean} state
*/
setDebug(state) {
if (state) {
if (!this.hasAttribute("debug")) {
this.setAttribute("debug", "true");
}
if (!this.style.getPropertyValue(IPUBElement.#PROPERTY_DEBUG_COLOR)) {
this.style.setProperty(
IPUBElement.#PROPERTY_DEBUG_COLOR,
`#${hashFromHTML(this)}`,
);
}
} else {
if (!state && this.hasAttribute("debug")) {
this.removeAttribute("debug");
}
if (
!state &&
this.style.getPropertyValue(IPUBElement.#PROPERTY_DEBUG_COLOR)
) {
this.style.removeProperty(IPUBElement.#PROPERTY_DEBUG_COLOR);
}
}
getAllDescendants(this)
.filter((el) => el.tagName.startsWith("ipub-"))
.forEach((el) => {
el.setDebug?.(state);
});
}
/**
* @private
* @type {Readonly<string>}
*/
static #PROPERTY_DEBUG_COLOR = "--ipub-debug-color";
}
globalThis.addEventListener("load", () => {
console.info("IPUB: Starting IPUB elements");
console.log("IPUB: Defining custom element <ipub-body>");
globalThis.customElements.define(IPUBBody.elementName, IPUBBody);
});
class IPUBBody extends IPUBElement {
static elementName = `ipub-body`;
connectedCallback() {
super.connectedCallback();
this.setAttribute("aria-busy", "true");
// TODO?: Move IPUBCover's "can-play" logic to here
console.log("IPUBBody: Defining custom element <ipub-cover>");
globalThis.customElements.define(IPUBCover.elementName, IPUBCover);
/** @type {IPUBCover} */
const cover = this.querySelector("ipub-cover");
if (!cover) {
// TODO: automatically create IPUBCover element if it doesn't exists
console.error("IPUBBody: Document doesn't has <ipub-cover> element");
this.#initElements();
this.setAttribute("aria-busy", "false");
return;
}
cover.onclose = () => {
this.#initElements();
};
cover.cover();
this.setAttribute("aria-busy", "false");
}
/**
* @private
*/
#initElements() {
for (const e of [
IPUBAudio,
IPUBBackground,
IPUBImage,
IPUBInteraction,
IPUBSoundtrack,
IPUBTrigger,
]) {
console.info(`IPUBBody: Defining custom element <${e.elementName}>`);
globalThis.customElements.define(e.elementName, e);
}
if (this.getDebug()) {
// HACK: Re-trigger IPUBElement debugging logic
console.debug("IPUBBody: triggeing debugger");
this.setAttribute("debug", "true");
}
}
}
class IPUBCover extends IPUBElement {
static elementName = `ipub-cover`;
/**
* @type {() => void} callback
*/
onclose = () => {};
connectedCallback() {
super.connectedCallback();
}
cover() {
console.debug("IPUBCover: Setting up cover");
this.setAttribute("aria-busy", "true");
const dialog = this.querySelector("dialog");
// HACK: Test if we can autoplay interactions, soundtracks, etc
/** @type {HTMLMediaElement | null} */
const media =
this.parentElement.querySelector("audio") ??
this.parentElement.querySelector("video");
if (!media) {
console.log("IPUBCover: no media element found, removing cover");
dialog.close();
this.onclose();
return;
}
const pastVolume = media.volume;
media.volume = 0.1; // don't let the user hear the test audio
media
.play()
.then(() => {
media.pause();
media.volume = pastVolume;
media.currentTime = 0;
console.debug("IPUBCover: Can autoplay interactions, removing cover");
dialog.close();
this.onclose();
})
.catch(() => {
console.debug(
"IPUBCover: Cannot autoplay interactions, covering content",
);
dialog.show();
dialog.parentElement.addEventListener("click", () => {
dialog.close();
this.onclose();
});
this.setAttribute("aria-busy", "false");
return;
});
}
}
class IPUBAudio extends IPUBElement {
static elementName = "ipub-audio";
/**
* @param {boolean} [forced=false]
* @throws {Error}
* @returns {Promise<void>}
*/
play(forced = false) {
if (!this.#audioElement.readyState > HTMLMediaElement.HAVE_CURRENT_DATA) {
throw new Error("IPUBAudio: audio is not ready");
}
if (forced) {
this.setAttribute("forced", "true");
}
return this.#audioElement.play();
}
/**
* @param {boolean} [forced=false]
*/
pause(forced = false) {
if (forced) {
this.setAttribute("forced", "true");
}
this.#audioElement.pause();
}
/**
* @param {boolean} state
*/
setLoop(state) {
this.#audioElement.loop = state;
}
/**
* @returns {boolean}
*/
getLoop() {
return this.#audioElement.loop;
}
connectedCallback() {
super.connectedCallback();
const audio = this.querySelector("audio");
if (!audio) {
console.error("IPUBAudio: Missing child <audio> element");
return;
}
this.#audioElement = audio;
}
/**
* @private
* @type {HTMLAudioElement}
*/
#audioElement;
/**
* @param {number} volume
* @param {Object} options
* @param {number} [options.fadetime=0]
* @param {() => void} [options.onfinish=() => {}]
*/
setVolume(volume, { fadetimeMS = 0, onFinishFade = () => {} } = {}) {
if (fadetimeMS === 0) {
this.#audioElement.volume = volume / 100;
return;
}
if (this.#isFading) {
// TODO: Be able to force fading to be canceled
return;
}
this.#onFinishFade = onFinishFade;
const diff = volume - this.#audioElement.volume * 100;
const ticks = diff < 0 ? Math.abs(diff) : diff;
let tick = 0;
const interval = fadetimeMS / ticks;
this.#isFading = true;
this.#fadeTask = setInterval(() => {
tick++;
const cancel = () => {
this.#isFading = false;
if (onFinishFade) {
onFinishFade();
}
clearInterval(this.#fadeTask);
this.#onFinishFade = null;
};
if (!this.#audioElement) {
cancel();
console.error("IPUBAudio: Missing child <audio> element");
return;
}
if (ticks < tick) {
cancel();
return;
}
if (volume === this.getVolume()) {
cancel();
return;
}
if (diff === 0) {
cancel();
return;
}
const nvol =
(diff > 0
? Math.ceil(this.#audioElement.volume * 100 + 1)
: Math.floor(this.#audioElement.volume * 100 - 1)) / 100;
if (nvol > 1 || nvol < 0) {
cancel();
return;
}
this.#audioElement.volume = nvol;
}, interval);
}
/**
* @returns {number}
*/
getVolume() {
return Math.floor((this.#audioElement?.volume ?? 0) * 100);
}
#isFading = false;
#fadeTask = 0;
/** @type {() => void | null} */
#onFinishFade = null;
}
class IPUBBackground extends IPUBElement {
static elementName = "ipub-background";
static observedAttributes = ["nofade"].concat(super.observedAttributes);
/**
* @private
*/
static #instancesOnScreen = {
/** @type {Map<IPUBBody, Set<IPUBBackground>>} */
map: new Map(),
/**
* @param {IPUBBackground} background
*/
add(background) {
const body = getAncestor(background, IPUBBody.elementName);
if (!body) {
console.error(
`IPUBBackground: ${background.id} does not have a valid ipub-body ancestor`,
);
return;
}
let set = this.map.get(body);
if (!set) {
set = new Set();
body.addEventListener("scroll", () => {
for (const instance of set.values()) {
const perc = getPercentageInView(
instance.querySelector("img") || instance,
);
instance.fade(perc);
}
});
}
set.add(background);
this.map.set(body, set);
},
/**
* @param {IPUBBackground} background
*/
remove(background) {
const body = getAncestor(background, IPUBBody.elementName);
if (!body) {
console.error(
`IPUBBackground: ${background.id} does not have a valid ipub-body ancestor`,
);
return;
}
const set = this.map.get(body);
if (!set) {
return;
}
set.delete(background);
if (set.size === 0) {
this.map.delete(body);
}
},
};
static addToScreen() {}
/**
* @private
*/
static #observer = new IntersectionObserver((entries) => {
for (const { intersectionRatio, target: image } of entries) {
const instance = getAncestor(image, IPUBBackground.elementName);
if (!instance) {
console.error(
"IPUBBackground: malformed <ipub-background> element",
image,
);
return;
}
if (intersectionRatio > 0 && instance.id) {
IPUBBackground.#instancesOnScreen.add(instance);
} else if (instance.id) {
IPUBBackground.#instancesOnScreen.remove(instance);
}
}
});
connectedCallback() {
super.connectedCallback();
const image = this.querySelector("img");
if (!image) {
console.error("IPUBBackground: missing <img> element inside background");
return;
}
// INFO: We don't need to fade the first background
if (this.matches(":first-of-type") || this.hasAttribute("nofade")) {
IPUBBackground.#observer.unobserve(image);
return;
}
IPUBBackground.#observer.observe(image);
const perc = getPercentageInView(image);
if (perc > 0) {
this.fade(perc);
}
}
attributeChangedCallback(name, _oldValue, _newValue) {
super.attributeChangedCallback();
if (name !== "nofade") {
return;
}
const image = this.querySelector("img");
if (!image) {
console.error("IPUBBackground: missing <img> element inside background");
return;
}
// INFO: We don't need to fade the first background
if (this.matches(":first-of-type") || this.hasAttribute("nofade")) {
IPUBBackground.#observer.unobserve(image);
return;
}
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) {
if (!this.style.getPropertyValue("--ipub-fade")) {
this.style.setProperty("--ipub-fade", `${perc}%`);
return;
}
const currentPerc = this.style.getPropertyValue("--ipub-fade");
if (currentPerc === "100%" && perc >= 100) {
return;
}
if (perc % 10 === 0) {
this.style.setProperty("--ipub-fade", `${perc}%`);
}
}
}
class IPUBImage extends IPUBElement {
static elementName = "ipub-image";
}
class IPUBInteraction extends IPUBElement {
static elementName = "ipub-interaction";
}
class IPUBSoundtrack extends IPUBElement {
static elementName = "ipub-soundtrack";
// TODO: Toggle automatic soundtrack playing
/**
* @private
*/
static #player = setInterval(() => {
const last = Array.from(this.#onScreenStack).pop();
if (!last) {
// TODO: Fallback to previous soundtrack if there's no audio
return;
}
// TODO: Get siblings based by group OR parent
/** @type {NodeListOf<IPUBSoundtrack> | undefined} */
const siblings = last.parentElement?.querySelectorAll(
IPUBSoundtrack.elementName,
);
try {
if (siblings) {
siblings.forEach((el) => {
if (el !== last) {
el.fadeOut();
}
});
}
last.fadeIn();
} catch (e) {
// TODO: Fallback to previous soundtrack on error
console.error(
`IPUBSoundtrack: error while trying to play audio, error: ${e}`,
{
error: e,
audio: last,
},
);
}
}, 1000);
/**
* @private
*/
static #observer = (() => {
return new IntersectionObserver(
(entries) => {
for (const { intersectionRatio, target, time } of entries) {
/** @type {IPUBSoundtrack} */
const soundtrack =
target.tagName === IPUBTrigger.elementName
? getAncestor(target, IPUBSoundtrack.elementName)
: target;
if (intersectionRatio === 1) {
console.debug(
`${soundtrack.id} is on screen at ${time}`,
soundtrack,
);
this.#onScreenStack.add(soundtrack);
} else {
console.debug(
`${soundtrack.id} is not on screen ${time}`,
soundtrack,
);
this.#onScreenStack.delete(soundtrack);
}
}
},
{ threshold: 1 },
);
})();
/**
* @private
* @type {Set<IPUBSoundtrack>}
*/
static #onScreenStack = new Set();
/**
* @throws {Error}
*/
fadeIn() {
/** @type {IPUBAudio | undefined} */
const audio = this.querySelector(IPUBAudio.elementName);
if (!audio) {
throw new Error("IPUBSoundtrack.fadeIn: missing audio element");
}
// TODO: Global volume settings
audio.play();
audio.setVolume(10, { fadetimeMS: IPUBSoundtrack.FADE_TIME_MS });
}
/**
* @throws {Error}
*/
fadeOut() {
/** @type {IPUBAudio | undefined} */
const audio = this.querySelector(IPUBAudio.elementName);
if (!audio) {
throw new Error("IPUBSoundtrack.fadeIn: missing audio element");
}
audio.setVolume(0, {
fadetimeMS: IPUBSoundtrack.FADE_TIME_MS,
onFinishFade: () => {
audio.pause();
},
});
}
/** @type {Readonly<number>} */
static FADE_TIME_MS = 1000 * 3;
connectedCallback() {
super.connectedCallback();
/** @type {IPUBAudio | undefined} */
const audio = this.querySelector(IPUBAudio.elementName);
if (audio) {
audio.setVolume(0);
} else {
console.error("IPUBSoundtrack: missing audio element");
return;
}
const trigger = this.querySelector(IPUBTrigger.elementName);
if (trigger) {
IPUBSoundtrack.#observer.observe(trigger);
} else {
IPUBSoundtrack.#observer.observe(this);
}
}
// TODO(guz013): Handle if element is moved, it's group should be updated
// TODO(guz013): Handle if element is deleted/disconnected, it should be removed from observer
}
class IPUBTrigger extends IPUBElement {
static elementName = "ipub-trigger";
static observedAttributes = ["height", "width"].concat(
IPUBElement.observedAttributes,
);
// TODO: Make this observer global
/** @private */
static #resizeObserver = new ResizeObserver((bodies) => {
for (const { target: body, contentRect } of bodies) {
const height = Math.max(body.scrollHeight, contentRect.height);
const width = Math.max(body.scrollWidth, contentRect.width);
for (const trigger of IPUBTrigger.#resizableTriggers.get(body)) {
const percH = trigger.getAttribute("height");
if (percH) {
trigger.style.setProperty(
"--ipub-height",
`${Math.round((height / 100) * Number.parseFloat(percH))}px`,
);
}
const percW = trigger.getAttribute("width");
if (percW) {
trigger.style.setProperty(
"--ipub-width",
`${Math.round((width / 100) * Number.parseFloat(percW))}px`,
);
}
}
}
});
/**
* @private
* @type {Map<IPUBBody, Set<IPUBTrigger>>}
*/
static #resizableTriggers = new Map();
// FIXME: trigger can be the same size as viewport, cap it to 80% of viewport
// height and 100% of viewport width
connectedCallback() {
super.connectedCallback();
const body = getAncestor(this, "ipub-body");
if (!body) {
console.error("IPUBTrigger: element must be a descendant of ipub-body");
return;
}
console.debug(
`IPUBTrigger#${this.id}: adding ${IPUBBody.elementName}#${body.id} from resize observer`,
);
IPUBTrigger.#resizeObserver.observe(body);
if (this.getAttribute("height") || this.getAttribute("width")) {
IPUBTrigger.#resizableTriggers.set(
body,
(IPUBTrigger.#resizableTriggers.get(body) || new Set()).add(this),
);
}
}
attributeChangedCallback(name, oldValue, newValue) {
super.attributeChangedCallback(name, oldValue, newValue);
const body = getAncestor(this, "ipub-body");
if (!body) {
console.error("IPUBTrigger: element must be a descendant of ipub-body");
return;
}
const set = IPUBTrigger.#resizableTriggers.get(body) || new Set();
if (this.getAttribute("height") || this.getAttribute("width")) {
set.add(this);
} else {
set.delete(this);
}
if (name === "width" || name === "height") {
const height = Math.max(
body.scrollHeight,
body.getBoundingClientRect().height,
);
const width = Math.max(
body.scrollWidth,
body.getBoundingClientRect().width,
);
const percH = this.getAttribute("height");
if (percH) {
this.style.setProperty(
"--ipub-height",
`${Math.round((height / 100) * Number.parseFloat(percH))}px`,
);
}
const percW = this.getAttribute("width");
if (percW) {
this.style.setProperty(
"--ipub-width",
`${Math.round((width / 100) * Number.parseFloat(percW))}px`,
);
}
}
}
disconnectedCallback() {
const set = IPUBTrigger.#resizableTriggers.get(body) || new Set();
set.delete(this);
if (set.size === 0) {
const body = getAncestor(this, "ipub-body");
if (!body) {
console.error("IPUBTrigger: element must be a descendant of ipub-body");
return;
}
console.debug(
`IPUBTrigger#${this.id}: removing ${IPUBBody.elementName}#${body.id} from resize observer`,
);
IPUBTrigger.#resizableTriggers.delete(body);
IPUBTrigger.#resizeObserver.unobserve(body);
}
}
}
/**
* @param {HTMLElement} el
* @param {string} tagName
* @returns {HTMLElement | undefined}
*/
function getAncestor(el, tagName) {
if (!el.parentElement) {
return undefined;
}
if (el.parentElement.tagName.toLowerCase() === tagName.toLowerCase()) {
return el.parentElement;
}
return getAncestor(el.parentElement, tagName);
}
/**
* @param {Element} el
* @returns {Element[]}
*/
function getAllDescendants(el) {
return Array.from(el.children).reduce(
(acc, current) => {
acc.push(current);
return acc.concat(getAllDescendants(current));
},
/** @type {Element[]} */ [],
);
}
/**
* @param {Readonly<Element>} el
* @param {number} [length=6]
* @returns {string}
*
* @copyright This function contains code from a hash algorithm by
* {@link https://stackoverflow.com/a/16348977|Joe Freeman} and
* {@link https://stackoverflow.com/a/3426956|Cristian Sanchez} at
* StackOverflow, licensed under {@link https://creativecommons.org/licenses/by-sa/3.0/|CC BY-SA 3.0}.
*/
function hashFromHTML(el, length = 6) {
const hexLength = length / 2;
if (hexLength % 2 !== 0) {
hexLength + 1;
}
let hash = 0;
for (const char of el.innerHTML) {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
}
let hex = "";
for (let i = 0; i < hexLength; i++) {
hex += ((hash >> (i * 8)) & 0xff).toString(16).padStart(2, "0");
}
return hex.substring(0, length);
}
/**
* @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

@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="x-ipub-version" content="0.1" />
<meta name="viewport"
content="initial-scale=1,width=device-width,height=device-height,viewport-fit=contain" />
<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">
<ipub-body style="--ipub-padding: 10%;" debug="">
<ipub-cover>
<dialog>
<header>
<h1>Test comic</h1>
<form method="dialog">
<p>Click anywhere to
<button>start Reading</button></p>
</form>
</header>
</dialog>
</ipub-cover>
<main id="content">
<ipub-background id="background0001">
<img src="../images/background0001.jpg" width="100" height="100" />
</ipub-background>
<ipub-soundtrack style="--ipub-color:cyan">
<ipub-trigger height="10" />
<!-- TODO: Search on how to make this more accessible, more semantic as using <details> -->
<figure>
<label>
<input type="checkbox" />
<figcaption>Soundtrack 1</figcaption>
</label>
<ipub-audio>
<audio controls="true" volume="0" controlslist="nofullscreen"
disableremoteplayback="">
<source src="../audios/track1.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
<ipub-image>
<img src="../images/image0001.png" />
<ipub-interaction style="--ipub-y:88.5%;--ipub-x:6%" circle="">
<a href="https://krita.org" referrerpolicy="same-origin"
rel="external nofollow noopener noreferrer" target="_blank" />
</ipub-interaction>
<ipub-interaction style="--ipub-y:93.5%;--ipub-x:81.5%;--ipub-size:13%;">
<a href="https://guz.one" referrerpolicy="same-origin"
rel="external nofollow noopener noreferrer" target="_blank" />
</ipub-interaction>
</ipub-image>
<ipub-image>
<img src="../images/image0002.png" />
</ipub-image>
<ipub-soundtrack style="--ipub-color:green;">
<ipub-trigger height="10" />
<figure>
<label>
<input type="checkbox" />
<figcaption>Soundtrack 2</figcaption>
</label>
<ipub-audio>
<audio controls="true" volume="0" controlslist="nofullscreen"
disableremoteplayback="">
<source src="../audios/track2.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
<ipub-background id="background0002">
<picture>
<img src="../images/background0002.jpg" />
</picture>
</ipub-background>
<ipub-image>
<img src="../images/image0003.png" />
</ipub-image>
<ipub-image>
<img src="../images/image0004.png" />
</ipub-image>
<ipub-background id="background0003">
<picture>
<img src="../images/background0003.jpg" />
</picture>
</ipub-background>
<ipub-image>
<img src="../images/image0002.png" />
</ipub-image>
<ipub-soundtrack style="--ipub-color:yellow;">
<ipub-trigger height="10" />
<figure>
<label>
<input type="checkbox" />
<figcaption>Soundtrack 3</figcaption>
</label>
<ipub-audio>
<audio controls="true" volume="0" controlslist="nofullscreen"
disableremoteplayback="">
<source src="../audios/track3.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
<ipub-image>
<img src="../images/image0003.png" />
</ipub-image>
<ipub-image>
<img src="../images/image0004.png" />
</ipub-image>
</main>
</ipub-body>
</body>
</html>

View File

@@ -0,0 +1,325 @@
.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;
max-height: 100vh;
overflow: clip;
display: flex;
:root {
--z-controls: 10;
--z-cover: 9;
--z-overlays: 6;
}
[debug] {
--ipub-debug-bg-opacity: 30%;
background-color: rgba(
from var(--ipub-debug-color) r g b / var(--ipub-debug-bg-opacity, 30%)
);
outline-color: var(--ipub-debug-color);
outline-width: 1px;
outline-style: solid;
}
ipub-cover > dialog[open] {
--ipub-accent-color: #fff;
z-index: var(--z-cover);
background-color: transparent;
border: none;
display: inline-block;
position: absolute;
backdrop-filter: blur(1rem);
background-image: linear-gradient(
rgba(from var(--ipub-accent-color) r g b / 0) 0%,
rgba(from var(--ipub-accent-color) r g b / 0.5)
calc(100% + calc(var(--ipub-fade, 50%) * -1))
);
top: 0;
left: 0;
width: 100%;
height: 100%;
}
ipub-body {
max-width: 100%;
max-height: 100%;
position: relative;
display: flex;
flex-direction: column;
overflow: scroll;
&:has(ipub-cover > dialog[open]) {
overflow: hidden;
}
--ipub-padding: 0%;
--ipub-gap: 0%;
--ipub-padding-x: var(--ipub-padding, 0%);
--ipub-padding-y: var(--ipub-padding, 0%);
--ipub-padding-t: var(--ipub-padding-y, 0%);
--ipub-padding-r: var(--ipub-padding-x, 0%);
--ipub-padding-b: var(--ipub-padding-y, 0%);
--ipub-padding-l: var(--ipub-padding-x, 0%);
& > article,
& > main,
& > section {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
& > *:first-child:not(ipub-background):not(ipub-soundtrack),
& > ipub-background:first-of-type + *:first-of-type:not(ipub-soundtrack),
& > ipub-soundtrack:first-of-type + *:first-of-type:not(ipub-background) {
margin-top: var(--ipub-padding-t);
margin-bottom: calc(var(--ipub-gap) / 2);
}
& > *:not(ipub-background):not(ipub-soundtrack) {
margin-top: calc(var(--ipub-gap) / 2);
margin-right: var(--ipub-padding-r);
margin-left: var(--ipub-padding-l);
margin-bottom: calc(var(--ipub-gap) / 2);
}
& > *:last-child:not(ipub-background),
& > *:last-child:not(ipub-soundtrack),
& > ipub-background:last-of-type + *:last-of-type:not(ipub-soundtrack),
& > ipub-soundtrack:last-of-type + *:last-of-type:not(ipub-background) {
margin-top: calc(var(--ipub-gap) / 2);
margin-bottom: var(--ipub-padding-b);
}
}
}
ipub-soundtrack {
display: inline-block;
z-index: var(--z-overlays);
--ipub-color: red;
top: 0;
left: 0;
width: 100%;
height: 0;
position: sticky;
align-self: start;
border-top: 0.1rem dashed var(--ipub-color);
figure {
margin: 0;
height: 1.5rem;
font-size: small;
width: 100%;
display: flex;
align-items: center;
flex-direction: row;
flex: 1;
label {
background-color: var(--ipub-color);
border-end-end-radius: 0.5rem;
padding: 0.1rem 0.4rem;
width: max-content;
max-width: 50%;
height: 100%;
white-space: nowrap;
display: flex;
align-items: center;
}
&:has(input:checked) label {
border-end-end-radius: 0;
}
figcaption::before {
content: "0 "; /* TODO: change to an icon and better positioning */
}
figcaption::after {
content: " >"; /* TODO: change to an icon and better positioning */
}
&:has(input:checked) figcaption::after {
content: " <"; /* TODO: change to an icon and better positioning */
}
input {
width: 0;
height: 0;
display: none;
}
&:has(input) audio {
width: 0;
height: 0;
}
&:has(input:checked) audio {
width: 100%;
@media (width >= 40rem) {
width: 70%;
}
height: 100%;
background-color: var(--ipub-color);
@media (width >= 40rem) {
border-end-end-radius: 0.5rem;
}
padding: 0.1rem 0.4rem;
}
audio::-webkit-media-controls-enclosure {
background-color: transparent;
padding: 0;
border-radius: 0px;
}
audio::-webkit-media-controls-panel {
margin: 0;
padding: 0;
}
}
&[playing] figure figcaption::before {
content: "P "; /* TODO: change to an icon and better positioning */
}
& > ipub-trigger {
position: absolute;
display: inline-block;
top: 0;
left: 0;
transform: translateY(--ipub-offset, 0%);
pointer-events: none;
width: var(--ipub-width, 100%);
height: var(--ipub-height, 0%);
}
}
ipub-background {
--ipub-mask: linear-gradient(
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) calc(100% + calc(var(--ipub-fade, 100%) * -1))
);
--ipub-width: 100vw;
--ipub-height: 100vh;
display: inline-block;
top: 0;
left: 0;
width: 0;
height: 0;
position: sticky;
align-self: start;
&:first-of-type,
&[nofade] {
--ipub-mask: unset;
}
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; */
/* background-image: var(--mask); */
mask-image: var(--ipub-mask);
-webkit-mask-image: var(--ipub-mask);
}
& > picture {
position: absolute;
top: 0;
left: 0;
display: block;
width: var(--ipub-width);
height: var(--ipub-height);
& > img {
object-fit: cover;
width: 100%;
height: 100%;
}
}
/* Support standalone img element */
& > img {
position: absolute;
top: 0;
left: 0;
display: block;
object-fit: cover;
width: var(--ipub-width);
height: var(--ipub-height);
}
}
ipub-image {
position: relative;
display: inline-block;
flex-direction: column;
width: var(--ipub-width, unset);
height: var(--ipub-height, unset);
img {
display: block;
max-width: 100%;
max-height: 100%;
}
}
ipub-interaction {
position: absolute;
--ipub-x: 0px;
--ipub-y: 0px;
--ipub-size: 10%;
--ipub-width: var(--ipub-size, unset);
--ipub-height: unset;
--ipub-ratio: 1/1;
left: var(--ipub-x);
top: var(--ipub-y);
width: var(--ipub-width);
height: var(--ipub-height);
aspect-ratio: var(--ipub-ratio, unset);
transform: translate(var(--ipub-offset-x, -50%), var(--ipub-offset-y, -50%));
& > * {
display: block;
width: 100%;
height: 100%;
border-radius: var(--ipub-radius, 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;
}
&[circle] {
--ipub-radius: 100%;
}
}

View File

@@ -0,0 +1,18 @@
<?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

@@ -0,0 +1,14 @@
<?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>

1
.epub/example/mimetype Normal file
View File

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

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ out.css
.tmp
.env
*.db
tmp

5
.gitmodules vendored
View File

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

8
.vscode/launch.json vendored
View File

@@ -14,13 +14,7 @@
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/cmd.go",
"args": [
"-dev",
"-port",
"8080",
"-hostname",
"0.0.0.0"
]
"args": ["-dev", "-port", "8080", "-hostname", "0.0.0.0"]
}
]
}

View File

@@ -2,7 +2,9 @@ package main
import (
"context"
"crypto/ed25519"
"database/sql"
"encoding/base64"
"errors"
"flag"
"fmt"
@@ -13,9 +15,9 @@ import (
"os/signal"
"syscall"
comicverse "forge.capytal.company/capytalcode/project-comicverse"
"forge.capytal.company/capytalcode/project-comicverse/templates"
"forge.capytal.company/loreddev/x/tinyssert"
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"
@@ -38,6 +40,9 @@ var (
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 {
@@ -62,19 +67,16 @@ func init() {
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()
assertions := tinyssert.NewDisabledAssertions()
if *dev {
assertions = tinyssert.NewAssertions(tinyssert.Opts{
Panic: true,
})
}
level := slog.LevelError
if *dev {
level = slog.LevelDebug
@@ -83,6 +85,14 @@ func main() {
}
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()))
@@ -119,10 +129,44 @@ func main() {
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,
Bucket: s3Bucket,
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()))

View File

@@ -2,35 +2,39 @@ package comicverse
import (
"context"
"crypto/ed25519"
"database/sql"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"forge.capytal.company/capytalcode/project-comicverse/assets"
"forge.capytal.company/capytalcode/project-comicverse/database"
"forge.capytal.company/capytalcode/project-comicverse/internals/joinedfs"
"forge.capytal.company/capytalcode/project-comicverse/router"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/capytalcode/project-comicverse/templates"
"forge.capytal.company/loreddev/x/tinyssert"
"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,
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.NewAssertions(),
assert: tinyssert.New(),
logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})),
}
@@ -44,6 +48,12 @@ func New(cfg Config, opts ...Option) (http.Handler, error) {
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")
}
@@ -71,9 +81,11 @@ func New(cfg Config, opts ...Option) (http.Handler, error) {
}
type Config struct {
DB *sql.DB
S3 *s3.Client
Bucket string
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)
@@ -103,9 +115,11 @@ func WithDevelopmentMode() Option {
}
type app struct {
db *sql.DB
s3 *s3.Client
bucket string
db *sql.DB
s3 *s3.Client
bucket string
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
ctx context.Context
@@ -127,34 +141,40 @@ func (app *app) setup() error {
app.assert.NotNil(app.assets)
app.assert.NotNil(app.logger)
var err error
database, err := database.New(database.Config{
SQL: app.db,
Context: app.ctx,
Assertions: app.assert,
Logger: app.logger.WithGroup("database"),
})
userRepository, err := repository.NewUser(app.ctx, app.db, app.logger.WithGroup("repository.user"), app.assert)
if err != nil {
return errors.Join(errors.New("unable to create database struct"), err)
return fmt.Errorf("app: failed to start user repository: %w", err)
}
service, err := service.New(service.Config{
DB: database,
S3: app.s3,
Bucket: app.bucket,
Context: app.ctx,
Assertions: app.assert,
Logger: app.logger.WithGroup("service"),
})
tokenRepository, err := repository.NewToken(app.ctx, app.db, app.logger.WithGroup("repository.token"), app.assert)
if err != nil {
return errors.Join(errors.New("unable to initiate service"), err)
return fmt.Errorf("app: failed to start token repository: %w", err)
}
publicationRepository, err := repository.NewPublication(app.ctx, app.db, app.logger.WithGroup("repository.publication"), app.assert)
if err != nil {
return fmt.Errorf("app: failed to start publication 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,
})
publicationService := service.NewPublication(publicationRepository, permissionRepository, app.logger.WithGroup("service.publication"), app.assert)
app.handler, err = router.New(router.Config{
Service: service,
UserService: userService,
TokenService: tokenService,
PublicationService: publicationService,
Templates: app.templates,
DisableCache: app.developmentMode,

View File

@@ -1,98 +0,0 @@
package database
import (
"context"
"database/sql"
"errors"
"log/slog"
"forge.capytal.company/loreddev/x/tinyssert"
)
var ErrNoRows = sql.ErrNoRows
type Database struct {
sql *sql.DB
ctx context.Context
assert tinyssert.Assertions
log *slog.Logger
}
func New(cfg Config) (*Database, error) {
if cfg.SQL == nil {
return nil, errors.New("SQL database interface should not be nil")
}
if cfg.Context == nil {
return nil, errors.New("context interface should not be nil")
}
if cfg.Assertions == nil {
return nil, errors.New("assertions interface should not be nil")
}
if cfg.Logger == nil {
return nil, errors.New("logger should not be a nil pointer")
}
db := &Database{
sql: cfg.SQL,
ctx: cfg.Context,
assert: cfg.Assertions,
log: cfg.Logger,
}
if err := db.setup(); err != nil {
return nil, errors.Join(errors.New("error while setting up Database struct"), err)
}
return db, nil
}
type Config struct {
SQL *sql.DB
Context context.Context
Assertions tinyssert.Assertions
Logger *slog.Logger
}
func (db *Database) setup() error {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
log := db.log
log.Info("Setting up database")
log.Debug("Pinging database")
err := db.sql.PingContext(db.ctx)
if err != nil {
return errors.Join(errors.New("unable to ping database"), err)
}
log.Debug("Creating tables")
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return errors.Join(errors.New("unable to start transaction to create tables"), err)
}
setups := []func(*sql.Tx) error{
db.setupProjects,
}
for _, setup := range setups {
err := setup(tx)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return errors.Join(errors.New("unable to run transaction to create tables"), err)
}
return nil
}

View File

@@ -1,156 +0,0 @@
package database
import (
"database/sql"
"errors"
"fmt"
"log/slog"
)
type Project struct {
ID string
Title string
}
func (db *Database) setupProjects(tx *sql.Tx) error {
db.assert.NotNil(tx)
db.assert.NotNil(db.ctx)
q := `CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL
) STRICT`
_, err := tx.ExecContext(db.ctx, q)
if err != nil {
return errors.Join(errors.New(`unable to execute create query to table "projects"`), err)
}
return nil
}
func (db *Database) CreateProject(id string, title string) (Project, error) {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
db.assert.NotZero(id)
db.assert.NotZero(title)
q := fmt.Sprintf(`INSERT INTO projects (id, title) VALUES ('%s', '%s')`, id, title)
db.log.Debug("Inserting into Projects", slog.String("query", q))
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return Project{}, err
}
_, err = tx.ExecContext(db.ctx, q)
if err != nil {
return Project{}, err
}
err = tx.Commit()
if err != nil {
return Project{}, err
}
return Project{ID: id, Title: title}, nil
}
func (db *Database) GetProject(id string) (Project, error) {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
q := fmt.Sprintf(`SELECT id, title FROM projects WHERE id = '%s'`, id)
db.log.Debug("Getting Project", slog.String("query", q))
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return Project{}, err
}
row := tx.QueryRowContext(db.ctx, q)
p := Project{}
err = row.Scan(&p.ID, &p.Title)
if err != nil {
return p, err
}
err = tx.Commit()
if err != nil {
return p, err
}
return p, nil
}
func (db *Database) ListProjects() ([]Project, error) {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
q := `SELECT id, title FROM projects`
db.log.Debug("Listing Projects", slog.String("query", q))
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return []Project{}, err
}
rows, err := tx.QueryContext(db.ctx, q)
if err != nil {
db.assert.Nil(tx.Rollback())
return []Project{}, err
}
ps := []Project{}
for rows.Next() {
p := Project{}
err := rows.Scan(&p.ID, &p.Title)
if err != nil {
db.assert.Nil(tx.Rollback())
return ps, err
}
ps = append(ps, p)
}
err = tx.Commit()
if err != nil {
return ps, err
}
return ps, nil
}
func (db *Database) DeleteProject(id string) error {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
db.assert.NotZero(id)
q := fmt.Sprintf(`DELETE FROM projects WHERE id = '%s'`, id)
db.log.Debug("Deleting from Projects", slog.String("query", q))
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return err
}
_, err = tx.ExecContext(db.ctx, q)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}

13
editor/assets/assets.go Normal file
View File

@@ -0,0 +1,13 @@
package assets
import (
"embed"
"io/fs"
)
//go:embed css/style.css js/*.js ipub/*.js ipub/*.css
var files embed.FS
func New() fs.FS {
return files
}

225
editor/assets/css/style.css Normal file
View File

@@ -0,0 +1,225 @@
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--color-gray-50: oklch(98.5% 0.002 247.839);
--color-gray-600: oklch(44.6% 0.03 256.802);
--color-gray-700: oklch(37.3% 0.034 259.733);
--color-gray-900: oklch(21% 0.034 264.665);
--spacing: 0.25rem;
--radius-md: 0.375rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden='until-found'])) {
display: none !important;
}
}
@layer utilities {
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.static {
position: static;
}
.contents {
display: contents;
}
.flex {
display: flex;
}
.resize {
resize: both;
}
.flex-col {
flex-direction: column;
}
.bg-gray-900 {
background-color: var(--color-gray-900);
}
.text-gray-50 {
color: var(--color-gray-50);
}
.has-\[\#first-publication\]\:h-full {
&:has(*:is(#first-publication)) {
height: 100%;
}
}
.has-\[\#first-publication\]\:h-svw {
&:has(*:is(#first-publication)) {
height: 100svw;
}
}
}
@layer base {
form {
input {
background-color: var(--color-gray-700);
border-radius: var(--radius-md);
padding-inline-start: calc(var(--spacing) * 2);
&:has(+ button) {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
}
button {
background-color: var(--color-gray-600);
border-radius: var(--radius-md);
padding: 0 calc(var(--spacing) * 2);
input + & {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
}

View File

@@ -0,0 +1,22 @@
@import "tailwindcss";
@layer base {
form {
input {
background-color: var(--color-gray-700);
border-radius: var(--radius-md);
padding-inline-start: --spacing(2);
&:has(+ button) {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
}
button {
background-color: var(--color-gray-600);
border-radius: var(--radius-md);
padding: 0 --spacing(2);
input + & {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
}

128
editor/cmd/cmd.go Normal file
View File

@@ -0,0 +1,128 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/capytal/comicverse/editor/assets"
"code.capytal.cc/capytal/comicverse/editor/router"
"code.capytal.cc/capytal/comicverse/editor/storage"
"code.capytal.cc/capytal/comicverse/editor/template"
"code.capytal.cc/loreddev/x/tinyssert"
)
var (
hostname = flag.String("hostname", "localhost", "Host to listen to")
port = flag.Uint("port", 8080, "Port to be used for the server.")
verbose = flag.Bool("verbose", false, "Print debug information on logs")
dev = flag.Bool("dev", false, "Run the server in debug mode.")
)
var (
storageDir = getEnv("EDITOR_PUBLICATIONS_DIR", ".publications") // TODO: Use XDG_STATE_HOME as default
assetsDir = getEnv("EDITOR_ASSETS_DIR", "assets") // TODO: Use XDG_CONFIG_HOME as default
templatesDir = getEnv("EDITOR_TEMPLATES_DIR", "template") // TODO: Use XDG_CONFIG_HOME as default
)
func getEnv(key string, d string) string {
v := os.Getenv(key)
if v == "" {
return d
}
return v
}
func init() {
flag.Parse()
}
func main() {
ctx := context.Background()
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
assert := tinyssert.New(tinyssert.WithLogger(log))
assets := assets.New()
templater, err := template.New()
if err != nil {
log.Error("Unable to initiate templater due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
if *dev {
assets = os.DirFS(assetsDir)
log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
assert = tinyssert.New(tinyssert.WithPanic(), tinyssert.WithLogger(log.WithGroup("assertions")))
templater, err = template.Dev(os.DirFS(templatesDir))
if err != nil {
log.Error("Unable to initiate dev templater due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
}
err = os.MkdirAll(storageDir, os.ModePerm)
if err != nil {
log.Error("Unable to create storage directory due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
root, err := os.OpenRoot(storageDir)
if err != nil {
log.Error("Unable to open storage directory due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
storage := storage.Newlocal(root, log)
editor := editor.New(storage, log.WithGroup("editor"), assert)
router := router.New(router.Config{
Assets: assets,
Editor: editor,
Templater: templater,
Logger: log.WithGroup("router"),
})
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", *hostname, *port),
Handler: router,
}
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(c); err != nil {
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
os.Exit(1)
}
log.Info("FINAL")
os.Exit(0)
}

69
editor/container.go Normal file
View File

@@ -0,0 +1,69 @@
package editor
import (
"bytes"
_ "embed"
"encoding/xml"
"errors"
"fmt"
"log/slog"
"path"
"sync"
"time"
"code.capytal.cc/capytal/comicverse/editor/epub"
"code.capytal.cc/capytal/comicverse/editor/internals/shortid"
"code.capytal.cc/capytal/comicverse/editor/storage"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
"golang.org/x/net/html"
)
type Container struct {
id uuid.UUID
pkg epub.Package
storage storage.Storage
log *slog.Logger
assert tinyssert.Assertions
flushed bool
}
func (p *Container) Flush() error {
p.assert.NotZero(p.pkg, "invalid ePUB: package must be set")
p.assert.NotZero(p.pkg.Metadata, "invalid ePUB: package must have metadata")
p.assert.NotZero(p.pkg.Metadata.ID, "invalid ePUB: ID must always be specified")
p.assert.NotZero(p.pkg.Metadata.Language, "invalid ePUB: Language must always be specified")
p.assert.NotZero(p.pkg.Metadata.Title, "invalid ePUB: Title must always be specified")
p.mu.Lock()
defer p.mu.Unlock()
p.log.Debug("Flushing state of publication")
if p.flushed {
p.log.Debug("Publication doesn't have unsaved changes, skipping flush")
return nil
}
defer p.log.Debug("Publication's state flushed")
b, err := xml.MarshalIndent(p.pkg, "", " ")
if err != nil {
return fmt.Errorf("editor.Publication: failed to marshal package: %w", err)
}
if _, err = p.storage.Write("content.opf", b); err != nil {
return fmt.Errorf("editor.Publication: failed to write content.opf: %w", err)
}
return nil
}

114
editor/editor.go Normal file
View File

@@ -0,0 +1,114 @@
package editor
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"log/slog"
"sync"
"time"
"code.capytal.cc/capytal/comicverse/editor/epub"
"code.capytal.cc/capytal/comicverse/editor/storage"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
"golang.org/x/text/language"
)
func New(
storage storage.Storage,
logger *slog.Logger,
assert tinyssert.Assertions,
) *Editor {
assert.NotZero(storage)
assert.NotZero(logger)
return &Editor{
storage: storage,
log: logger,
assert: assert,
}
}
type Editor struct {
storage storage.Storage
ctx context.Context
log *slog.Logger
assert tinyssert.Assertions
}
func (e *Editor) New(id uuid.UUID, title string, lang language.Tag) (*Container, error) {
f := fmt.Sprintf("%s/content.opf", id)
if e.storage.Exists(f) {
return nil, ErrAlreadyExists
}
pub := &Container{
id: id,
pkg: epub.Package{
Metadata: epub.Metadata{
ID: fmt.Sprintf("comicverse:%s", id),
Title: title,
Language: lang,
Date: time.Now(),
Modified: time.Now(),
},
},
log: e.log.WithGroup(fmt.Sprintf("publication:%s", id)),
assert: e.assert,
storage: storage.WithRoot(id.String(), e.storage),
}
err := pub.Flush()
if err != nil {
return nil, fmt.Errorf("editor: unable to flush changes of publication: %w", err)
}
return pub, nil
}
func (e *Editor) Open(id uuid.UUID) (*Container, error) {
content, err := e.storage.Open(fmt.Sprintf("%s/content.opf", id))
if errors.Is(err, storage.ErrNotExists) {
return nil, ErrNotExists
} else if err != nil {
return nil, fmt.Errorf("editor: unable to open package: %w", err)
}
b, err := io.ReadAll(content)
if err != nil {
return nil, fmt.Errorf("editor: unable to read contents of package: %w", err)
}
var pkg epub.Package
err = xml.Unmarshal(b, &pkg)
if err != nil {
return nil, fmt.Errorf("editor: unable to decode xml of package: %w", err)
}
c := &Container{
id: id,
pkg: pkg,
log: e.log.WithGroup(fmt.Sprintf("publication:%s", id)),
assert: e.assert,
storage: storage.WithRoot(id.String(), e.storage),
}
return c, nil
}
var (
ErrAlreadyExists = errors.New("editor: file already exists")
ErrNotExists = errors.New("editor: file doesn't exist")
)

45
editor/epub/epub.go Normal file
View File

@@ -0,0 +1,45 @@
package epub
import (
"encoding/xml"
"fmt"
)
type Meta struct {
Attributes map[string]string `xml:"-"`
Value string `xml:",chardata"`
}
var (
_ xml.Marshaler = Meta{}
_ xml.Unmarshaler = (*Meta)(nil)
)
func (m Meta) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
for n, v := range m.Attributes {
start.Attr = append(start.Attr, xml.Attr{
Name: xml.Name{Local: n},
Value: v,
})
}
return e.EncodeElement(m.Value, start)
}
func (m *Meta) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
if m == nil {
m = &Meta{}
}
if m.Attributes == nil {
m.Attributes = map[string]string{}
}
for _, attr := range start.Attr {
m.Attributes[attr.Name.Local] = attr.Value
}
if err := d.DecodeElement(&m.Value, &start); err != nil {
return fmt.Errorf("epub.Meta: failed to decode chardata: %w", err)
}
return nil
}

450
editor/epub/opf.go Normal file
View File

@@ -0,0 +1,450 @@
package epub
import (
"encoding/xml"
"fmt"
"strings"
"time"
"code.capytal.cc/capytal/comicverse/editor/internals/shortid"
"golang.org/x/text/language"
)
type Package struct {
Metadata Metadata `xml:"metadata"`
Manifest Manisfest `xml:"manifest"`
Spine Spine `xml:"spine"`
// TODO: Collections https://www.w3.org/TR/epub-33/#sec-pkg-collections
}
var _ xml.Marshaler = Package{}
func (p Package) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
start.Name = xml.Name{
Local: "package",
Space: "http://www.idpf.org/2007/opf",
}
start.Attr = append(start.Attr, []xml.Attr{
{Name: xml.Name{Local: "xmlns:dc"}, Value: "http://purl.org/dc/elements/1.1/"},
{Name: xml.Name{Local: "xmlns:dcterms"}, Value: "http://purl.org/dc/terms/"},
{Name: xml.Name{Local: "xmlns:opf"}, Value: "http://www.idpf.org/2007/opf"},
{Name: xml.Name{Local: "unique-identifier"}, Value: uniqueIdentifierID},
{Name: xml.Name{Local: "version"}, Value: "3.0"},
}...)
if err := e.EncodeToken(start); err != nil {
return err
}
err := e.EncodeElement(p.Metadata, xml.StartElement{Name: xml.Name{Local: "metadata"}})
if err != nil {
return err
}
err = e.EncodeElement(p.Manifest, xml.StartElement{Name: xml.Name{Local: "manifest"}})
if err != nil {
return err
}
err = e.EncodeElement(p.Spine, xml.StartElement{Name: xml.Name{Local: "spine"}})
if err != nil {
return err
}
return e.EncodeToken(xml.EndElement{Name: start.Name})
}
type Metadata struct {
ID string `xml:"dc:identifier"`
Title string `xml:"dc:title"`
Language language.Tag `xml:"dc:language"`
Creators []Person `xml:"dc:creator"`
Contributors []Person `xml:"dc:contributor"`
Date time.Time `xml:"dc:date"`
Modified time.Time `xml:"-"`
// TODO: Support for dc:subject, dc:type and meta elements
// https://www.w3.org/TR/epub-33/#sec-opf-dcsubject
}
var (
_ xml.Marshaler = Metadata{}
_ xml.Unmarshaler = (*Metadata)(nil)
)
func (m Metadata) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if err := e.EncodeToken(start); err != nil {
return err
}
helper := encoderHelper(e, "epub.Metadata")
err := helper("dc:identifier", m.ID, xml.Attr{
Name: xml.Name{Local: "id"}, Value: uniqueIdentifierID,
})
if err != nil {
return err
}
if err = helper("dc:title", m.Title); err != nil {
return err
}
if err = helper("dc:language", m.Language.String()); err != nil {
return err
}
for _, creator := range m.Creators {
err := creator.marshalIntoRootXML(xml.Name{Local: "dc:creator"}, e)
if err != nil {
return err
}
}
for _, contributor := range m.Contributors {
err := contributor.marshalIntoRootXML(xml.Name{Local: "dc:contributor"}, e)
if err != nil {
return err
}
}
if !m.Date.IsZero() {
if err = helper("dc:date", m.Date.Format(time.RFC3339)); err != nil {
return err
}
}
if !m.Modified.IsZero() {
if err = helper("meta", m.Modified.Format(time.RFC3339), xml.Attr{
Name: xml.Name{Local: "property"}, Value: "dcterms:modified",
}); err != nil {
return err
}
}
return e.EncodeToken(start.End())
}
func (m *Metadata) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
if m == nil {
m = &Metadata{}
}
var v struct {
ID string `xml:"http://purl.org/dc/elements/1.1/ identifier"`
Title string `xml:"http://purl.org/dc/elements/1.1/ title"`
Language language.Tag `xml:"http://purl.org/dc/elements/1.1/ language"`
Creators []Person `xml:"http://purl.org/dc/elements/1.1/ creator"`
Contributors []Person `xml:"http://purl.org/dc/elements/1.1/ contributor"`
Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
Meta []Meta `xml:"meta"`
}
if err := d.DecodeElement(&v, &start); err != nil {
return fmt.Errorf("epub.Metadata: unable to unmarshal: %w", err)
}
m.ID = v.ID
m.Title = v.Title
m.Language = v.Language
if v.Date != "" {
t, err := time.Parse(time.RFC3339, v.Date)
if err != nil {
return fmt.Errorf("epub.Metadata: date is not valid: %w", err)
}
m.Date = t
}
m.Creators = v.Creators
for i, c := range m.Creators {
c, err := c.unmarshalFromMetas(v.Meta)
if err != nil {
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
}
m.Creators[i] = c
}
m.Contributors = v.Contributors
for i, c := range m.Contributors {
c, err := c.unmarshalFromMetas(v.Meta)
if err != nil {
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
}
m.Contributors[i] = c
}
for _, meta := range v.Meta {
if property, ok := meta.Attributes["property"]; ok {
switch property {
case "dcterms:modified":
t, err := time.Parse(time.RFC3339, meta.Value)
if err != nil {
return fmt.Errorf("epub.Metadata: modified date is not valid: %w", err)
}
m.Modified = t
}
}
}
return nil
}
var uniqueIdentifierID = "pub-id"
type Person struct {
ID string `xml:"id,attr"`
Name string `xml:",chardata"`
Role string `xml:"-"`
FileAs string `xml:"-"`
AlternateScripts map[language.Tag]string `xml:"-"`
}
func (p Person) marshalIntoRootXML(name xml.Name, e *xml.Encoder) error {
if p.ID == "" {
p.ID = shortid.New().String()
}
err := e.EncodeElement(p.Name, xml.StartElement{
Name: name,
Attr: []xml.Attr{{Name: xml.Name{Local: "id"}, Value: p.ID}},
})
if err != nil {
return err
}
for lang, name := range p.AlternateScripts {
err = e.EncodeElement(name, xml.StartElement{
Name: xml.Name{Local: "meta"},
Attr: []xml.Attr{
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
{Name: xml.Name{Local: "property"}, Value: "alternate-script"},
{Name: xml.Name{Local: "xml:lang"}, Value: lang.String()},
},
})
if err != nil {
return err
}
}
if p.FileAs != "" {
err = e.EncodeElement(p.FileAs, xml.StartElement{
Name: xml.Name{Local: "meta"},
Attr: []xml.Attr{
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
{Name: xml.Name{Local: "property"}, Value: "file-as"},
},
})
if err != nil {
return err
}
}
if p.Role != "" {
err = e.EncodeElement(p.Role, xml.StartElement{
Name: xml.Name{Local: "meta"},
Attr: []xml.Attr{
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
{Name: xml.Name{Local: "property"}, Value: "role"},
{Name: xml.Name{Local: "scheme"}, Value: "marc:relators"},
},
})
if err != nil {
return err
}
}
return nil
}
func (p Person) unmarshalFromMetas(metaList []Meta) (Person, error) {
if p.ID == "" {
return p, nil
}
if p.AlternateScripts == nil {
p.AlternateScripts = map[language.Tag]string{}
}
for _, meta := range metaList {
refines, ok := meta.Attributes["refines"]
if !ok || refines != fmt.Sprintf("#%s", p.ID) {
continue
}
property, ok := meta.Attributes["property"]
if !ok {
continue
}
switch property {
case "alternate-script":
l, ok := meta.Attributes["lang"]
if !ok {
continue
}
lang, err := language.Parse(l)
if err != nil {
return p, fmt.Errorf("epub.Person: language %q is not valid: %w", l, err)
}
p.AlternateScripts[lang] = meta.Value
case "file-as":
p.FileAs = meta.Value
case "role":
p.Role = meta.Value
}
}
return p, nil
}
type Manisfest struct {
Items []Item `xml:"item"`
}
type Item struct {
ID string `xml:"id,attr"`
HRef string `xml:"href,attr"`
MediaType string `xml:"media-type,attr"`
MediaOverlay string `xml:"media-overlay,attr,omitempty"`
Properties ItemProperties `xml:"properties,attr,omitempty"`
}
type Spine struct {
ID string `xml:"id,attr,omitempty"`
Toc string `xml:"toc,attr,omitempty"`
PageProgressionDir PageProgressionDir `xml:"page-progression-direction,attr,omitempty"`
ItemRefs []ItemRef `xml:"itemref"`
}
type PageProgressionDir string
const (
PageProgressionDirDefault PageProgressionDir = "default"
PageProgressionDirLTR PageProgressionDir = "ltr"
PageProgressionDirRTL PageProgressionDir = "rtl"
)
type ItemRef struct {
IDRef string `xml:"idref,attr"`
ID string `xml:"id,attr"`
NotLinear bool `xml:"linear,attr"`
Properties ItemProperties `xml:"properties,attr"`
}
var (
_ xml.Marshaler = ItemRef{}
_ xml.Unmarshaler = (*ItemRef)(nil)
)
func (ref ItemRef) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
linear := xml.Attr{Name: xml.Name{Local: "linear"}}
if !ref.NotLinear {
linear.Value = "no"
} else {
linear.Value = "yes"
}
props, _ := ref.Properties.MarshalXMLAttr(xml.Name{Local: "properties"})
start.Attr = append(start.Attr, []xml.Attr{
{Name: xml.Name{Local: "idref"}, Value: ref.IDRef},
{Name: xml.Name{Local: "id"}, Value: ref.ID},
linear,
props,
}...)
if err := e.EncodeToken(start); err != nil {
return err
}
return e.EncodeToken(start.End())
}
func (ref *ItemRef) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
if ref == nil {
ref = &ItemRef{}
}
for _, attr := range start.Attr {
switch attr.Name.Local {
case "idref":
ref.IDRef = attr.Value
case "id":
ref.ID = attr.Value
case "linear":
if attr.Value == "no" {
ref.NotLinear = true
} else {
ref.NotLinear = false
}
case "properties":
ref.Properties.UnmarshalXMLAttr(attr)
}
}
var t string
return d.DecodeElement(&t, &start)
}
type (
ItemProperty string
ItemProperties []ItemProperty
)
const (
ItemPropertyCoverImage ItemProperty = "cover-image"
ItemPropertyNav ItemProperty = "nav"
ItemPropertyMathML ItemProperty = "mathml"
ItemPropertyRemoteResources ItemProperty = "remote-resources"
ItemPropertyScripted ItemProperty = "scripted"
ItemPropertySVG ItemProperty = "svg"
ItemPropertySwitch ItemProperty = "switch"
)
var (
_ xml.MarshalerAttr = (ItemProperties)(nil)
_ xml.UnmarshalerAttr = (*ItemProperties)(nil)
)
func (is ItemProperties) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
strs := make([]string, len(is))
for i := range is {
strs[i] = string(is[i])
}
return xml.Attr{Name: name, Value: strings.Join(strs, " ")}, nil
}
func (is *ItemProperties) UnmarshalXMLAttr(attr xml.Attr) error {
if is == nil {
is = &ItemProperties{}
}
for s := range strings.SplitSeq(attr.Value, " ") {
*is = append(*is, ItemProperty(s))
}
return nil
}
func encoderHelper(e *xml.Encoder, errPrefix ...string) func(
key string, value string, attrs ...xml.Attr,
) error {
if len(errPrefix) == 0 {
errPrefix[0] = ""
} else {
errPrefix[0] = fmt.Sprintf("%s: ", errPrefix[0])
}
return func(key string, value string, attrs ...xml.Attr) error {
err := e.EncodeElement(value, xml.StartElement{
Name: xml.Name{Local: key},
Attr: attrs,
})
if err != nil {
return fmt.Errorf("%sfailed to encode %q: %w", errPrefix[0], key, err)
}
return nil
}
}

11
editor/go.mod Normal file
View File

@@ -0,0 +1,11 @@
module code.capytal.cc/capytal/comicverse/editor
go 1.25.2
require (
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1
github.com/google/uuid v1.6.0
golang.org/x/net v0.47.0
golang.org/x/text v0.31.0
)

10
editor/go.sum Normal file
View File

@@ -0,0 +1,10 @@
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e h1:LdkirHDzhkcnhOBnDN0po84DjHAAkGztjHu/4mfWpSI=
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e/go.mod h1:jMvSPUj295pTk/ixyxZfwZJE/RQ7DZzvQ3cVoAklkPA=
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1 h1:BE0QdvwVVTG/t7nwNO5rrLf1vdAc5axv/1mWd/oAWhw=
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1/go.mod h1:p5ZPHzutdbUDfpvNBCjv5ls6rM4YNl2k4ipD5b0aRho=
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=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=

View File

@@ -0,0 +1,8 @@
Adjectives and names list files were copied from Dustin Kirkland's <dustin.kirkland@gmail.com>
petname project at Github, specifically from these files:
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/adjectives.txt
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/names.txt
The original files are provided and released under the Apache License version 2,
which a copy is available at
https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/LICENSE

View File

@@ -0,0 +1,449 @@
able
above
absolute
accepted
accurate
ace
active
actual
adapted
adapting
adequate
adjusted
advanced
alert
alive
allowed
allowing
amazed
amazing
ample
amused
amusing
apparent
apt
arriving
artistic
assured
assuring
awaited
awake
aware
balanced
becoming
beloved
better
big
blessed
bold
boss
brave
brief
bright
bursting
busy
calm
capable
capital
careful
caring
casual
causal
central
certain
champion
charmed
charming
cheerful
chief
choice
civil
classic
clean
clear
clever
climbing
close
closing
coherent
comic
communal
complete
composed
concise
concrete
content
cool
correct
cosmic
crack
creative
credible
crisp
crucial
cuddly
cunning
curious
current
cute
daring
darling
dashing
dear
decent
deciding
deep
definite
delicate
desired
destined
devoted
direct
discrete
distinct
diverse
divine
dominant
driven
driving
dynamic
eager
easy
electric
elegant
emerging
eminent
enabled
enabling
endless
engaged
engaging
enhanced
enjoyed
enormous
enough
epic
equal
equipped
eternal
ethical
evident
evolved
evolving
exact
excited
exciting
exotic
expert
factual
fair
faithful
famous
fancy
fast
feasible
fine
finer
firm
first
fit
fitting
fleet
flexible
flowing
fluent
flying
fond
frank
free
fresh
full
fun
funky
funny
game
generous
gentle
genuine
giving
glad
glorious
glowing
golden
good
gorgeous
grand
grateful
great
growing
grown
guided
guiding
handy
happy
hardy
harmless
healthy
helped
helpful
helping
heroic
hip
holy
honest
hopeful
hot
huge
humane
humble
humorous
ideal
immense
immortal
immune
improved
in
included
infinite
informed
innocent
inspired
integral
intense
intent
internal
intimate
inviting
joint
just
keen
key
kind
knowing
known
large
lasting
leading
learning
legal
legible
lenient
liberal
light
liked
literate
live
living
logical
loved
loving
loyal
lucky
magical
magnetic
main
major
many
massive
master
mature
maximum
measured
meet
merry
mighty
mint
model
modern
modest
moral
more
moved
moving
musical
mutual
national
native
natural
nearby
neat
needed
neutral
new
next
nice
noble
normal
notable
noted
novel
obliging
on
one
open
optimal
optimum
organic
oriented
outgoing
patient
peaceful
perfect
pet
picked
pleasant
pleased
pleasing
poetic
polished
polite
popular
positive
possible
powerful
precious
precise
premium
prepared
present
pretty
primary
prime
pro
probable
profound
promoted
prompt
proper
proud
proven
pumped
pure
quality
quick
quiet
rapid
rare
rational
ready
real
refined
regular
related
relative
relaxed
relaxing
relevant
relieved
renewed
renewing
resolved
rested
rich
right
robust
romantic
ruling
sacred
safe
saved
saving
secure
select
selected
sensible
set
settled
settling
sharing
sharp
shining
simple
sincere
singular
skilled
smart
smashing
smiling
smooth
social
solid
sought
sound
special
splendid
square
stable
star
steady
sterling
still
stirred
stirring
striking
strong
stunning
subtle
suitable
suited
summary
sunny
super
superb
supreme
sure
sweeping
sweet
talented
teaching
tender
thankful
thorough
tidy
tight
together
tolerant
top
topical
tops
touched
touching
tough
true
trusted
trusting
trusty
ultimate
unbiased
uncommon
unified
unique
united
up
upright
upward
usable
useful
valid
valued
vast
verified
viable
vital
vocal
wanted
warm
wealthy
welcome
welcomed
well
whole
willing
winning
wired
wise
witty
wondrous
workable
working
worthy

View File

@@ -0,0 +1,452 @@
ox
ant
ape
asp
bat
bee
boa
bug
cat
cod
cow
cub
doe
dog
eel
eft
elf
elk
emu
ewe
fly
fox
gar
gnu
hen
hog
imp
jay
kid
kit
koi
lab
man
owl
pig
pug
pup
ram
rat
ray
yak
bass
bear
bird
boar
buck
bull
calf
chow
clam
colt
crab
crow
dane
deer
dodo
dory
dove
drum
duck
fawn
fish
flea
foal
fowl
frog
gnat
goat
grub
gull
hare
hawk
ibex
joey
kite
kiwi
lamb
lark
lion
loon
lynx
mako
mink
mite
mole
moth
mule
mutt
newt
orca
oryx
pika
pony
puma
seal
shad
slug
sole
stag
stud
swan
tahr
teal
tick
toad
tuna
wasp
wolf
worm
wren
yeti
adder
akita
alien
aphid
bison
boxer
bream
bunny
burro
camel
chimp
civet
cobra
coral
corgi
crane
dingo
drake
eagle
egret
filly
finch
gator
gecko
ghost
ghoul
goose
guppy
heron
hippo
horse
hound
husky
hyena
koala
krill
leech
lemur
liger
llama
louse
macaw
midge
molly
moose
moray
mouse
panda
perch
prawn
quail
racer
raven
rhino
robin
satyr
shark
sheep
shrew
skink
skunk
sloth
snail
snake
snipe
squid
stork
swift
tapir
tetra
tiger
troll
trout
viper
wahoo
whale
zebra
alpaca
amoeba
baboon
badger
beagle
bedbug
beetle
bengal
bobcat
caiman
cattle
cicada
collie
condor
cougar
coyote
dassie
dragon
earwig
falcon
feline
ferret
gannet
gibbon
glider
goblin
gopher
grouse
guinea
hermit
hornet
iguana
impala
insect
jackal
jaguar
jennet
kitten
kodiak
lizard
locust
maggot
magpie
mammal
mantis
marlin
marmot
marten
martin
mayfly
minnow
monkey
mullet
muskox
ocelot
oriole
osprey
oyster
parrot
pigeon
piglet
poodle
possum
python
quagga
rabbit
raptor
rodent
roughy
salmon
sawfly
serval
shiner
shrimp
spider
sponge
tarpon
thrush
tomcat
toucan
turkey
turtle
urchin
vervet
walrus
weasel
weevil
wombat
anchovy
anemone
bluejay
buffalo
bulldog
buzzard
caribou
catfish
chamois
cheetah
chicken
chigger
cowbird
crappie
crawdad
cricket
dogfish
dolphin
firefly
garfish
gazelle
gelding
giraffe
gobbler
gorilla
goshawk
grackle
griffon
grizzly
grouper
haddock
hagfish
halibut
hamster
herring
javelin
jawfish
jaybird
katydid
ladybug
lamprey
lemming
leopard
lioness
lobster
macaque
mallard
mammoth
manatee
mastiff
meerkat
mollusk
monarch
mongrel
monitor
monster
mudfish
muskrat
mustang
narwhal
oarfish
octopus
opossum
ostrich
panther
peacock
pegasus
pelican
penguin
phoenix
piranha
polecat
primate
quetzal
raccoon
rattler
redbird
redfish
reptile
rooster
sawfish
sculpin
seagull
skylark
snapper
spaniel
sparrow
sunbeam
sunbird
sunfish
tadpole
terrier
unicorn
vulture
wallaby
walleye
warthog
whippet
wildcat
aardvark
airedale
albacore
anteater
antelope
arachnid
barnacle
basilisk
blowfish
bluebird
bluegill
bonefish
bullfrog
cardinal
chipmunk
cockatoo
crayfish
dinosaur
doberman
duckling
elephant
escargot
flamingo
flounder
foxhound
glowworm
goldfish
grubworm
hedgehog
honeybee
hookworm
humpback
kangaroo
killdeer
kingfish
labrador
lacewing
ladybird
lionfish
longhorn
mackerel
malamute
marmoset
mastodon
moccasin
mongoose
monkfish
mosquito
pangolin
parakeet
pheasant
pipefish
platypus
polliwog
porpoise
reindeer
ringtail
sailfish
scorpion
seahorse
seasnail
sheepdog
shepherd
silkworm
squirrel
stallion
starfish
starling
stingray
stinkbug
sturgeon
terrapin
titmouse
tortoise
treefrog
werewolf
woodcock

View File

@@ -0,0 +1,33 @@
package randname
import (
_ "embed"
"fmt"
"math/rand"
"strings"
)
// TODO: Make generator be based on fantasy, sci-fi and other literature
// and artistic names.
//go:embed adjectives.txt
var adjectives string
//go:embed names.txt
var names string
var (
adjectivesList = strings.Split(adjectives, "\n")
namesList = strings.Split(names, "\n")
)
func New(sep ...string) string {
if len(sep) == 0 {
sep = append(sep, " ")
}
a := adjectivesList[rand.Intn(len(adjectivesList))]
n := namesList[rand.Intn(len(namesList))]
return fmt.Sprintf("%s%s%s", a, sep[0], n)
}

View File

@@ -0,0 +1,27 @@
package router
import (
"net/http"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/capytal/comicverse/editor/internals/randname"
"code.capytal.cc/loreddev/smalltrip/problem"
"code.capytal.cc/loreddev/x/xtemplate"
)
type dashboardController struct {
editor *editor.Editor
templater xtemplate.Templater
}
func (ctrl *dashboardController) dashboard(w http.ResponseWriter, r *http.Request) {
randtitle := randname.New()
err := ctrl.templater.ExecuteTemplate(w, "editor-dashboard", map[string]any{
"RandTitle": randtitle,
})
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
}

View File

@@ -0,0 +1,68 @@
package router
import (
"errors"
"fmt"
"net/http"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/capytal/comicverse/editor/internals/randname"
"code.capytal.cc/loreddev/smalltrip/problem"
"code.capytal.cc/loreddev/x/xtemplate"
"github.com/google/uuid"
"golang.org/x/text/language"
)
type publicationController struct {
editor *editor.Editor
templater xtemplate.Templater
}
func (ctrl *publicationController) createPublication(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title")
if title == "" {
title = randname.New()
}
lang := language.English
id, err := uuid.NewV7()
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
_, err = ctrl.editor.New(id, title, lang)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("./%s", id), http.StatusTemporaryRedirect)
}
func (ctrl *publicationController) getPublication(w http.ResponseWriter, r *http.Request) {
idstr := r.PathValue("publicationID")
if idstr == "" {
problem.NewBadRequest("Missing publication ID in path").ServeHTTP(w, r)
return
}
id, err := uuid.Parse(idstr)
if err != nil {
problem.NewBadRequest("Invalid UUID in path", problem.WithError(err)).ServeHTTP(w, r)
return
}
pkg, err := ctrl.editor.Open(id)
if errors.Is(err, editor.ErrNotExists) {
problem.NewNotFound().ServeHTTP(w, r)
return
} else if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
w.Write(fmt.Appendf([]byte{}, "%+v", pkg))
}

52
editor/router/router.go Normal file
View File

@@ -0,0 +1,52 @@
package router
import (
"io/fs"
"log/slog"
"net/http"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/loreddev/smalltrip"
"code.capytal.cc/loreddev/smalltrip/middleware"
"code.capytal.cc/loreddev/smalltrip/multiplexer"
"code.capytal.cc/loreddev/x/xtemplate"
)
func New(cfg Config) http.Handler {
log := cfg.Logger
mux := multiplexer.New()
mux = multiplexer.WithFormMethod(mux, "x-method")
mux = multiplexer.WithPatternRules(mux,
multiplexer.EnsureMethod(),
multiplexer.EnsureTrailingSlash(),
multiplexer.EnsureStrictEnd(),
)
r := smalltrip.NewRouter(
smalltrip.WithMultiplexer(mux),
smalltrip.WithLogger(log.WithGroup("router")),
)
r.Use(middleware.Logger(log.WithGroup("requests")))
// r.Use(problem.Middleware(problem.DefaultHandler))
r.Handle("GET /assets/{asset...}", http.StripPrefix("/assets/", http.FileServerFS(cfg.Assets)))
dashboardCtrl := &dashboardController{editor: cfg.Editor, templater: cfg.Templater}
publicationCtrl := &publicationController{editor: cfg.Editor, templater: cfg.Templater}
r.HandleFunc("GET /{$}", dashboardCtrl.dashboard)
r.HandleFunc("POST /publication/{$}", publicationCtrl.createPublication)
r.HandleFunc("GET /publication/{publicationID}/{$}", publicationCtrl.getPublication)
return r
}
type Config struct {
Assets fs.FS
Editor *editor.Editor
Templater xtemplate.Templater
Logger *slog.Logger
}

98
editor/storage/local.go Normal file
View File

@@ -0,0 +1,98 @@
package storage
import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
)
func Newlocal(
root *os.Root,
logger *slog.Logger,
) Storage {
return &local{
log: logger,
root: root,
}
}
type local struct {
log *slog.Logger
root *os.Root
}
var _ Storage = (*local)(nil)
func (files *local) Exists(p string) bool {
if _, err := files.root.Stat(p); err != nil {
return false
}
return true
}
func (files *local) Open(p string) (fs.File, error) {
log := files.log.With(
slog.String("path", p),
slog.String("root", files.root.Name()))
log.Debug("Opening file")
defer log.Debug("File opened")
f, err := files.root.Open(p)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotExists
} else if err != nil {
return nil, err
}
return f, nil
}
func (files *local) Write(p string, d []byte) (int, error) {
log := files.log.With(
slog.String("path", p),
slog.String("root", files.root.Name()))
log.Debug("Writing file")
defer log.Debug("File wrote")
if err := files.root.MkdirAll(path.Dir(p), os.ModePerm); err != nil {
return 0, fmt.Errorf("file.local: failed to create parent directories %q: %w", path.Dir(p), err)
}
err := files.root.WriteFile(p, d, os.ModePerm)
if err != nil {
return 0, fmt.Errorf("file.local: failed to write file %q: %w", p, err)
}
return len(d), nil
}
func (files *local) WriteFrom(p string, r io.Reader) (int64, error) {
log := files.log.With(
slog.String("path", p),
slog.String("root", files.root.Name()))
log.Debug("Writing file")
defer log.Debug("File wrote")
if err := files.root.MkdirAll(path.Dir(p), os.ModePerm); err != nil {
return 0, fmt.Errorf("file.local: failed to create parent directories %q: %w", path.Dir(p), err)
}
f, err := files.root.Create(p)
if err != nil {
return 0, fmt.Errorf("file.local: failed to create file %q: %w", p, err)
}
n, err := f.ReadFrom(r)
if err != nil {
return 0, fmt.Errorf("file.local: failed to write file %q: %w", p, err)
}
return n, nil
}

42
editor/storage/storage.go Normal file
View File

@@ -0,0 +1,42 @@
package storage
import (
"io"
"io/fs"
"os"
"path"
)
type Storage interface {
Exists(p string) bool
Open(p string) (fs.File, error)
Write(p string, b []byte) (int, error)
WriteFrom(p string, r io.Reader) (int64, error)
}
type withRoot struct {
root string
Storage
}
func WithRoot(rootDir string, s Storage) Storage {
return &withRoot{root: rootDir, Storage: s}
}
func (f *withRoot) Exists(p string) bool {
return f.Storage.Exists(path.Join(f.root, p))
}
func (f *withRoot) Open(p string) (fs.File, error) {
return f.Storage.Open(path.Join(f.root, p))
}
func (f *withRoot) Write(p string, b []byte) (int, error) {
return f.Storage.Write(path.Join(f.root, p), b)
}
func (f *withRoot) WriteFrom(p string, r io.Reader) (int64, error) {
return f.Storage.WriteFrom(path.Join(f.root, p), r)
}
var ErrNotExists = os.ErrNotExist

View File

@@ -0,0 +1,31 @@
{{define "editor-dashboard"}} {{template "layout-base"}}
<body class="bg-gray-900 text-gray-50 has-[#first-publication]:h-svw">
<main class="has-[#first-publication]:h-full flex flex-col">
{{if .Publications}}
<p>Publications</p>
{{else}}
<h1>Create your first publication</h1>
<form method="post" action="/publication/" id="first-publication">
<input
id="title"
type="text"
name="title"
value="{{if .RandTitle}}{{.RandTitle}}{{end}}"
/><button type="submit">Create</button>
</form>
<style>
body:has(:is(#first-publication)) {
height: 100svh;
& > main {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
}
</style>
{{end}}
</main>
</body>
{{template "layout-base-end"}} {{end}}

View File

@@ -0,0 +1,19 @@
{{define "layout-base"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{if .Title}}
<title>{{.Title}}</title>
{{end}}
<link href="/assets/css/style.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

@@ -0,0 +1,50 @@
package template
import (
"embed"
"errors"
"fmt"
"html/template"
"io/fs"
"code.capytal.cc/loreddev/x/xtemplate"
)
func New() (xtemplate.Template, error) {
return xtemplate.New[template.Template]("template").
Funcs(functions).
ParseFS(embedded, patterns...)
}
//go:embed *.html layouts/*.html
var embedded embed.FS
func Dev(dir fs.FS) (xtemplate.Template, error) {
return xtemplate.NewHot[template.Template]("template").
Funcs(functions).
ParseFS(dir, patterns...)
}
var (
patterns = []string{"*.html", "layouts/*.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
},
}
)

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1742069588,
"narHash": "sha256-C7jVfohcGzdZRF6DO+ybyG/sqpo1h6bZi9T56sxLy+k=",
"lastModified": 1762844143,
"narHash": "sha256-SlybxLZ1/e4T2lb1czEtWVzDCVSTvk9WLwGhmxFmBxI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c80f6a7e10b39afcc1894e02ef785b1ad0b0d7e5",
"rev": "9da7f1cf7f8a6e2a7cb3001b048546c92a8258b4",
"type": "github"
},
"original": {

View File

@@ -19,11 +19,19 @@
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
CGO_ENABLED = "1";
hardeningDisable = ["all"];
hardeningDisable = ["fortify"];
GOPRIVATE = "code.capytal.cc/*";
shellHook = ''
set -a
source .env
set +a
'';
buildInputs = with pkgs; [
# Go tools
go
go_1_25
golangci-lint
gofumpt
gotools
@@ -39,6 +47,12 @@
# S3
awscli
# ePUB
http-server
calibre
zip
unzip
];
};
});

11
go.mod
View File

@@ -1,12 +1,16 @@
module forge.capytal.company/capytalcode/project-comicverse
module code.capytal.cc/capytal/comicverse
go 1.24.1
go 1.25.2
require (
forge.capytal.company/loreddev/x v0.0.0-20250305165122-0ccb26ab783b
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.43.0
)
require (
@@ -22,4 +26,5 @@ require (
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
golang.org/x/sync v0.17.0 // indirect
)

14
go.sum
View File

@@ -1,5 +1,7 @@
forge.capytal.company/loreddev/x v0.0.0-20250305165122-0ccb26ab783b h1:QxTrkGp1cBiPs5vd1Lkh+I/3kNc82CQ5VkF3Cp+8R3E=
forge.capytal.company/loreddev/x v0.0.0-20250305165122-0ccb26ab783b/go.mod h1:Fc5nkrgOwJYdiwZK9SElFAB5xd7C/fh/mD+tBERfUPM=
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=
@@ -24,17 +26,21 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1 h1:1M0gSbyP6q06gl3384wpoKPaH9G16
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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
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=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

View File

@@ -1,6 +1,8 @@
go 1.24.1
go 1.25.2
use (
./.
.
./editor
./smalltrip
./x
)

16
go.work.sum Normal file
View File

@@ -0,0 +1,16 @@
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=

View File

@@ -0,0 +1,8 @@
Adjectives and names list files were copied from Dustin Kirkland's <dustin.kirkland@gmail.com>
petname project at Github, specifically from these files:
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/adjectives.txt
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/names.txt
The original files are provided and released under the Apache License version 2,
which a copy is available at
https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/LICENSE

View File

@@ -0,0 +1,452 @@
ox
ant
ape
asp
bat
bee
boa
bug
cat
cod
cow
cub
doe
dog
eel
eft
elf
elk
emu
ewe
fly
fox
gar
gnu
hen
hog
imp
jay
kid
kit
koi
lab
man
owl
pig
pug
pup
ram
rat
ray
yak
bass
bear
bird
boar
buck
bull
calf
chow
clam
colt
crab
crow
dane
deer
dodo
dory
dove
drum
duck
fawn
fish
flea
foal
fowl
frog
gnat
goat
grub
gull
hare
hawk
ibex
joey
kite
kiwi
lamb
lark
lion
loon
lynx
mako
mink
mite
mole
moth
mule
mutt
newt
orca
oryx
pika
pony
puma
seal
shad
slug
sole
stag
stud
swan
tahr
teal
tick
toad
tuna
wasp
wolf
worm
wren
yeti
adder
akita
alien
aphid
bison
boxer
bream
bunny
burro
camel
chimp
civet
cobra
coral
corgi
crane
dingo
drake
eagle
egret
filly
finch
gator
gecko
ghost
ghoul
goose
guppy
heron
hippo
horse
hound
husky
hyena
koala
krill
leech
lemur
liger
llama
louse
macaw
midge
molly
moose
moray
mouse
panda
perch
prawn
quail
racer
raven
rhino
robin
satyr
shark
sheep
shrew
skink
skunk
sloth
snail
snake
snipe
squid
stork
swift
tapir
tetra
tiger
troll
trout
viper
wahoo
whale
zebra
alpaca
amoeba
baboon
badger
beagle
bedbug
beetle
bengal
bobcat
caiman
cattle
cicada
collie
condor
cougar
coyote
dassie
dragon
earwig
falcon
feline
ferret
gannet
gibbon
glider
goblin
gopher
grouse
guinea
hermit
hornet
iguana
impala
insect
jackal
jaguar
jennet
kitten
kodiak
lizard
locust
maggot
magpie
mammal
mantis
marlin
marmot
marten
martin
mayfly
minnow
monkey
mullet
muskox
ocelot
oriole
osprey
oyster
parrot
pigeon
piglet
poodle
possum
python
quagga
rabbit
raptor
rodent
roughy
salmon
sawfly
serval
shiner
shrimp
spider
sponge
tarpon
thrush
tomcat
toucan
turkey
turtle
urchin
vervet
walrus
weasel
weevil
wombat
anchovy
anemone
bluejay
buffalo
bulldog
buzzard
caribou
catfish
chamois
cheetah
chicken
chigger
cowbird
crappie
crawdad
cricket
dogfish
dolphin
firefly
garfish
gazelle
gelding
giraffe
gobbler
gorilla
goshawk
grackle
griffon
grizzly
grouper
haddock
hagfish
halibut
hamster
herring
javelin
jawfish
jaybird
katydid
ladybug
lamprey
lemming
leopard
lioness
lobster
macaque
mallard
mammoth
manatee
mastiff
meerkat
mollusk
monarch
mongrel
monitor
monster
mudfish
muskrat
mustang
narwhal
oarfish
octopus
opossum
ostrich
panther
peacock
pegasus
pelican
penguin
phoenix
piranha
polecat
primate
quetzal
raccoon
rattler
redbird
redfish
reptile
rooster
sawfish
sculpin
seagull
skylark
snapper
spaniel
sparrow
sunbeam
sunbird
sunfish
tadpole
terrier
unicorn
vulture
wallaby
walleye
warthog
whippet
wildcat
aardvark
airedale
albacore
anteater
antelope
arachnid
barnacle
basilisk
blowfish
bluebird
bluegill
bonefish
bullfrog
cardinal
chipmunk
cockatoo
crayfish
dinosaur
doberman
duckling
elephant
escargot
flamingo
flounder
foxhound
glowworm
goldfish
grubworm
hedgehog
honeybee
hookworm
humpback
kangaroo
killdeer
kingfish
labrador
lacewing
ladybird
lionfish
longhorn
mackerel
malamute
marmoset
mastodon
moccasin
mongoose
monkfish
mosquito
pangolin
parakeet
pheasant
pipefish
platypus
polliwog
porpoise
reindeer
ringtail
sailfish
scorpion
seahorse
seasnail
sheepdog
shepherd
silkworm
squirrel
stallion
starfish
starling
stingray
stinkbug
sturgeon
terrapin
titmouse
tortoise
treefrog
werewolf
woodcock

View File

@@ -0,0 +1,449 @@
able
above
absolute
accepted
accurate
ace
active
actual
adapted
adapting
adequate
adjusted
advanced
alert
alive
allowed
allowing
amazed
amazing
ample
amused
amusing
apparent
apt
arriving
artistic
assured
assuring
awaited
awake
aware
balanced
becoming
beloved
better
big
blessed
bold
boss
brave
brief
bright
bursting
busy
calm
capable
capital
careful
caring
casual
causal
central
certain
champion
charmed
charming
cheerful
chief
choice
civil
classic
clean
clear
clever
climbing
close
closing
coherent
comic
communal
complete
composed
concise
concrete
content
cool
correct
cosmic
crack
creative
credible
crisp
crucial
cuddly
cunning
curious
current
cute
daring
darling
dashing
dear
decent
deciding
deep
definite
delicate
desired
destined
devoted
direct
discrete
distinct
diverse
divine
dominant
driven
driving
dynamic
eager
easy
electric
elegant
emerging
eminent
enabled
enabling
endless
engaged
engaging
enhanced
enjoyed
enormous
enough
epic
equal
equipped
eternal
ethical
evident
evolved
evolving
exact
excited
exciting
exotic
expert
factual
fair
faithful
famous
fancy
fast
feasible
fine
finer
firm
first
fit
fitting
fleet
flexible
flowing
fluent
flying
fond
frank
free
fresh
full
fun
funky
funny
game
generous
gentle
genuine
giving
glad
glorious
glowing
golden
good
gorgeous
grand
grateful
great
growing
grown
guided
guiding
handy
happy
hardy
harmless
healthy
helped
helpful
helping
heroic
hip
holy
honest
hopeful
hot
huge
humane
humble
humorous
ideal
immense
immortal
immune
improved
in
included
infinite
informed
innocent
inspired
integral
intense
intent
internal
intimate
inviting
joint
just
keen
key
kind
knowing
known
large
lasting
leading
learning
legal
legible
lenient
liberal
light
liked
literate
live
living
logical
loved
loving
loyal
lucky
magical
magnetic
main
major
many
massive
master
mature
maximum
measured
meet
merry
mighty
mint
model
modern
modest
moral
more
moved
moving
musical
mutual
national
native
natural
nearby
neat
needed
neutral
new
next
nice
noble
normal
notable
noted
novel
obliging
on
one
open
optimal
optimum
organic
oriented
outgoing
patient
peaceful
perfect
pet
picked
pleasant
pleased
pleasing
poetic
polished
polite
popular
positive
possible
powerful
precious
precise
premium
prepared
present
pretty
primary
prime
pro
probable
profound
promoted
prompt
proper
proud
proven
pumped
pure
quality
quick
quiet
rapid
rare
rational
ready
real
refined
regular
related
relative
relaxed
relaxing
relevant
relieved
renewed
renewing
resolved
rested
rich
right
robust
romantic
ruling
sacred
safe
saved
saving
secure
select
selected
sensible
set
settled
settling
sharing
sharp
shining
simple
sincere
singular
skilled
smart
smashing
smiling
smooth
social
solid
sought
sound
special
splendid
square
stable
star
steady
sterling
still
stirred
stirring
striking
strong
stunning
subtle
suitable
suited
summary
sunny
super
superb
supreme
sure
sweeping
sweet
talented
teaching
tender
thankful
thorough
tidy
tight
together
tolerant
top
topical
tops
touched
touching
tough
true
trusted
trusting
trusty
ultimate
unbiased
uncommon
unified
unique
united
up
upright
upward
usable
useful
valid
valued
vast
verified
viable
vital
vocal
wanted
warm
wealthy
welcome
welcomed
well
whole
willing
winning
wired
wise
witty
wondrous
workable
working
worthy

View File

@@ -0,0 +1,33 @@
package randname
import (
_ "embed"
"fmt"
"math/rand"
"strings"
)
// TODO: Make generator be based on fantasy, sci-fi and other literature
// and artistic names.
//go:embed adjectives.txt
var adjectives string
//go:embed names.txt
var names string
var (
adjectivesList = strings.Split(adjectives, "\n")
namesList = strings.Split(names, "\n")
)
func New(sep ...string) string {
if len(sep) == 0 {
sep = append(sep, " ")
}
a := adjectivesList[rand.Intn(len(adjectivesList))]
n := namesList[rand.Intn(len(namesList))]
return fmt.Sprintf("%s%s%s", a, sep[0], n)
}

View File

@@ -6,8 +6,8 @@ import (
"io"
"testing"
"forge.capytal.company/capytalcode/project-comicverse/ipub/ast"
"forge.capytal.company/loreddev/x/tinyssert"
"code.capytal.cc/capytal/comicverse/ipub/ast"
"code.capytal.cc/loreddev/x/tinyssert"
)
//go:embed test.xml

View File

@@ -9,7 +9,7 @@ import (
"slices"
"strings"
"forge.capytal.company/capytalcode/project-comicverse/ipub/element/attr"
"code.capytal.cc/capytal/comicverse/ipub/element/attr"
)
type Element interface {

View File

@@ -4,7 +4,7 @@ import (
"encoding/xml"
"testing"
"forge.capytal.company/capytalcode/project-comicverse/ipub/element"
"code.capytal.cc/capytal/comicverse/ipub/element"
)
func Test(t *testing.T) {

View File

@@ -27,6 +27,36 @@ dev/assets:
dev:
$(MAKE) -j2 dev/assets dev/server
dev/debug:
$(MAKE) -j2 debug dev/assets
editor/dev/server:
cd ./editor; 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
editor/dev/assets:
cd ./editor; tailwindcss \
-i ./assets/css/tailwind.css \
-o ./assets/css/style.css \
--watch
editor/dev:
$(MAKE) -j2 editor/dev/assets editor/dev/server
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 \
@@ -39,6 +69,19 @@ build: build/assets
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

66
model/model.go Normal file
View File

@@ -0,0 +1,66 @@
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)
}

145
model/permission.go Normal file
View File

@@ -0,0 +1,145 @@
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"
PermissionAdminPublication Permissions = 0x0100000000000000 // "admin.publication"
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",
PermissionAdminPublication: "admin.publication",
PermissionAdminMembers: "admin.members",
PermissionEditPages: "edit.pages",
PermissionEditInteractions: "edit.interactions",
PermissionEditDialogs: "edit.dialogs",
PermissionEditTranslations: "edit.translations",
PermissionEditAccessibility: "edit.accessibility",
PermissionRead: "read",
}

38
model/publication.go Normal file
View File

@@ -0,0 +1,38 @@
package model
import (
"time"
"github.com/google/uuid"
)
type Publication 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 = (*Publication)(nil)
func (p Publication) 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: "Publication", Errors: errs}
}
return nil
}

34
model/token.go Normal file
View File

@@ -0,0 +1,34 @@
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
}

41
model/user.go Normal file
View File

@@ -0,0 +1,41 @@
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
}

283
repository/permission.go Normal file
View File

@@ -0,0 +1,283 @@
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 [Publication]
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 publication_permissions (
publication_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(publication_id, user_id)
FOREIGN KEY(publication_id)
REFERENCES publications (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 publication tables"), err)
}
return &Permissions{baseRepostiory: b}, nil
}
func (repo Permissions) Create(publication, 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 publication_permissions (publication_id, user_id, permissions_value, _permissions_text, created_at, updated_at)
VALUES (:publication_id, :user_id, :permissions_value, :permissions_text, :created_at, :updated_at)
`
now := time.Now()
log := repo.log.With(slog.String("publication_id", publication.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 publication permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("publication_id", publication),
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 publication 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(publication 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 publication_permissions
WHERE publication_id = :publication_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("projcet_id", publication.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("publication_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 publication_id-to-permissions map containing all publications 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 publication_id, permissions_value FROM publication_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 publication uuid.UUID
var permissions model.Permissions
err := rows.Scan(&publication, &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[publication] = 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(publication, 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 publication_permissions
SET permissions_value = :permissions_value
_permissions_text = :permissions_text
updated_at = :updated_at
WHERE publication_uuid = :publication_uuid
AND user_uuid = :user_uuid
`
log := repo.log.With(slog.String("publication_id", publication.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 publication 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("publication_id", publication),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to update publication 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(publication, 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 publication_permissions
WHERE publication_id = :publication_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("publication_id", publication.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting publication permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("publication_id", publication),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete publication 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
}

282
repository/publication.go Normal file
View File

@@ -0,0 +1,282 @@
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 Publication struct {
baseRepostiory
}
func NewPublication(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Publication, 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 publications (
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 publication tables"), err)
}
return &Publication{baseRepostiory: b}, nil
}
func (repo Publication) Create(p model.Publication) 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 publications (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 publication")
_, 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 publication", 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 Publication) GetByID(publicationID uuid.UUID) (publication model.Publication, 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 publications
WHERE id = :id
`
log := repo.log.With(slog.String("query", q), slog.String("id", publicationID.String()))
log.DebugContext(repo.ctx, "Getting publication by ID")
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("id", publicationID))
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 publications with IDs", slog.String("error", err.Error()))
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
}
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
}
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
}
return model.Publication{
ID: id,
Title: title,
DateCreated: dateCreated,
DateUpdated: dateUpdated,
}, nil
}
func (repo Publication) GetByIDs(ids []uuid.UUID) (publications []model.Publication, 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 publications
WHERE %s
`, strings.Join(c, " OR "))
log := repo.log.With(slog.String("query", q))
log.DebugContext(repo.ctx, "Getting publications by IDs")
rows, err := tx.QueryContext(repo.ctx, q)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to get publications 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.Publication{}
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 publications 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 publications 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 publications with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
ps = append(ps, model.Publication{
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 Publication) Update(p model.Publication) 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 publications
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 publication")
_, 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 publication", 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 Publication) 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 publications WHERE id = :id
`
log := repo.log.With(slog.String("id", id.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting publication")
_, err = tx.ExecContext(repo.ctx, q, sql.Named("id", id))
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete publication", 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
}

52
repository/repository.go Normal file
View File

@@ -0,0 +1,52 @@
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
// TODO: Change error to be agnostic to underlying storage type
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
}

17
repository/role.go Normal file
View File

@@ -0,0 +1,17 @@
package repository
import (
"context"
"database/sql"
"log/slog"
"code.capytal.cc/loreddev/x/tinyssert"
)
type RoleRepository struct {
db *sql.DB
ctx context.Context
log *slog.Logger
assert tinyssert.Assertions
}

245
repository/token.go Normal file
View File

@@ -0,0 +1,245 @@
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
}

211
repository/user.go Normal file
View File

@@ -0,0 +1,211 @@
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,283 +0,0 @@
package router
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"forge.capytal.company/capytalcode/project-comicverse/internals/randstr"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/loreddev/x/smalltrip/exception"
)
func (router *router) pages(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
// TODO: Check if project exists
id := r.PathValue("ID")
if id == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
ServeHTTP(w, r)
return
}
pageID := r.PathValue("PageID")
switch getMethod(r) {
case http.MethodGet, http.MethodHead:
if pageID == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "PageID" must be provided`)).
ServeHTTP(w, r)
return
}
router.getPage(w, r)
case http.MethodPost:
router.addPage(w, r)
case http.MethodDelete:
if pageID == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "PageID" must be provided`)).
ServeHTTP(w, r)
return
}
router.deletePage(w, r)
default:
exception.
MethodNotAllowed([]string{
http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodDelete,
}).
ServeHTTP(w, r)
}
}
func (router *router) addPage(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
img, _, err := r.FormFile("image")
if err != nil {
// TODO: Handle if the file is bigger than allowed by ParseForm (10mb)
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
err = router.service.AddPage(id, img)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
}
func (router *router) getPage(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
pageID := r.PathValue("PageID")
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
page, err := router.service.GetPage(id, pageID)
if errors.Is(err, service.ErrPageNotExists) {
exception.NotFound(exception.WithError(err)).ServeHTTP(w, r)
return
}
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
if i, ok := page.Image.(io.WriterTo); ok {
_, err = i.WriteTo(w)
} else {
_, err = io.Copy(w, page.Image)
}
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
}
func (router *router) deletePage(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
pageID := r.PathValue("PageID")
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
err := router.service.DeletePage(id, pageID)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
}
func (router *router) interactions(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
// TODO: Check if the project exists
id := r.PathValue("ID")
if id == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
ServeHTTP(w, r)
return
}
// TODO: Check if page exists
pageID := r.PathValue("PageID")
if pageID == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "PageID" must be provided`)).
ServeHTTP(w, r)
return
}
interactionID := r.PathValue("InteractionID")
switch getMethod(r) {
case http.MethodPost:
router.addInteraction(w, r)
case http.MethodDelete:
if interactionID == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "InteractionID" must be provided`)).
ServeHTTP(w, r)
return
}
router.deleteInteraction(w, r)
default:
exception.
MethodNotAllowed([]string{
http.MethodPost,
http.MethodDelete,
}).
ServeHTTP(w, r)
}
}
func (router *router) addInteraction(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
pageID := r.PathValue("PageID")
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
// TODO: Methods to manipulate interactions, instead of router need to do this logic
page, err := router.service.GetPage(id, pageID)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
page.Image = nil // HACK: Prevent image update on S3
x, err := strconv.ParseUint(r.FormValue("x"), 10, 0)
if err != nil {
exception.
BadRequest(errors.Join(errors.New(`value "x" should be a valid non-negative integer`), err)).
ServeHTTP(w, r)
return
}
y, err := strconv.ParseUint(r.FormValue("y"), 10, 0)
if err != nil {
exception.
BadRequest(errors.Join(errors.New(`value "y" should be a valid non-negative integer`), err)).
ServeHTTP(w, r)
return
}
link := r.FormValue("link")
if link == "" {
exception.BadRequest(errors.New(`missing parameter "link" in request`)).ServeHTTP(w, r)
return
}
intID, err := randstr.NewHex(6)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
page.Interactions[intID] = service.PageInteraction{
X: uint16(x),
Y: uint16(y),
URL: link,
}
err = router.service.UpdatePage(id, page)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
}
func (router *router) deleteInteraction(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
pageID := r.PathValue("PageID")
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
interactionID := r.PathValue("InteractionID")
router.assert.NotZero(interactionID, "This method should be used after the path values are checked")
// TODO: Methods to manipulate interactions, instead of router need to do this logic
page, err := router.service.GetPage(id, pageID)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
page.Image = nil // HACK: Prevent image update on S3
delete(page.Interactions, interactionID)
err = router.service.UpdatePage(id, page)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
}

View File

@@ -1,181 +0,0 @@
package router
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/loreddev/x/smalltrip/exception"
)
func (router *router) projects(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
switch getMethod(r) {
case http.MethodGet, http.MethodHead:
if id := r.PathValue("ID"); id != "" {
router.getProject(w, r)
} else {
router.listProjects(w, r)
}
case http.MethodPost:
router.createProject(w, r)
case http.MethodDelete:
if id := r.PathValue("ID"); id != "" {
router.deleteProject(w, r)
} else {
exception.
BadRequest(errors.New(`missing "ID" path value`)).
ServeHTTP(w, r)
}
default:
exception.MethodNotAllowed([]string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodDelete,
}).ServeHTTP(w, r)
}
}
func (router *router) createProject(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
if getMethod(r) != http.MethodPost {
exception.
MethodNotAllowed([]string{http.MethodPost}).
ServeHTTP(w, r)
return
}
p, err := router.service.CreateProject()
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
router.assert.NotZero(p.ID)
http.Redirect(w, r, fmt.Sprintf("%s/", path.Join(r.URL.Path, p.ID)), http.StatusSeeOther)
}
func (router *router) getProject(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
router.assert.NotNil(router.templates)
if getMethod(r) != http.MethodGet && getMethod(r) != http.MethodHead {
exception.
MethodNotAllowed([]string{http.MethodGet, http.MethodHead}).
ServeHTTP(w, r)
return
}
id := r.PathValue("ID")
if id == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
ServeHTTP(w, r)
return
}
p, err := router.service.GetProject(id)
switch {
case errors.Is(err, service.ErrProjectNotExists):
exception.NotFound().ServeHTTP(w, r)
return
case err != nil:
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
err = router.templates.ExecuteTemplate(w, "project", p)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
}
func (router *router) listProjects(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
router.assert.NotNil(router.templates)
if getMethod(r) != http.MethodGet && getMethod(r) != http.MethodHead {
exception.
MethodNotAllowed([]string{http.MethodGet, http.MethodHead}).
ServeHTTP(w, r)
return
}
ps, err := router.service.ListProjects()
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
b, err := json.Marshal(ps)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write(b)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
}
func (router *router) deleteProject(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
router.assert.NotNil(router.templates)
if getMethod(r) != http.MethodDelete {
exception.
MethodNotAllowed([]string{http.MethodDelete}).
ServeHTTP(w, r)
return
}
id := r.PathValue("ID")
if id == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
ServeHTTP(w, r)
return
}
err := router.service.DeleteProject(id)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
err = router.templates.ExecuteTemplate(w, "partials-status", map[string]any{
"StatusCode": http.StatusOK,
"Message": fmt.Sprintf("Project %q successfully deleted", id),
"Redirect": "/dashboard/",
"RedirectMessage": "Go back to dashboard",
})
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
}

132
router/publication.go Normal file
View File

@@ -0,0 +1,132 @@
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 publicationController struct {
publicationSvc *service.Publication
templates templates.ITemplate
assert tinyssert.Assertions
}
func newPublicationController(
publicationService *service.Publication,
templates templates.ITemplate,
assertions tinyssert.Assertions,
) *publicationController {
return &publicationController{
publicationSvc: publicationService,
templates: templates,
assert: assertions,
}
}
func (ctrl publicationController) dashboard(w http.ResponseWriter, r *http.Request) {
userCtx := NewUserContext(r.Context())
userID, ok := userCtx.GetUserID()
if !ok {
userCtx.Unathorize(w, r)
return
}
publications, err := ctrl.publicationSvc.ListOwnedBy(userID)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
ps := make([]struct {
ID string
Title string
}, len(publications))
for i, publication := range publications {
ps[i] = struct {
ID string
Title string
}{
ID: base64.URLEncoding.EncodeToString([]byte(publication.ID.String())),
Title: publication.Title,
}
}
err = ctrl.templates.ExecuteTemplate(w, "dashboard", ps)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
}
}
func (ctrl publicationController) getPublication(w http.ResponseWriter, r *http.Request) {
// TODO: Handle private publications
shortPublicationID := r.PathValue("publicationID")
id, err := base64.URLEncoding.DecodeString(shortPublicationID)
if err != nil {
problem.NewBadRequest(fmt.Sprintf("Incorrectly encoded publication ID: %s", err.Error())).ServeHTTP(w, r)
return
}
publicationID, err := uuid.ParseBytes(id)
if err != nil {
problem.NewBadRequest("Publication ID is not a valid UUID").ServeHTTP(w, r)
return
}
publication, err := ctrl.publicationSvc.Get(publicationID)
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 publication template
b, err := json.Marshal(publication)
w.Header().Add("Content-Type", "application/json")
if _, err := w.Write(b); err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
}
func (ctrl publicationController) createPublication(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
}
publication, err := ctrl.publicationSvc.Create(title, userID)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
path := fmt.Sprintf("/publication/%s/", base64.URLEncoding.EncodeToString([]byte(publication.ID.String())))
http.Redirect(w, r, path, http.StatusSeeOther)
}

View File

@@ -5,18 +5,20 @@ import (
"io/fs"
"log/slog"
"net/http"
"strings"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/capytalcode/project-comicverse/templates"
"forge.capytal.company/loreddev/x/smalltrip"
"forge.capytal.company/loreddev/x/smalltrip/exception"
"forge.capytal.company/loreddev/x/smalltrip/middleware"
"forge.capytal.company/loreddev/x/tinyssert"
"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 {
service *service.Service
userService *service.User
tokenService *service.Token
publicationService *service.Publication
templates templates.ITemplate
assets fs.FS
@@ -27,8 +29,14 @@ type router struct {
}
func New(cfg Config) (http.Handler, error) {
if cfg.Service == nil {
return nil, errors.New("service is nil")
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.PublicationService == nil {
return nil, errors.New("publication service is nil")
}
if cfg.Templates == nil {
return nil, errors.New("templates is nil")
@@ -44,7 +52,9 @@ func New(cfg Config) (http.Handler, error) {
}
r := &router{
service: cfg.Service,
userService: cfg.UserService,
tokenService: cfg.TokenService,
publicationService: cfg.PublicationService,
templates: cfg.Templates,
assets: cfg.Assets,
@@ -58,7 +68,9 @@ func New(cfg Config) (http.Handler, error) {
}
type Config struct {
Service *service.Service
UserService *service.User
TokenService *service.Token
PublicationService *service.Publication
Templates templates.ITemplate
Assets fs.FS
@@ -76,8 +88,16 @@ func (router *router) setup() http.Handler {
log.Debug("Initializing router")
mux := multiplexer.New()
mux = multiplexer.WithFormMethod(mux, "x-method")
mux = multiplexer.WithPatternRules(mux,
multiplexer.EnsureMethod(),
multiplexer.EnsureStrictEnd(),
multiplexer.EnsureTrailingSlash(),
)
r := smalltrip.NewRouter(
smalltrip.WithAssertions(router.assert),
smalltrip.WithMultiplexer(mux),
smalltrip.WithLogger(log.WithGroup("smalltrip")),
)
@@ -88,51 +108,47 @@ func (router *router) setup() http.Handler {
r.Use(middleware.DisableCache())
}
r.Use(exception.PanicMiddleware())
r.Use(exception.Middleware())
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())
r.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
userController := newUserController(userControllerCfg{
UserService: router.userService,
TokenService: router.tokenService,
LoginPath: "/login/",
RedirectPath: "/",
Templates: router.templates,
Assert: router.assert,
})
publicationController := newPublicationController(router.publicationService, router.templates, router.assert)
r.HandleFunc("/dashboard/", router.dashboard)
r.Handle("GET /assets/{_file...}", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.HandleFunc("/projects/{$}", router.projects)
r.HandleFunc("/projects/{ID}/", router.projects)
r.HandleFunc("/projects/{ID}/pages/{$}", router.pages)
r.HandleFunc("/projects/{ID}/pages/{PageID}", router.pages)
r.HandleFunc("/projects/{ID}/pages/{PageID}/interactions/{$}", router.interactions)
r.HandleFunc("/projects/{ID}/pages/{PageID}/interactions/{InteractionID}", router.interactions)
r.Use(userController.userMiddleware)
r.HandleFunc("GET /{$}", 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 {
publicationController.dashboard(w, r)
return
}
err := router.templates.ExecuteTemplate(w, "landing", nil)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
}
})
r.HandleFunc("GET /login/{$}", userController.login)
r.HandleFunc("POST /login/{$}", userController.login)
r.HandleFunc("GET /register/{$}", userController.register)
r.HandleFunc("POST /register/{$}", userController.register)
// TODO: Provide/redirect short publication-id paths to long paths with the publication title as URL /publications/title-of-the-publication-<start of uuid>
r.HandleFunc("GET /publication/{publicationID}/{$}", publicationController.getPublication)
r.HandleFunc("POST /publication/{$}", publicationController.createPublication)
return r
}
func (router *router) dashboard(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(router.templates)
router.assert.NotNil(router.service)
router.assert.NotNil(w)
router.assert.NotNil(r)
p, err := router.service.ListProjects()
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
w.WriteHeader(http.StatusOK)
err = router.templates.ExecuteTemplate(w, "dashboard", p)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
}
}
func getMethod(r *http.Request) string {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return r.Method
}
m := r.FormValue("x-method")
if m == "" {
return r.Method
}
return strings.ToUpper(m)
}

293
router/user.go Normal file
View File

@@ -0,0 +1,293 @@
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,180 +0,0 @@
package service
import (
"errors"
"fmt"
"io"
"net/http"
"slices"
"forge.capytal.company/capytalcode/project-comicverse/internals/randstr"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
const pageIDLength = 6
var ErrPageNotExists = errors.New("page does not exists in storage")
type Project struct {
ID string `json:"id"`
Title string `json:"title"`
Pages []ProjectPage `json:"pages"`
}
type ProjectPage struct {
ID string `json:"id"`
Interactions map[string]PageInteraction `json:"interactions"`
Image io.ReadCloser `json:"-"`
}
type PageInteraction struct {
URL string `json:"url"`
X uint16 `json:"x"`
Y uint16 `json:"y"`
}
func (s *Service) AddPage(projectID string, img io.Reader) error {
s.assert.NotNil(s.ctx)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.bucket)
s.assert.NotZero(projectID)
s.assert.NotNil(img)
id, err := randstr.NewHex(pageIDLength)
if err != nil {
return err
}
p, err := s.GetProject(projectID)
if err != nil {
return errors.Join(errors.New("unable to get project"), err)
}
p.Pages = append(p.Pages, ProjectPage{ID: id, Interactions: map[string]PageInteraction{}})
k := fmt.Sprintf("%s/%s", projectID, id)
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
Key: &k,
Body: img,
Bucket: &s.bucket,
})
if err != nil {
return err
}
err = s.UpdateProject(projectID, p)
return err
}
func (s *Service) GetPage(projectID string, pageID string) (ProjectPage, error) {
s.assert.NotNil(s.ctx)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.bucket)
s.assert.NotZero(projectID)
s.assert.NotNil(pageID)
p, err := s.GetProject(projectID)
if err != nil {
return ProjectPage{}, errors.Join(errors.New("unable to get project"), err)
}
pageIndex := slices.IndexFunc(p.Pages, func(p ProjectPage) bool { return p.ID == pageID })
if pageIndex == -1 {
return ProjectPage{}, ErrPageNotExists
}
page := p.Pages[pageIndex]
k := fmt.Sprintf("%s/%s", projectID, pageID)
res, err := s.s3.GetObject(s.ctx, &s3.GetObjectInput{
Key: &k,
Bucket: &s.bucket,
})
if err != nil {
var resErr *awshttp.ResponseError
if errors.As(err, &resErr) && resErr.ResponseError.HTTPStatusCode() == http.StatusNotFound {
// TODO: This would probably be better in some background "maintenance" worker
p.Pages = slices.Delete(p.Pages, pageIndex, pageIndex)
_ = s.UpdateProject(projectID, p)
return ProjectPage{}, errors.Join(ErrPageNotExists, resErr)
}
return ProjectPage{}, err
}
s.assert.NotNil(res.Body)
s.assert.NotNil(page.Interactions)
page.Image = res.Body
return page, nil
}
func (s *Service) UpdatePage(projectID string, page ProjectPage) error {
s.assert.NotNil(s.ctx)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.bucket)
s.assert.NotZero(projectID)
s.assert.NotZero(page.ID)
s.assert.NotNil(page.Interactions)
p, err := s.GetProject(projectID)
if err != nil {
return errors.Join(errors.New("unable to get project"), err)
}
pageIndex := slices.IndexFunc(p.Pages, func(p ProjectPage) bool { return p.ID == page.ID })
if pageIndex == -1 {
return ErrPageNotExists
}
p.Pages[pageIndex] = page
// TODO: Probably a "lastUpdated" timestamp in the ProjectPage data would be better
// so we don't update equal images. Changing the image in ProjectPage would be better
// using a method, or could be completely decoupled from the struct.
if page.Image != nil {
k := fmt.Sprintf("%s/%s", projectID, page.ID)
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
Key: &k,
Body: page.Image,
Bucket: &s.bucket,
})
if err != nil {
return errors.Join(errors.New("error while trying to update image"), err)
}
}
err = s.UpdateProject(projectID, p)
if err != nil {
return errors.Join(errors.New("error while trying to update project"), err)
}
return nil
}
func (s *Service) DeletePage(projectID string, id string) error {
s.assert.NotNil(s.ctx)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.bucket)
s.assert.NotZero(projectID)
s.assert.NotNil(id)
p, err := s.GetProject(projectID)
if err != nil {
return errors.Join(errors.New("unable to get project"), err)
}
k := fmt.Sprintf("%s/%s", projectID, id)
_, err = s.s3.DeleteObject(s.ctx, &s3.DeleteObjectInput{
Key: &k,
Bucket: &s.bucket,
})
if err != nil {
return err
}
p.Pages = slices.DeleteFunc(p.Pages, func(p ProjectPage) bool { return p.ID == id })
err = s.UpdateProject(projectID, p)
return err
}

View File

@@ -1,196 +0,0 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"forge.capytal.company/capytalcode/project-comicverse/database"
"forge.capytal.company/capytalcode/project-comicverse/internals/randstr"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
const projectIDLength = 6
var ErrProjectNotExists = errors.New("project does not exists in database")
func (s *Service) CreateProject() (Project, error) {
s.assert.NotNil(s.db)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.ctx)
s.assert.NotZero(s.bucket)
s.log.Debug("Creating new project")
id, err := randstr.NewHex(projectIDLength)
if err != nil {
return Project{}, errors.Join(errors.New("creating hexadecimal ID returned error"), err)
}
title := "New Project"
s.assert.NotZero(id, "ID should never be empty")
s.log.Debug("Creating project on database", slog.String("id", id))
_, err = s.db.CreateProject(id, title)
if err != nil {
return Project{}, err
}
p := Project{
ID: id,
Title: title,
Pages: []ProjectPage{},
}
c, err := json.Marshal(p)
if err != nil {
return Project{}, err
}
s.log.Debug("Creating project on storage", slog.String("id", id))
f := fmt.Sprintf("%s.comic.json", id)
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Key: &f,
Body: bytes.NewReader(c),
})
if err != nil {
return Project{}, err
}
return p, nil
}
func (s *Service) GetProject(id string) (Project, error) {
s.assert.NotNil(s.db)
s.assert.NotNil(s.s3)
s.assert.NotZero(s.bucket)
s.assert.NotNil(s.ctx)
s.assert.NotZero(id)
res, err := s.db.GetProject(id)
if errors.Is(err, database.ErrNoRows) {
return Project{}, errors.Join(ErrProjectNotExists, err)
}
if err != nil {
return Project{}, err
}
f := fmt.Sprintf("%s.comic.json", id)
file, err := s.s3.GetObject(s.ctx, &s3.GetObjectInput{
Bucket: &s.bucket,
Key: &f,
})
if err != nil {
return Project{}, err
}
c, err := io.ReadAll(file.Body)
if err != nil {
return Project{}, err
}
var p Project
err = json.Unmarshal(c, &p)
s.assert.Equal(res.ID, p.ID, "The project ID should always be equal in the Database and Storage")
s.assert.Equal(res.Title, p.Title)
return p, err
}
func (s *Service) ListProjects() ([]Project, error) {
s.assert.NotNil(s.db)
ps, err := s.db.ListProjects()
if err != nil {
return []Project{}, err
}
p := make([]Project, len(ps))
for i, dp := range ps {
// TODO: this is temporally for debugging, getting every project
// from s3 can be expensive
v, err := s.GetProject(dp.ID)
if err != nil {
return []Project{}, err
}
p[i] = v
}
return p, nil
}
func (s *Service) UpdateProject(id string, project Project) error {
s.assert.NotNil(s.db)
s.assert.NotNil(s.s3)
s.assert.NotZero(s.bucket)
s.assert.NotNil(s.ctx)
s.assert.NotZero(id)
c, err := json.Marshal(project)
if err != nil {
return err
}
s.log.Debug("Updating project on storage", slog.String("id", id))
f := fmt.Sprintf("%s.comic.json", id)
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Body: bytes.NewReader(c),
Key: &f,
})
if err != nil {
return err
}
return nil
}
func (s *Service) DeleteProject(id string) error {
s.assert.NotNil(s.db)
s.assert.NotNil(s.s3)
s.assert.NotZero(s.bucket)
s.assert.NotNil(s.ctx)
s.assert.NotZero(id)
p, err := s.GetProject(id)
if err != nil {
return errors.Join(errors.New("unable to get information of project"), err)
}
err = s.db.DeleteProject(id)
if err != nil {
return err
}
s.log.Debug("Deleting project on storage", slog.String("id", id))
files := []types.ObjectIdentifier{}
f := fmt.Sprintf("%s.comic.json", id)
files = append(files, types.ObjectIdentifier{Key: &f})
for k := range p.Pages {
f := fmt.Sprintf("%s/%s", id, k)
files = append(files, types.ObjectIdentifier{Key: &f})
}
_, err = s.s3.DeleteObjects(s.ctx, &s3.DeleteObjectsInput{
Delete: &types.Delete{Objects: files},
Bucket: &s.bucket,
})
if err != nil {
return err
}
return nil
}

124
service/publication.go Normal file
View File

@@ -0,0 +1,124 @@
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 Publication struct {
publicationRepo *repository.Publication
permissionRepo *repository.Permissions
log *slog.Logger
assert tinyssert.Assertions
}
func NewPublication(
publication *repository.Publication,
permissions *repository.Permissions,
logger *slog.Logger,
assertions tinyssert.Assertions,
) *Publication {
return &Publication{
publicationRepo: publication,
permissionRepo: permissions,
log: logger,
assert: assertions,
}
}
func (svc Publication) Get(publicationID uuid.UUID) (model.Publication, error) {
p, err := svc.publicationRepo.GetByID(publicationID)
if err != nil {
return model.Publication{}, fmt.Errorf("service: failed to get publication: %w", err)
}
return p, nil
}
func (svc Publication) Create(title string, ownerUserID ...uuid.UUID) (model.Publication, error) {
log := svc.log.With(slog.String("title", title))
log.Info("Creating publication")
defer log.Info("Finished creating publication")
id, err := uuid.NewV7()
if err != nil {
return model.Publication{}, fmt.Errorf("service: failed to generate id: %w", err)
}
now := time.Now()
p := model.Publication{
ID: id,
Title: title,
DateCreated: now,
DateUpdated: now,
}
err = svc.publicationRepo.Create(p)
if err != nil {
return model.Publication{}, fmt.Errorf("service: failed to create publication: %w", err)
}
if len(ownerUserID) > 0 {
err := svc.SetAuthor(p.ID, ownerUserID[0])
if err != nil {
return model.Publication{}, err
}
}
return p, nil
}
func (svc Publication) SetAuthor(publicationID uuid.UUID, userID uuid.UUID) error {
log := svc.log.With(slog.String("publication", publicationID.String()), slog.String("user", userID.String()))
log.Info("Setting publication owner")
defer log.Info("Finished setting publication owner")
if _, err := svc.permissionRepo.GetByID(publicationID, userID); err == nil {
err := svc.permissionRepo.Update(publicationID, userID, model.PermissionAuthor)
if err != nil {
return fmt.Errorf("service: failed to update publication author: %w", err)
}
}
p := model.PermissionAuthor
err := svc.permissionRepo.Create(publicationID, userID, p)
if err != nil {
return fmt.Errorf("service: failed to set publication owner: %w", err)
}
return nil
}
func (svc Publication) ListOwnedBy(userID uuid.UUID) ([]model.Publication, 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 publication, permissions := range perms {
if permissions.Has(model.PermissionRead) {
ids = append(ids, publication)
}
}
if len(ids) == 0 {
return []model.Publication{}, nil
}
publications, err := svc.publicationRepo.GetByIDs(ids)
if err != nil {
return nil, fmt.Errorf("service: failed to get publications: %w", err)
}
return publications, nil
}

View File

@@ -1,64 +1,5 @@
package service
import (
"context"
"errors"
"log/slog"
import "code.capytal.cc/capytal/comicverse/repository"
"forge.capytal.company/capytalcode/project-comicverse/database"
"forge.capytal.company/loreddev/x/tinyssert"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type Service struct {
db *database.Database
s3 *s3.Client
bucket string
ctx context.Context
assert tinyssert.Assertions
log *slog.Logger
}
func New(cfg Config) (*Service, error) {
if cfg.DB == nil {
return nil, errors.New("database should not be a nil pointer")
}
if cfg.S3 == nil {
return nil, errors.New("s3 client should not be a nil pointer")
}
if cfg.Bucket == "" {
return nil, errors.New("bucket should not be a empty string")
}
if cfg.Context == nil {
return nil, errors.New("context should not be a nil interface")
}
if cfg.Assertions == nil {
return nil, errors.New("assertions should not be a nil interface")
}
if cfg.Logger == nil {
return nil, errors.New("logger should not be a nil pointer")
}
return &Service{
db: cfg.DB,
s3: cfg.S3,
bucket: cfg.Bucket,
ctx: cfg.Context,
assert: cfg.Assertions,
log: cfg.Logger,
}, nil
}
type Config struct {
DB *database.Database
S3 *s3.Client
Bucket string
Context context.Context
Assertions tinyssert.Assertions
Logger *slog.Logger
}
var ErrNotFound = repository.ErrNotFound

188
service/token.go Normal file
View File

@@ -0,0 +1,188 @@
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
}

95
service/user.go Normal file
View File

@@ -0,0 +1,95 @@
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
)

1
smalltrip Submodule

Submodule smalltrip added at e3813daa80

View File

@@ -1,45 +1,64 @@
{{define "dashboard"}}
{{template "layout-page-start" (args "Title" "Dashboard")}}
{{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="/projects/" 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="/projects/{{.ID}}">
<h3>{{.Title}}</h3>
<p>{{.ID}}</p>
</a>
<form action="/projects/{{.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="/projects/" method="post">
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
New project
</button>
</form>
</div>
{{end}}
{{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">Publications</h2>
<form action="/publication/" method="post">
<button
class="rounded-full bg-slate-700 p-1 px-3 text-sm text-slate-100"
>
New publication
</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="/publication/{{.ID}}/">
<h3>{{.Title}}</h3>
<p class="hidden">{{.ID}}</p>
</a>
<form action="/publication/{{.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="/publication/"
method="post"
class="bg-slate-300 rounded-full"
>
<input
type="text"
name="title"
placeholder="Publication title"
value="{{randomName}}"
required
class="pl-5"
/>
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
New publication
</button>
</form>
</div>
{{end}}
</main>
{{template "layout-page-end"}}
{{end}}
{{template "layout-page-end"}} {{end}}

20
templates/landing.html Normal file
View File

@@ -0,0 +1,20 @@
{{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}}

32
templates/login.html Normal file
View File

@@ -0,0 +1,32 @@
{{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,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}}

111
templates/publication.html Normal file
View File

@@ -0,0 +1,111 @@
{{define "publication"}} {{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="/publications/{{$.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="/publications/{{$.ID}}/pages/{{$page.ID}}/"
class="z-1 relative"
/>
</div>
{{else}}
<img
src="/publications/{{$.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="/publications/{{$.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="/publications/{{$.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="/publications/{{.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

@@ -0,0 +1 @@
{{define "publications"}} {{end}}

32
templates/register.html Normal file
View File

@@ -0,0 +1,32 @@
{{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

@@ -9,6 +9,8 @@ import (
"html/template"
"io"
"io/fs"
"code.capytal.cc/capytal/comicverse/internals/randname"
)
var (
@@ -32,6 +34,9 @@ var (
return m, nil
},
"randomName": func() string {
return randname.New()
},
}
)

2
x

Submodule x updated: ceda7536f1...2ce5d71249