Compare commits
101 Commits
feat/user-
...
7d1a21430c
| Author | SHA1 | Date | |
|---|---|---|---|
|
7d1a21430c
|
|||
|
a3c2efd5b0
|
|||
|
77631f2a6c
|
|||
|
185001308d
|
|||
|
bbb9ad0e35
|
|||
|
6a7abdea6f
|
|||
|
b52e7f165f
|
|||
|
d775301567
|
|||
|
ba7ca52ed2
|
|||
|
185ca863fe
|
|||
|
11456db9c4
|
|||
|
d556b0eefe
|
|||
|
007de6b9f1
|
|||
|
60c9d3624a
|
|||
|
d0463ee0c0
|
|||
|
306a9c9adc
|
|||
|
caf43ad920
|
|||
|
20274ffdf2
|
|||
|
f0d6207fd9
|
|||
|
b5949413d9
|
|||
|
eeb4d2b9b3
|
|||
|
fc6afa28d7
|
|||
|
ea04b14751
|
|||
|
ef0c5b0266
|
|||
|
642ac17c7a
|
|||
|
99606f65f3
|
|||
|
09dc059630
|
|||
|
17dee3141b
|
|||
|
58a02dd90c
|
|||
|
aeda9be57c
|
|||
|
bdc99c103a
|
|||
|
3e5095428e
|
|||
|
9ca8b9ff42
|
|||
|
e8b429720b
|
|||
|
8ad87ea2e3
|
|||
|
46540e6482
|
|||
|
3554d3f3ad
|
|||
|
07785992c3
|
|||
|
c14f44e81c
|
|||
|
2e673c8c75
|
|||
|
c02ab731b7
|
|||
|
2df6cd14fb
|
|||
|
826ea4088a
|
|||
|
5d23372bd4
|
|||
|
bbfeb08265
|
|||
|
492bbfd653
|
|||
|
efd7867d61
|
|||
|
c40f3cc9f0
|
|||
|
8a014f617c
|
|||
|
5be4378aff
|
|||
|
a9b74b5d95
|
|||
|
935b0874e3
|
|||
|
3cf79b047c
|
|||
|
66e37831fc
|
|||
|
320cfecc58
|
|||
|
7d80cac994
|
|||
|
e5e9f1dea6
|
|||
|
cd4acd5a98
|
|||
|
fbb4b1da53
|
|||
|
7bc60988c2
|
|||
|
c81d9824cd
|
|||
|
05e1b4b84d
|
|||
|
9a110a814b
|
|||
|
4975a65406
|
|||
|
8e3152159f
|
|||
|
27b2e37704
|
|||
|
aac89dc604
|
|||
|
05eb5f79cc
|
|||
|
08ba62e469
|
|||
|
9e87966e35
|
|||
|
b33b82b272
|
|||
|
6357af3aa2
|
|||
|
9b158f7b01
|
|||
|
f2c0fba4b4
|
|||
|
72e227ac40
|
|||
|
db876a9a17
|
|||
|
97429ab7cf
|
|||
|
d3589d2c63
|
|||
|
2d3afd2ad6
|
|||
|
baf602a811
|
|||
|
1189770e55
|
|||
|
1391e5fe9d
|
|||
|
114c00d3e2
|
|||
|
dc2f769f93
|
|||
|
691472071e
|
|||
|
f73d5918e5
|
|||
|
5b4978b0ac
|
|||
|
3690a4046b
|
|||
|
00441f9844
|
|||
|
dc7e3aaf57
|
|||
|
5b1dac140a
|
|||
|
910b6cef1e
|
|||
|
39689ab702
|
|||
|
395f627e33
|
|||
|
adf32c1666
|
|||
|
9caf46ec9f
|
|||
|
8f62d64ae0
|
|||
|
991db9ea7a
|
|||
|
0c87bcbf3d
|
|||
|
074ea2fdbc
|
|||
|
347a734df9
|
@@ -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=*******************************
|
||||
|
||||
7
.epub/example/META-INF/container.xml
Normal file
7
.epub/example/META-INF/container.xml
Normal 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>
|
||||
|
||||
26
.epub/example/OEBPS/content.opf
Normal file
26
.epub/example/OEBPS/content.opf
Normal 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>
|
||||
0
.epub/example/OEBPS/images/.gitkeep
Normal file
0
.epub/example/OEBPS/images/.gitkeep
Normal file
643
.epub/example/OEBPS/scripts/ipub.js
Normal file
643
.epub/example/OEBPS/scripts/ipub.js
Normal file
@@ -0,0 +1,643 @@
|
||||
"use strict";
|
||||
|
||||
class IPUBElement extends HTMLElement {
|
||||
static observedAttributes = ["id"];
|
||||
|
||||
connectedCallback() {
|
||||
this.#ensureID();
|
||||
}
|
||||
|
||||
attributeChangedCallback(_name, _oldValue, _newValue) {
|
||||
this.#ensureID();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
#ensureID() {
|
||||
if (!this.id) {
|
||||
this.id = hashFromHTML(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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 IPUBBody extends IPUBElement {
|
||||
static elementName = "ipub-body";
|
||||
|
||||
static defineContentElements() {
|
||||
for (const e of [
|
||||
IPUBAudio,
|
||||
IPUBBackground,
|
||||
IPUBImage,
|
||||
IPUBInteraction,
|
||||
IPUBSoundtrack,
|
||||
]) {
|
||||
console.info(`IPUBBody: Defining custom element <${e.elementName}>`);
|
||||
globalThis.customElements.define(e.elementName, e);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
IPUBBody.defineContentElements();
|
||||
}
|
||||
cover.onclose = IPUBBody.defineContentElements;
|
||||
|
||||
this.setAttribute("aria-busy", "false");
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.addEventListener("load", () => {
|
||||
console.info("IPUB: Starting IPUB elements");
|
||||
|
||||
console.log("IPUB: Defining custom element <ipub-body>");
|
||||
globalThis.customElements.define(IPUBBody.elementName, IPUBBody);
|
||||
});
|
||||
|
||||
class IPUBCover extends IPUBElement {
|
||||
static elementName = "ipub-cover";
|
||||
|
||||
/**
|
||||
* @type {() => void} callback
|
||||
*/
|
||||
onclose = () => {};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
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) {
|
||||
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 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: audio,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
static #observer = (() => {
|
||||
return new IntersectionObserver((entries) => {
|
||||
for (const { intersectionRatio, target, time } of entries) {
|
||||
/** @type {IPUBSoundtrack} */
|
||||
const soundtrack = target;
|
||||
|
||||
if (intersectionRatio > 0) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
IPUBSoundtrack.#observer.observe(this);
|
||||
}
|
||||
|
||||
// TODO(guz013): Handle if element is moved, it's group should be updated
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {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);
|
||||
}
|
||||
|
||||
115
.epub/example/OEBPS/sections/section0001.xhtml
Normal file
115
.epub/example/OEBPS/sections/section0001.xhtml
Normal file
@@ -0,0 +1,115 @@
|
||||
<?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%;">
|
||||
<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">
|
||||
<!-- 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;">
|
||||
<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;">
|
||||
<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>
|
||||
298
.epub/example/OEBPS/styles/stylesheet.css
Normal file
298
.epub/example/OEBPS/styles/stylesheet.css
Normal file
@@ -0,0 +1,298 @@
|
||||
.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;
|
||||
|
||||
--z-cover: 9;
|
||||
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-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%;
|
||||
}
|
||||
}
|
||||
18
.epub/example/OEBPS/toc.ncx
Normal file
18
.epub/example/OEBPS/toc.ncx
Normal 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>
|
||||
|
||||
14
.epub/example/OEBPS/toc.xhtml
Normal file
14
.epub/example/OEBPS/toc.xhtml
Normal 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
1
.epub/example/mimetype
Normal file
@@ -0,0 +1 @@
|
||||
application/epub+zip
|
||||
17
.forgejo/workflows/todos.yaml
Normal file
17
.forgejo/workflows/todos.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# name: TODO tracker
|
||||
# on: [push, pull_request]
|
||||
# jobs:
|
||||
# build:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: https://forge.capytal.company/actions/checkout@v3
|
||||
# - uses: https://forge.capytal.company/actions/tdg-forgejo-action@master
|
||||
# with:
|
||||
# TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
# REPO: ${{ github.repository }}
|
||||
# SHA: ${{ github.sha }}
|
||||
# REF: ${{ github.ref }}
|
||||
# LABEL: status/todo
|
||||
# DRY_RUN: true
|
||||
# COMMENT_ON_ISSUES: 128
|
||||
# ASSIGN_FROM_BLAME: 128
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,4 +3,5 @@ out.css
|
||||
.tmp
|
||||
.env
|
||||
*.db
|
||||
*.epub
|
||||
tmp
|
||||
|
||||
5
.gitmodules
vendored
5
.gitmodules
vendored
@@ -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
|
||||
|
||||
55
cmd/cmd.go
55
cmd/cmd.go
@@ -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()))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
projectRepository, err := repository.NewProject(app.ctx, app.db, app.logger.WithGroup("repository.project"), app.assert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: failed to start project repository: %w", err)
|
||||
}
|
||||
|
||||
permissionRepository, err := repository.NewPermissions(app.ctx, app.db, app.logger.WithGroup("repository.permission"), app.assert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: failed to start permission repository: %w", err)
|
||||
}
|
||||
|
||||
userService := service.NewUser(userRepository, app.logger.WithGroup("service.user"), app.assert)
|
||||
tokenService := service.NewToken(service.TokenConfig{
|
||||
PrivateKey: app.privateKey,
|
||||
PublicKey: app.publicKey,
|
||||
Repository: tokenRepository,
|
||||
Logger: app.logger.WithGroup("service.token"),
|
||||
Assertions: app.assert,
|
||||
})
|
||||
projectService := service.NewProject(projectRepository, permissionRepository, app.logger.WithGroup("service.project"), app.assert)
|
||||
|
||||
app.handler, err = router.New(router.Config{
|
||||
UserService: userService,
|
||||
UserService: userService,
|
||||
TokenService: tokenService,
|
||||
ProjectService: projectService,
|
||||
|
||||
Templates: app.templates,
|
||||
DisableCache: app.developmentMode,
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
CGO_ENABLED = "1";
|
||||
hardeningDisable = ["fortify"];
|
||||
|
||||
GOPRIVATE = "code.capytal.cc/*";
|
||||
|
||||
shellHook = ''
|
||||
set -a
|
||||
source .env
|
||||
@@ -45,6 +47,12 @@
|
||||
|
||||
# S3
|
||||
awscli
|
||||
|
||||
# ePUB
|
||||
http-server
|
||||
calibre
|
||||
zip
|
||||
unzip
|
||||
];
|
||||
};
|
||||
});
|
||||
|
||||
7
go.mod
7
go.mod
@@ -1,9 +1,10 @@
|
||||
module forge.capytal.company/capytalcode/project-comicverse
|
||||
module code.capytal.cc/capytal/comicverse
|
||||
|
||||
go 1.24.1
|
||||
go 1.24.8
|
||||
|
||||
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
|
||||
|
||||
6
go.sum
6
go.sum
@@ -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=
|
||||
|
||||
47
go.work.sum
Normal file
47
go.work.sum
Normal file
@@ -0,0 +1,47 @@
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1 h1:1M0gSbyP6q06gl3384wpoKPaH9G16NPqZFieEhLboSU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
|
||||
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
||||
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
|
||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92 h1:IYI1S1xt4WdQHjgVYzMa+Owot82BqlZfQV05BLnTcTA=
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
|
||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
13
makefile
13
makefile
@@ -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
66
model/model.go
Normal 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
145
model/permission.go
Normal 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"
|
||||
PermissionAdminProject Permissions = 0x0100000000000000 // "admin.project"
|
||||
PermissionAdminMembers Permissions = 0x0010000000000000 // "admin.members"
|
||||
PermissionEditAll Permissions = 0x0000001111111111 // "edit.all" ---------
|
||||
PermissionEditPages Permissions = 0x0000000100000000 // "edit.pages"
|
||||
PermissionEditInteractions Permissions = 0x0000000010000000 // "edit.interactions"
|
||||
PermissionEditDialogs Permissions = 0x0000000000001000 // "edit.dialogs"
|
||||
PermissionEditTranslations Permissions = 0x0000000000000100 // "edit.translations"
|
||||
PermissionEditAccessibility Permissions = 0x0000000000000010 // "edit.accessibility"
|
||||
PermissionRead Permissions = 0x0000000000000001 // "read"
|
||||
)
|
||||
|
||||
var PermissionLabels = map[Permissions]string{
|
||||
PermissionAuthor: "author",
|
||||
PermissionAdminDelete: "admin.delete",
|
||||
PermissionAdminProject: "admin.project",
|
||||
PermissionAdminMembers: "admin.members",
|
||||
PermissionEditPages: "edit.pages",
|
||||
PermissionEditInteractions: "edit.interactions",
|
||||
PermissionEditDialogs: "edit.dialogs",
|
||||
PermissionEditTranslations: "edit.translations",
|
||||
PermissionEditAccessibility: "edit.accessibility",
|
||||
PermissionRead: "read",
|
||||
}
|
||||
38
model/project.go
Normal file
38
model/project.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Project struct {
|
||||
ID uuid.UUID // Must be unique, represented as base64 string in URLs
|
||||
Title string // Must not be empty
|
||||
DateCreated time.Time
|
||||
DateUpdated time.Time
|
||||
}
|
||||
|
||||
var _ Model = (*Project)(nil)
|
||||
|
||||
func (p Project) Validate() error {
|
||||
errs := []error{}
|
||||
if len(p.ID) == 0 {
|
||||
errs = append(errs, ErrZeroValue{Name: "UUID"})
|
||||
}
|
||||
if p.Title == "" {
|
||||
errs = append(errs, ErrZeroValue{Name: "Title"})
|
||||
}
|
||||
if p.DateCreated.IsZero() {
|
||||
errs = append(errs, ErrZeroValue{Name: "DateCreated"})
|
||||
}
|
||||
if p.DateUpdated.IsZero() {
|
||||
errs = append(errs, ErrZeroValue{Name: "DateUpdated"})
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return ErrInvalidModel{Name: "Project", Errors: errs}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
34
model/token.go
Normal file
34
model/token.go
Normal 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
|
||||
}
|
||||
@@ -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
283
repository/permission.go
Normal 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 [Project]
|
||||
func NewPermissions(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
log *slog.Logger,
|
||||
assert tinyssert.Assertions,
|
||||
) (*Permissions, error) {
|
||||
b := newBaseRepostiory(ctx, db, log, assert)
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS project_permissions (
|
||||
project_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
permissions_value INTEGER NOT NULL DEFAULT '0',
|
||||
_permissions_text TEXT NOT NULL DEFAULT '', -- For display purposes only, may not always be up-to-date
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY(project_id, user_id)
|
||||
FOREIGN KEY(project_id)
|
||||
REFERENCES projects (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE RESTRICT,
|
||||
FOREIGN KEY(user_id)
|
||||
REFERENCES users (id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE RESTRICT
|
||||
)
|
||||
`)
|
||||
|
||||
_, err = tx.ExecContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.Join(errors.New("unable to create project tables"), err)
|
||||
}
|
||||
|
||||
return &Permissions{baseRepostiory: b}, nil
|
||||
}
|
||||
|
||||
func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permissions) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO project_permissions (project_id, user_id, permissions_value, _permissions_text, created_at, updated_at)
|
||||
VALUES (:project_id, :user_id, :permissions_value, :permissions_text, :created_at, :updated_at)
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
|
||||
log := repo.log.With(slog.String("project_id", project.String()),
|
||||
slog.String("user_id", user.String()),
|
||||
slog.String("permissions", fmt.Sprintf("%d", permissions)),
|
||||
slog.String("permissions_text", permissions.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Inserting new project permissions")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("project_id", project),
|
||||
sql.Named("user_id", user),
|
||||
sql.Named("permissions_value", permissions),
|
||||
sql.Named("permissions_text", permissions.String()),
|
||||
sql.Named("created_at", now.Format(dateFormat)),
|
||||
sql.Named("updated_at", now.Format(dateFormat)),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to insert project permissions", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo Permissions) GetByID(project uuid.UUID, user uuid.UUID) (model.Permissions, error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
q := `
|
||||
SELECT permissions_value FROM project_permissions
|
||||
WHERE project_id = :project_id
|
||||
AND user_id = :user_id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("projcet_id", project.String()),
|
||||
slog.String("user_id", user.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Getting by ID")
|
||||
|
||||
row := repo.db.QueryRowContext(repo.ctx, q,
|
||||
sql.Named("project_id", user),
|
||||
sql.Named("user_id", user))
|
||||
|
||||
var p model.Permissions
|
||||
if err := row.Scan(&p); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to get permissions by ID", slog.String("error", err.Error()))
|
||||
return model.Permissions(0), errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetByUserID returns a project_id-to-permissions map containing all projects and permissions that said userID
|
||||
// has relation to.
|
||||
func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]model.Permissions, err error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
// Begin tx so we don't read rows as they are being updated
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT project_id, permissions_value FROM project_permissions
|
||||
WHERE user_id = :user_id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("user_id", user.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Getting by user ID")
|
||||
|
||||
rows, err := tx.QueryContext(repo.ctx, q, sql.Named("user_id", user))
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to get permissions by user ID", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = rows.Close()
|
||||
if err != nil {
|
||||
err = errors.Join(ErrCloseConn, err)
|
||||
}
|
||||
}()
|
||||
|
||||
ps := map[uuid.UUID]model.Permissions{}
|
||||
|
||||
for rows.Next() {
|
||||
var project uuid.UUID
|
||||
var permissions model.Permissions
|
||||
|
||||
err := rows.Scan(&project, &permissions)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan permissions of user id", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
ps[project] = permissions
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permissions) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
UPDATE project_permissions
|
||||
SET permissions_value = :permissions_value
|
||||
_permissions_text = :permissions_text
|
||||
updated_at = :updated_at
|
||||
WHERE project_uuid = :project_uuid
|
||||
AND user_uuid = :user_uuid
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("project_id", project.String()),
|
||||
slog.String("user_id", user.String()),
|
||||
slog.String("permissions", fmt.Sprintf("%d", permissions)),
|
||||
slog.String("permissions_text", permissions.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Updating project permissions")
|
||||
|
||||
now := time.Now()
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("permissions_value", permissions),
|
||||
sql.Named("permissions_text", permissions.String()),
|
||||
sql.Named("updated_at", now.Format(dateFormat)),
|
||||
sql.Named("project_id", project),
|
||||
sql.Named("user_id", user),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to update project permissions", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo Permissions) Delete(project, user uuid.UUID) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := `
|
||||
DELETE FROM project_permissions
|
||||
WHERE project_id = :project_id
|
||||
AND user_id = :user_id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("project_id", project.String()),
|
||||
slog.String("user_id", user.String()),
|
||||
slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Deleting project permissions")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("project_id", project),
|
||||
sql.Named("user_id", user),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to delete project permissions", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
282
repository/project.go
Normal file
282
repository/project.go
Normal 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 Project struct {
|
||||
baseRepostiory
|
||||
}
|
||||
|
||||
func NewProject(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Project, error) {
|
||||
b := newBaseRepostiory(ctx, db, log, assert)
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.Join(errors.New("unable to create project tables"), err)
|
||||
}
|
||||
|
||||
return &Project{baseRepostiory: b}, nil
|
||||
}
|
||||
|
||||
func (repo Project) Create(p model.Project) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
if err := p.Validate(); err != nil {
|
||||
return errors.Join(ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO projects (id, title, created_at, updated_at)
|
||||
VALUES (:id, :title, :created_at, :updated_at)
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Inserting new project")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("id", p.ID),
|
||||
sql.Named("title", p.Title),
|
||||
sql.Named("created_at", p.DateCreated.Format(dateFormat)),
|
||||
sql.Named("updated_at", p.DateUpdated.Format(dateFormat)),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to insert project", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo Project) GetByID(projectID uuid.UUID) (project model.Project, err error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
q := `
|
||||
SELECT id, title, created_at, updated_at FROM projects
|
||||
WHERE id = :id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("query", q), slog.String("id", projectID.String()))
|
||||
log.DebugContext(repo.ctx, "Getting project by ID")
|
||||
|
||||
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("id", projectID))
|
||||
|
||||
var id uuid.UUID
|
||||
var title string
|
||||
var dateCreatedStr, dateUpdatedStr string
|
||||
|
||||
err = row.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
|
||||
return model.Project{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
|
||||
return model.Project{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
|
||||
return model.Project{}, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
return model.Project{
|
||||
ID: id,
|
||||
Title: title,
|
||||
DateCreated: dateCreated,
|
||||
DateUpdated: dateUpdated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (repo Project) GetByIDs(ids []uuid.UUID) (projects []model.Project, err error) {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.log)
|
||||
|
||||
// Begin tx so we don't read rows as they are being updated
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
c := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
c[i] = fmt.Sprintf("id = '%s'", id.String())
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
SELECT id, title, created_at, updated_at FROM projects
|
||||
WHERE %s
|
||||
`, strings.Join(c, " OR "))
|
||||
|
||||
log := repo.log.With(slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Getting projects by IDs")
|
||||
|
||||
rows, err := tx.QueryContext(repo.ctx, q)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to get projects by IDs", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = rows.Close()
|
||||
if err != nil {
|
||||
err = errors.Join(ErrCloseConn, err)
|
||||
}
|
||||
}()
|
||||
|
||||
ps := []model.Project{}
|
||||
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var title string
|
||||
var dateCreatedStr, dateUpdatedStr string
|
||||
|
||||
err := rows.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrInvalidOutput, err)
|
||||
}
|
||||
|
||||
ps = append(ps, model.Project{
|
||||
ID: id,
|
||||
Title: title,
|
||||
DateCreated: dateCreated,
|
||||
DateUpdated: dateUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return nil, errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func (repo Project) Update(p model.Project) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
if err := p.Validate(); err != nil {
|
||||
return errors.Join(ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Join(ErrDatabaseConn, err)
|
||||
}
|
||||
|
||||
q := `
|
||||
UPDATE projects
|
||||
SET title = :title
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Updating project")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q,
|
||||
sql.Named("title", p.Title),
|
||||
sql.Named("updated_at", p.DateUpdated.Format(dateFormat)),
|
||||
sql.Named("id", p.ID),
|
||||
)
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to insert project", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo Project) DeleteByID(id uuid.UUID) error {
|
||||
repo.assert.NotNil(repo.db)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
repo.assert.NotNil(repo.ctx)
|
||||
|
||||
tx, err := repo.db.BeginTx(repo.ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := `
|
||||
DELETE FROM projects WHERE id = :id
|
||||
`
|
||||
|
||||
log := repo.log.With(slog.String("id", id.String()), slog.String("query", q))
|
||||
log.DebugContext(repo.ctx, "Deleting project")
|
||||
|
||||
_, err = tx.ExecContext(repo.ctx, q, sql.Named("id", id))
|
||||
if err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to delete project", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrExecuteQuery, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
|
||||
return errors.Join(ErrCommitQuery, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,16 +1,51 @@
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
245
repository/token.go
Normal file
245
repository/token.go
Normal 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
211
repository/user.go
Normal 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
|
||||
}
|
||||
@@ -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/projects.go
Normal file
132
router/projects.go
Normal 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 projectController struct {
|
||||
projectSvc *service.Project
|
||||
|
||||
templates templates.ITemplate
|
||||
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func newProjectController(
|
||||
projectService *service.Project,
|
||||
templates templates.ITemplate,
|
||||
assertions tinyssert.Assertions,
|
||||
) *projectController {
|
||||
return &projectController{
|
||||
projectSvc: projectService,
|
||||
templates: templates,
|
||||
assert: assertions,
|
||||
}
|
||||
}
|
||||
|
||||
func (ctrl projectController) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx := NewUserContext(r.Context())
|
||||
|
||||
userID, ok := userCtx.GetUserID()
|
||||
if !ok {
|
||||
userCtx.Unathorize(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := ctrl.projectSvc.GetUserProjects(userID)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ps := make([]struct {
|
||||
ID string
|
||||
Title string
|
||||
}, len(projects))
|
||||
|
||||
for i, project := range projects {
|
||||
ps[i] = struct {
|
||||
ID string
|
||||
Title string
|
||||
}{
|
||||
ID: base64.URLEncoding.EncodeToString([]byte(project.ID.String())),
|
||||
Title: project.Title,
|
||||
}
|
||||
}
|
||||
|
||||
err = ctrl.templates.ExecuteTemplate(w, "dashboard", ps)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (ctrl projectController) getProject(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Handle private projects
|
||||
|
||||
shortProjectID := r.PathValue("projectID")
|
||||
|
||||
id, err := base64.URLEncoding.DecodeString(shortProjectID)
|
||||
if err != nil {
|
||||
problem.NewBadRequest(fmt.Sprintf("Incorrectly encoded project ID: %s", err.Error())).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
projectID, err := uuid.ParseBytes(id)
|
||||
if err != nil {
|
||||
problem.NewBadRequest("Project ID is not a valid UUID").ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
project, err := ctrl.projectSvc.GetProject(projectID)
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
problem.NewNotFound().ServeHTTP(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Return project template
|
||||
b, err := json.Marshal(project)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if _, err := w.Write(b); err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (ctrl projectController) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
userCtx := NewUserContext(r.Context())
|
||||
|
||||
userID, ok := userCtx.GetUserID()
|
||||
if !ok {
|
||||
userCtx.Unathorize(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
title := r.FormValue("title")
|
||||
if title == "" {
|
||||
problem.NewBadRequest(`Missing "title" parameter`).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
project, err := ctrl.projectSvc.Create(title, userID)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/p/%s/", base64.URLEncoding.EncodeToString([]byte(project.ID.String())))
|
||||
http.Redirect(w, r, path, http.StatusSeeOther)
|
||||
}
|
||||
@@ -5,17 +5,21 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"forge.capytal.company/capytalcode/project-comicverse/service"
|
||||
"forge.capytal.company/capytalcode/project-comicverse/templates"
|
||||
"forge.capytal.company/loreddev/x/smalltrip"
|
||||
"forge.capytal.company/loreddev/x/smalltrip/exception"
|
||||
"forge.capytal.company/loreddev/x/smalltrip/middleware"
|
||||
"forge.capytal.company/loreddev/x/tinyssert"
|
||||
"code.capytal.cc/capytal/comicverse/service"
|
||||
"code.capytal.cc/capytal/comicverse/templates"
|
||||
"code.capytal.cc/loreddev/smalltrip"
|
||||
"code.capytal.cc/loreddev/smalltrip/middleware"
|
||||
"code.capytal.cc/loreddev/smalltrip/multiplexer"
|
||||
"code.capytal.cc/loreddev/smalltrip/problem"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
)
|
||||
|
||||
type router struct {
|
||||
userService *service.UserService
|
||||
userService *service.User
|
||||
tokenService *service.Token
|
||||
projectService *service.Project
|
||||
|
||||
templates templates.ITemplate
|
||||
assets fs.FS
|
||||
@@ -29,6 +33,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.ProjectService == nil {
|
||||
return nil, errors.New("project service is nil")
|
||||
}
|
||||
if cfg.Templates == nil {
|
||||
return nil, errors.New("templates is nil")
|
||||
}
|
||||
@@ -43,7 +53,9 @@ func New(cfg Config) (http.Handler, error) {
|
||||
}
|
||||
|
||||
r := &router{
|
||||
userService: cfg.UserService,
|
||||
userService: cfg.UserService,
|
||||
tokenService: cfg.TokenService,
|
||||
projectService: cfg.ProjectService,
|
||||
|
||||
templates: cfg.Templates,
|
||||
assets: cfg.Assets,
|
||||
@@ -57,7 +69,9 @@ func New(cfg Config) (http.Handler, error) {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
UserService *service.UserService
|
||||
UserService *service.User
|
||||
TokenService *service.Token
|
||||
ProjectService *service.Project
|
||||
|
||||
Templates templates.ITemplate
|
||||
Assets fs.FS
|
||||
@@ -87,32 +101,56 @@ 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,
|
||||
})
|
||||
projectController := newProjectController(router.projectService, router.templates, router.assert)
|
||||
|
||||
r.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
|
||||
|
||||
r.Use(userController.userMiddleware)
|
||||
|
||||
r.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add a way to the user to bypass this check and see the landing page.
|
||||
// Probably a query parameter to bypass like "?landing=true"
|
||||
if 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 {
|
||||
projectController.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)
|
||||
|
||||
// TODO: Provide/redirect short project-id paths to long paths with the project title as URL /projects/title-of-the-project-<start of uuid>
|
||||
r.HandleFunc("GET /p/{projectID}/{$}", projectController.getProject)
|
||||
r.HandleFunc("POST /p/{$}", projectController.createProject)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// getMethod is a helper function to get the HTTP method of request, tacking precedence
|
||||
// the "x-method" argument sent by requests via form or query values.
|
||||
func getMethod(r *http.Request) string {
|
||||
m := r.FormValue("x-method")
|
||||
if m != "" {
|
||||
return strings.ToUpper(m)
|
||||
}
|
||||
|
||||
return strings.ToUpper(r.Method)
|
||||
}
|
||||
|
||||
293
router/user.go
Normal file
293
router/user.go
Normal 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
|
||||
}
|
||||
139
router/users.go
139
router/users.go
@@ -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/project.go
Normal file
124
service/project.go
Normal 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 Project struct {
|
||||
projectRepo *repository.Project
|
||||
permissionRepo *repository.Permissions
|
||||
|
||||
log *slog.Logger
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func NewProject(
|
||||
project *repository.Project,
|
||||
permissions *repository.Permissions,
|
||||
logger *slog.Logger,
|
||||
assertions tinyssert.Assertions,
|
||||
) *Project {
|
||||
return &Project{
|
||||
projectRepo: project,
|
||||
permissionRepo: permissions,
|
||||
|
||||
log: logger,
|
||||
assert: assertions,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc Project) Create(title string, ownerUserID ...uuid.UUID) (model.Project, error) {
|
||||
log := svc.log.With(slog.String("title", title))
|
||||
log.Info("Creating project")
|
||||
defer log.Info("Finished creating project")
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return model.Project{}, fmt.Errorf("service: failed to generate id: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
p := model.Project{
|
||||
ID: id,
|
||||
Title: title,
|
||||
DateCreated: now,
|
||||
DateUpdated: now,
|
||||
}
|
||||
|
||||
err = svc.projectRepo.Create(p)
|
||||
if err != nil {
|
||||
return model.Project{}, fmt.Errorf("service: failed to create project: %w", err)
|
||||
}
|
||||
|
||||
if len(ownerUserID) > 0 {
|
||||
err := svc.SetAuthor(p.ID, ownerUserID[0])
|
||||
if err != nil {
|
||||
return model.Project{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (svc Project) SetAuthor(projectID uuid.UUID, userID uuid.UUID) error {
|
||||
log := svc.log.With(slog.String("project", projectID.String()), slog.String("user", userID.String()))
|
||||
log.Info("Setting project owner")
|
||||
defer log.Info("Finished setting project owner")
|
||||
|
||||
if _, err := svc.permissionRepo.GetByID(projectID, userID); err == nil {
|
||||
err := svc.permissionRepo.Update(projectID, userID, model.PermissionAuthor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service: failed to update project author: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
p := model.PermissionAuthor
|
||||
|
||||
err := svc.permissionRepo.Create(projectID, userID, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service: failed to set project owner: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc Project) GetUserProjects(userID uuid.UUID) ([]model.Project, error) {
|
||||
perms, err := svc.permissionRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service: failed to get user permissions: %w", err)
|
||||
}
|
||||
|
||||
ids := []uuid.UUID{}
|
||||
for project, permissions := range perms {
|
||||
if permissions.Has(model.PermissionRead) {
|
||||
ids = append(ids, project)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []model.Project{}, nil
|
||||
}
|
||||
|
||||
projects, err := svc.projectRepo.GetByIDs(ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service: failed to get projects: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (svc Project) GetProject(projectID uuid.UUID) (model.Project, error) {
|
||||
p, err := svc.projectRepo.GetByID(projectID)
|
||||
if err != nil {
|
||||
return model.Project{}, fmt.Errorf("service: failed to get project: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
package service
|
||||
|
||||
import "code.capytal.cc/capytal/comicverse/repository"
|
||||
|
||||
var ErrNotFound = repository.ErrNotFound
|
||||
|
||||
178
service/token.go
178
service/token.go
@@ -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
|
||||
}
|
||||
|
||||
101
service/user.go
101
service/user.go
@@ -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
1
smalltrip
Submodule
Submodule smalltrip added at 3d201d2122
@@ -1,45 +1,59 @@
|
||||
{{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">Projects</h2>
|
||||
<form action="/p/" method="post">
|
||||
<button
|
||||
class="rounded-full bg-slate-700 p-1 px-3 text-sm text-slate-100"
|
||||
>
|
||||
New project
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
class="grid h-full grid-flow-col grid-rows-1 justify-start gap-5 overflow-scroll"
|
||||
>
|
||||
{{range .}}
|
||||
<div class="w-38 grid h-full grid-rows-2 bg-slate-500">
|
||||
<div class="bg-blue-500 p-2">Image</div>
|
||||
<div class="p-2">
|
||||
<a href="/p/{{.ID}}/">
|
||||
<h3>{{.Title}}</h3>
|
||||
<p class="hidden">{{.ID}}</p>
|
||||
</a>
|
||||
<form action="/p/{{.ID}}/" method="post">
|
||||
<input type="hidden" name="x-method" value="delete" />
|
||||
<button
|
||||
class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{else}}
|
||||
<div
|
||||
class="fixed flex h-screen w-full items-center justify-center top-0 left-0"
|
||||
>
|
||||
<form action="/p/" method="post" class="bg-slate-300 rounded-full">
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
placeholder="Project title"
|
||||
required
|
||||
class="pl-5"
|
||||
/>
|
||||
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
|
||||
New project
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "layout-page-end"}}
|
||||
{{end}}
|
||||
{{template "layout-page-end"}} {{end}}
|
||||
|
||||
2
x
2
x
Submodule x updated: c62be87c6a...6ea200aa64
Reference in New Issue
Block a user