110 Commits

Author SHA1 Message Date
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
56 changed files with 4911 additions and 573 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

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

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,6 +67,10 @@ 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")
}
}
@@ -120,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,28 +2,32 @@ 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/internals/joinedfs"
"forge.capytal.company/capytalcode/project-comicverse/repository"
"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(),
@@ -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,18 +141,40 @@ func (app *app) setup() error {
app.assert.NotNil(app.assets)
app.assert.NotNil(app.logger)
userRepo, err := repository.NewUserRepository(app.db, app.ctx, app.logger, app.assert)
userRepository, err := repository.NewUser(app.ctx, app.db, app.logger.WithGroup("repository.user"), app.assert)
if err != nil {
return err
return fmt.Errorf("app: failed to start user repository: %w", err)
}
userService, err := service.NewUserService(userRepo, app.assert)
tokenRepository, err := repository.NewToken(app.ctx, app.db, app.logger.WithGroup("repository.token"), app.assert)
if err != nil {
return 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{
UserService: userService,
UserService: userService,
TokenService: tokenService,
PublicationService: publicationService,
Templates: app.templates,
DisableCache: app.developmentMode,

3
editor/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module code.capytal.cc/capytal/comicverse/editor
go 1.25.2

0
editor/go.sum Normal file
View File

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

@@ -21,6 +21,8 @@
CGO_ENABLED = "1";
hardeningDisable = ["fortify"];
GOPRIVATE = "code.capytal.cc/*";
shellHook = ''
set -a
source .env
@@ -29,7 +31,7 @@
buildInputs = with pkgs; [
# Go tools
go
go_1_25
golangci-lint
gofumpt
gotools
@@ -45,6 +47,12 @@
# S3
awscli
# ePUB
http-server
calibre
zip
unzip
];
};
});

10
go.mod
View File

@@ -1,15 +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.38.0
golang.org/x/crypto v0.43.0
)
require (
@@ -25,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
)

12
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=
@@ -36,11 +38,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92 h1:IYI1S1xt4WdQHjgVYzMa+Owot82BqlZfQV05BLnTcTA=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/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

@@ -49,6 +49,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
}

View File

@@ -2,12 +2,40 @@ package model
import (
"time"
"github.com/google/uuid"
)
type User struct {
Username string `json:"username"` // Must be unique
Password []byte `json:"password"`
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
}

View File

@@ -1,16 +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 (
ErrDatabaseConn = errors.New("failed to begin transaction/connection with database")
ErrExecuteQuery = errors.New("failed to execute query")
ErrCommitQuery = errors.New("failed to commit transaction")
ErrInvalidData = errors.New("data sent to save is invalid")
ErrNotFound = sql.ErrNoRows
// 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,173 +0,0 @@
package repository
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"log/slog"
"time"
"forge.capytal.company/capytalcode/project-comicverse/model"
"forge.capytal.company/loreddev/x/tinyssert"
)
type UserRepository struct {
db *sql.DB
ctx context.Context
log *slog.Logger
assert tinyssert.Assertions
}
func NewUserRepository(
db *sql.DB,
ctx context.Context,
logger *slog.Logger,
assert tinyssert.Assertions,
) (*UserRepository, error) {
assert.NotNil(db)
assert.NotNil(ctx)
assert.NotNil(logger)
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
if err != nil {
return nil, err
}
return &UserRepository{
db: db,
ctx: ctx,
log: logger,
assert: assert,
}, nil
}
func (r *UserRepository) Create(u model.User) (model.User, error) {
r.assert.NotNil(r.db)
r.assert.NotNil(r.log)
r.assert.NotNil(r.ctx)
tx, err := r.db.BeginTx(r.ctx, nil)
if err != nil {
return model.User{}, err
}
q := `
INSERT INTO users (username, password_hash, created_at, updated_at)
VALUES (:username, :password_hash, :created_at, :updated_at)
`
log := r.log.With(slog.String("username", u.Username), slog.String("query", q))
log.DebugContext(r.ctx, "Inserting new user")
t := time.Now()
passwd := base64.URLEncoding.EncodeToString(u.Password)
_, err = tx.ExecContext(r.ctx, q,
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(r.ctx, "Failed to create user", slog.String("error", err.Error()))
return model.User{}, err
}
if err := tx.Commit(); err != nil {
log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return model.User{}, err
}
return u, nil
}
func (r *UserRepository) GetByUsername(username string) (model.User, error) {
r.assert.NotNil(r.db)
r.assert.NotNil(r.log)
r.assert.NotNil(r.ctx)
tx, err := r.db.BeginTx(r.ctx, nil)
if err != nil {
return model.User{}, err
}
q := `
SELECT username, password_hash, created_at, updated_at FROM users
WHERE username = :username
`
log := r.log.With(slog.String("username", username), slog.String("query", q))
log.DebugContext(r.ctx, "Querying user")
row := tx.QueryRowContext(r.ctx, q, sql.Named("username", username))
var password_hash, dateCreated, dateUpdated string
if err = row.Scan(&username, &password_hash, &dateCreated, &dateUpdated); err != nil {
return model.User{}, err
}
if err := tx.Commit(); err != nil {
log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return model.User{}, err
}
passwd, err := base64.URLEncoding.DecodeString(password_hash)
if err != nil {
return model.User{}, err
}
c, err := time.Parse(dateFormat, dateCreated)
if err != nil {
return model.User{}, errors.Join(ErrInvalidData, err)
}
u, err := time.Parse(dateFormat, dateUpdated)
if err != nil {
return model.User{}, errors.Join(ErrInvalidData, err)
}
return model.User{
Username: username,
Password: passwd,
DateCreated: c,
DateUpdated: u,
}, nil
}
func (r *UserRepository) Delete(u model.User) error {
r.assert.NotNil(r.db)
r.assert.NotNil(r.log)
r.assert.NotNil(r.ctx)
tx, err := r.db.BeginTx(r.ctx, nil)
if err != nil {
return err
}
q := `
DELETE FROM users WHERE username = :username
`
log := r.log.With(slog.String("username", u.Username), slog.String("query", q))
log.DebugContext(r.ctx, "Deleting user")
_, err = tx.ExecContext(r.ctx, q, sql.Named("username", u.Username))
if err != nil {
log.ErrorContext(r.ctx, "Failed to delete user", slog.String("error", err.Error()))
return err
}
if err := tx.Commit(); err != nil {
log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return err
}
return nil
}

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

@@ -6,16 +6,19 @@ import (
"log/slog"
"net/http"
"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 {
userService *service.UserService
userService *service.User
tokenService *service.Token
publicationService *service.Publication
templates templates.ITemplate
assets fs.FS
@@ -29,6 +32,12 @@ func New(cfg Config) (http.Handler, error) {
if cfg.UserService == nil {
return nil, errors.New("user service is nil")
}
if cfg.TokenService == nil {
return nil, errors.New("token service is nil")
}
if cfg.PublicationService == nil {
return nil, errors.New("publication service is nil")
}
if cfg.Templates == nil {
return nil, errors.New("templates is nil")
}
@@ -43,7 +52,9 @@ func New(cfg Config) (http.Handler, error) {
}
r := &router{
userService: cfg.UserService,
userService: cfg.UserService,
tokenService: cfg.TokenService,
publicationService: cfg.PublicationService,
templates: cfg.Templates,
assets: cfg.Assets,
@@ -57,7 +68,9 @@ func New(cfg Config) (http.Handler, error) {
}
type Config struct {
UserService *service.UserService
UserService *service.User
TokenService *service.Token
PublicationService *service.Publication
Templates templates.ITemplate
Assets fs.FS
@@ -75,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")),
)
@@ -87,32 +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())
userController := newUserController(router.userService, router.templates, router.assert)
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.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.Handle("GET /assets/{_file...}", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
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 userController.isLogged(r) {
err := router.templates.ExecuteTemplate(w, "dashboard", nil)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
}
if _, ok := NewUserContext(r.Context()).GetUserID(); ok {
publicationController.dashboard(w, r)
return
}
err := router.templates.ExecuteTemplate(w, "landing", nil)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
problem.NewInternalServerError(err).ServeHTTP(w, r)
}
})
r.HandleFunc("/login/{$}", userController.login)
r.HandleFunc("/register/{$}", userController.register)
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
}

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,139 +0,0 @@
package router
import (
"errors"
"net/http"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/capytalcode/project-comicverse/templates"
"forge.capytal.company/loreddev/x/smalltrip/exception"
"forge.capytal.company/loreddev/x/tinyssert"
)
type userController struct {
assert tinyssert.Assertions
templates templates.ITemplate
service *service.UserService
}
func newUserController(
service *service.UserService,
templates templates.ITemplate,
assert tinyssert.Assertions,
) userController {
return userController{
assert: assert,
templates: templates,
service: service,
}
}
func (c userController) login(w http.ResponseWriter, r *http.Request) {
c.assert.NotNil(c.templates)
c.assert.NotNil(c.service)
if r.Method == http.MethodGet {
err := c.templates.ExecuteTemplate(w, "login", nil)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
}
return
}
if r.Method != http.MethodPost {
exception.MethodNotAllowed([]string{http.MethodGet, http.MethodPost}).
ServeHTTP(w, r)
return
}
user, passwd := r.FormValue("username"), r.FormValue("password")
if user == "" {
exception.BadRequest(errors.New(`missing "username" form value`)).ServeHTTP(w, r)
return
}
if passwd == "" {
exception.BadRequest(errors.New(`missing "password" form value`)).ServeHTTP(w, r)
return
}
// TODO: Move token issuing to it's own service, make UserService.Login just return the user
token, _, err := c.service.Login(user, passwd)
if errors.Is(err, service.ErrNotFound) {
exception.NotFound(exception.WithError(errors.New("user not found"))).ServeHTTP(w, r)
return
} else if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
// TODO: harden the cookie policy to the same domain
cookie := &http.Cookie{
Path: "/",
HttpOnly: true,
Name: "token",
Value: token,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (ctrl userController) register(w http.ResponseWriter, r *http.Request) {
ctrl.assert.NotNil(ctrl.templates)
ctrl.assert.NotNil(ctrl.service)
if r.Method == http.MethodGet {
err := ctrl.templates.ExecuteTemplate(w, "register", nil)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
}
return
}
if r.Method != http.MethodPost {
exception.MethodNotAllowed([]string{http.MethodGet, http.MethodPost}).ServeHTTP(w, r)
return
}
user, passwd := r.FormValue("username"), r.FormValue("password")
if user == "" {
exception.BadRequest(errors.New(`missing "username" form value`)).ServeHTTP(w, r)
return
}
if passwd == "" {
exception.BadRequest(errors.New(`missing "password" form value`)).ServeHTTP(w, r)
return
}
_, err := ctrl.service.Register(user, passwd)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
// TODO: Move token issuing to it's own service, make UserService.Login just return the user
token, _, err := ctrl.service.Login(user, passwd)
if err == service.ErrNotFound {
exception.NotFound(exception.WithError(errors.New("user not found"))).ServeHTTP(w, r)
return
} else if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
// TODO: harden the cookie policy to the same domain
cookie := &http.Cookie{
Path: "/",
HttpOnly: true,
Name: "token",
Value: token,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (ctrl userController) isLogged(r *http.Request) bool {
// TODO: Check if token in valid (depends on token service being implemented)
cs := r.CookiesNamed("token")
return len(cs) > 0
}

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 +1,5 @@
package service
import "code.capytal.cc/capytal/comicverse/repository"
var ErrNotFound = repository.ErrNotFound

View File

@@ -1,34 +1,188 @@
package service
import (
"crypto/ed25519"
"errors"
"fmt"
"log/slog"
"time"
"forge.capytal.company/capytalcode/project-comicverse/model"
"forge.capytal.company/loreddev/x/tinyssert"
"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 TokenService struct {
type Token struct {
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
repo *repository.Token
log *slog.Logger
assert tinyssert.Assertions
}
func NewTokenService(assert tinyssert.Assertions) *TokenService {
return &TokenService{assert: assert}
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,
}
}
func (s *TokenService) Issue(user model.User) (*jwt.Token, error) {
id, err := uuid.NewV7()
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 nil, err
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.SigningMethodES256, jwt.RegisteredClaims{
ID: id.String(),
Subject: user.Username,
IssuedAt: jwt.NewNumericDate(now),
t := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.RegisteredClaims{
Issuer: "comicverse", // TODO: Make application ID and Name be a parameter
Subject: user.ID.String(),
Audience: jwt.ClaimStrings{"comicverse"}, // TODO: When we have third-party apps integration, this should be the name/URI/id of the app
ExpiresAt: jwt.NewNumericDate(expires),
NotBefore: jwt.NewNumericDate(now),
IssuedAt: jwt.NewNumericDate(now),
ID: jti.String(),
})
signed, err := t.SignedString(svc.privateKey)
if err != nil {
return "", fmt.Errorf("service: failed to sign token: %w", err)
}
// TODO: Store refresh tokens in repo
err = svc.repo.Create(model.Token{
ID: jti,
UserID: user.ID,
DateCreated: now,
DateExpires: expires,
})
if err != nil {
return "", fmt.Errorf("service: failed to save token: %w", err)
}
return signed, nil
}
func (svc Token) Parse(tokenStr string) (*jwt.Token, error) {
svc.assert.NotNil(svc.publicKey)
svc.assert.NotNil(svc.log)
log := svc.log.With(slog.String("preview_token", tokenStr[0:5]))
log.Info("Parsing token")
defer log.Info("Finished parsing token")
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
return svc.publicKey, nil
}, jwt.WithValidMethods([]string{(&jwt.SigningMethodEd25519{}).Alg()}))
if err != nil {
log.Error("Invalid token", slog.String("error", err.Error()))
return nil, fmt.Errorf("service: invalid token: %w", err)
}
// TODO: Check issuer and if the token was issued at the correct date
// TODO: Structure token claims type
_, ok := token.Claims.(jwt.MapClaims)
if !ok {
log.Error("Invalid claims type", slog.String("claims", fmt.Sprintf("%#v", token.Claims)))
return nil, fmt.Errorf("service: invalid claims type")
}
return token, nil
}
func (svc Token) Revoke(token *jwt.Token) error {
svc.assert.NotNil(svc.log)
svc.assert.NotNil(svc.repo)
svc.assert.NotNil(token)
claims, ok := token.Claims.(jwt.RegisteredClaims)
if !ok {
return errors.New("service: invalid claims type")
}
log := svc.log.With(slog.String("token_id", claims.ID))
log.Info("Revoking token")
defer log.Info("Finished revoking token")
jti, err := uuid.Parse(claims.ID)
if err != nil {
return fmt.Errorf("service: invalid token UUID: %w", err)
}
user, err := uuid.Parse(claims.Subject)
if err != nil {
return fmt.Errorf("service: invalid token subject UUID: %w", err)
}
// TODO: Mark tokens as revoked instead of deleting them
err = svc.repo.Delete(jti, user)
if err != nil {
return fmt.Errorf("service: failed to delete token: %w", err)
}
return nil
}
func (svc Token) IsRevoked(token *jwt.Token) (bool, error) {
svc.assert.NotNil(svc.log)
svc.assert.NotNil(svc.repo)
svc.assert.NotNil(token)
claims, ok := token.Claims.(jwt.RegisteredClaims)
if !ok {
return false, errors.New("service: invalid claims type")
}
log := svc.log.With(slog.String("token_id", claims.ID))
log.Info("Checking if token is revoked")
defer log.Info("Finished checking if token is revoked")
jti, err := uuid.Parse(claims.ID)
if err != nil {
return false, fmt.Errorf("service: invalid token UUID: %w", err)
}
user, err := uuid.Parse(claims.Subject)
if err != nil {
return false, fmt.Errorf("service: invalid token subject UUID: %w", err)
}
_, err = svc.repo.Get(jti, user)
if errors.Is(err, repository.ErrNotFound) {
return true, nil
} else if err != nil {
return false, fmt.Errorf("service: failed to get token: %w", err)
}
return false, nil
}

View File

@@ -2,97 +2,94 @@ package service
import (
"errors"
"fmt"
"log/slog"
"time"
"forge.capytal.company/capytalcode/project-comicverse/model"
"forge.capytal.company/capytalcode/project-comicverse/repository"
"forge.capytal.company/loreddev/x/tinyssert"
"github.com/golang-jwt/jwt/v4"
"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 UserService struct {
type User struct {
repo *repository.User
assert tinyssert.Assertions
repo *repository.UserRepository
log *slog.Logger
}
func NewUserService(repo *repository.UserRepository, assert tinyssert.Assertions) (*UserService, error) {
if err := assert.NotNil(repo); err != nil {
return nil, err
}
func NewUser(repo *repository.User, logger *slog.Logger, assert tinyssert.Assertions) *User {
assert.NotNil(repo)
assert.NotNil(logger)
return &UserService{repo: repo, assert: assert}, nil
return &User{repo: repo, assert: assert, log: logger}
}
func (s *UserService) Register(username, password string) (model.User, error) {
s.assert.NotNil(s.repo)
func (svc *User) Register(username, password string) (model.User, error) {
svc.assert.NotNil(svc.repo)
svc.assert.NotNil(svc.log)
if _, err := s.repo.GetByUsername(username); err == nil {
return model.User{}, ErrAlreadyExists
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{}, err
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: time.Now(),
DateUpdated: time.Now(),
DateCreated: now,
DateUpdated: now,
}
u, err = s.repo.Create(u)
u, err = svc.repo.Create(u)
if err != nil {
return model.User{}, errors.Join(errors.New("failed to create user model"), err)
return model.User{}, fmt.Errorf("service: failed to create user model: %w", err)
}
return u, nil
}
func (s *UserService) Login(username, password string) (signedToken string, user model.User, err error) {
s.assert.NotNil(s.repo)
func (svc *User) Login(username, password string) (user model.User, err error) {
svc.assert.NotNil(svc.repo)
svc.assert.NotNil(svc.log)
user, err = s.repo.GetByUsername(username)
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{}, errors.Join(errors.New("unable to find user"), err)
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{}, errors.Join(errors.New("unable to compare passwords"), err)
return model.User{}, fmt.Errorf("service: unable to compare passwords: %w", err)
}
t := time.Now()
jti, err := uuid.NewV7()
if err != nil {
return "", model.User{}, errors.Join(errors.New("unable to generate token ID"), err)
}
// TODO: Use ECDSA, so users can verify that their token is signed by the project
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
// TODO: Add IDs to users
Issuer: "comicverse",
Subject: username,
IssuedAt: jwt.NewNumericDate(t),
NotBefore: jwt.NewNumericDate(t),
ID: jti.String(),
})
signedToken, err = token.SignedString(jwtKey)
if err != nil {
return "", user, errors.Join(errors.New("unable to sign token"), err)
}
return signedToken, user, nil
return user, nil
}
var jwtKey = []byte("ieurqpieurqpoiweurpewoqueiur") // TODO: move to environment variable
var (
ErrAlreadyExists = errors.New("model already exists")
ErrNotFound = repository.ErrNotFound
ErrPasswordTooLong = bcrypt.ErrPasswordTooLong
ErrIncorrectPassword = bcrypt.ErrMismatchedHashAndPassword
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}}

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}}

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: c62be87c6a...2ce5d71249