329 Commits

Author SHA1 Message Date
e1734a5310 feat(ipub): ipub-track element to position elements outside of content elements 2025-11-04 18:41:49 -03:00
f76be67247 feat(ipub): debug attribute and overlay 2025-11-04 18:40:00 -03:00
c90bff53a3 chore(ipub): move IPUBBody and IPUBCover definitions to top 2025-11-04 18:38:07 -03:00
7d1a21430c feat(ipub): soundtrack support with fade transition 2025-11-04 10:54:36 -03:00
a3c2efd5b0 feat(ipub): make ipub-background containerized into ipub-body 2025-11-04 10:54:36 -03:00
77631f2a6c feat(ipub): ipub-cover, forcing user to interact to enable autoplay 2025-11-03 15:24:21 -03:00
185001308d feat(ipub): use ipub-body to handle custom elements defining 2025-11-03 15:24:20 -03:00
bbb9ad0e35 feat(ipub): ipub-body element
this replaces the ipub-content element, and makes ipub publications
limited/containerized into one element.
2025-11-03 15:24:20 -03:00
6a7abdea6f feat(ipub): make #ensureID private and remove old code 2025-10-30 09:42:34 -03:00
b52e7f165f feat(ipub): change all section images to ipub-images 2025-10-30 09:42:34 -03:00
d775301567 feat(ipub): make sticky and fade backgrounds the default 2025-10-17 19:23:41 -03:00
ba7ca52ed2 feat(ipub): ipub-interaction element 2025-10-17 18:05:12 -03:00
185ca863fe feat(ipub): base elements on IPUBElement
This makes the random hex ID logic shared between all elements
2025-10-17 18:05:11 -03:00
11456db9c4 feat(ipub): ipub-image element 2025-10-17 18:05:11 -03:00
d556b0eefe feat(ipub): ipub-content element 2025-10-17 18:05:11 -03:00
007de6b9f1 feat(ipub): sticky background implementation via web components 2025-10-17 18:01:53 -03:00
60c9d3624a chore: update module definition 2025-10-13 15:26:31 -03:00
d0463ee0c0 chore: use new repository for loreddev/smalltrip 2025-10-13 14:58:48 -03:00
306a9c9adc chore: update loreddev/x submodule origin 2025-10-13 14:58:30 -03:00
caf43ad920 chore: ignore *.epub files 2025-08-12 17:19:58 -03:00
20274ffdf2 chore(ipub,example): remove .epub file from repository 2025-08-12 17:19:23 -03:00
f0d6207fd9 fix(ipub,example): add audio and script to content spine 2025-08-12 17:18:33 -03:00
b5949413d9 fix(ipub,example): make layout of epub pre-paginated 2025-08-12 17:18:17 -03:00
eeb4d2b9b3 feat(ipub): on-screen interaction trigger 2025-08-12 17:17:30 -03:00
fc6afa28d7 chore(ipub): formatting using html tidy 2025-08-12 17:17:15 -03:00
ea04b14751 feat(ipub): point interaction aspect ratio 2025-08-12 17:16:26 -03:00
ef0c5b0266 chore(ipub): example ipub file initial commit 2025-07-31 19:18:28 -03:00
642ac17c7a feat(router,deps): update x package and use smalltrip/problem instead of smalltrip/exception 2025-07-30 19:15:19 -03:00
99606f65f3 feat(router): project controller and routes 2025-06-26 19:11:17 -03:00
09dc059630 style(templates): format dashboard template 2025-06-26 19:11:17 -03:00
17dee3141b feat(router,app): pass project service to router 2025-06-26 19:11:16 -03:00
58a02dd90c feat(service,project): project service to manage project and project's permissions
Probably in the future, permissions will be separated into their own
service.
2025-06-26 19:11:16 -03:00
aeda9be57c fix(app): token repository logging group 2025-06-26 19:11:14 -03:00
bdc99c103a refactor(service): use Errorf instead of errors.Join 2025-06-26 19:11:14 -03:00
3e5095428e feat(repo,project): get by ID and IDs methods 2025-06-26 19:11:13 -03:00
9ca8b9ff42 fix(repo,project): projects table initiation 2025-06-26 19:11:12 -03:00
e8b429720b feat(model,repo,permission): permissions repository and model 2025-06-26 19:11:11 -03:00
8ad87ea2e3 refactor(user,router): return uuid in UserContext.GetUserID 2025-06-16 07:09:15 -03:00
46540e6482 feat(user,router): UserContext struct
this is intended to better structure ways to get information about the
user and the session context values
2025-06-16 07:08:55 -03:00
3554d3f3ad feat(user,router): userMiddleware to provide context of what user is logged in 2025-06-16 07:08:54 -03:00
07785992c3 feat(user,router): use token service to issue tokens 2025-06-16 07:08:54 -03:00
c14f44e81c refactor(user,router): move arguments to struct cfg 2025-06-16 07:08:52 -03:00
2e673c8c75 feat(router): add token service to router 2025-06-16 07:08:50 -03:00
c02ab731b7 feat(app): provide public and private keys to comicverse app 2025-06-16 07:08:50 -03:00
2df6cd14fb feat(cmd): parse public and private ed256 keys env variables 2025-06-16 07:08:50 -03:00
826ea4088a refactor(service,token): provide arguments via cfg struct 2025-06-16 07:08:47 -03:00
5d23372bd4 fix(service,token): unable to cast claims type (jwt always return MapClaims) 2025-06-16 07:08:47 -03:00
bbfeb08265 feat(service,token): add logs to token parsing method 2025-06-16 07:08:46 -03:00
492bbfd653 fix(service,token): incorrect algorithm being used to parse 2025-06-16 07:08:46 -03:00
efd7867d61 fix(service,token): missing userID pass to repository model 2025-06-13 19:16:35 -03:00
c40f3cc9f0 fix(service,user): update UsernameExists error 2025-06-13 19:16:34 -03:00
8a014f617c fix(service,user): missing logger value pass to struct 2025-06-13 19:16:34 -03:00
5be4378aff fix(repo,token): create table query using old uuid column 2025-06-13 19:16:33 -03:00
a9b74b5d95 fix(repo,token): properly close rows in case of error 2025-06-13 19:16:32 -03:00
935b0874e3 chore(ci): disable todo tracker 2025-06-13 19:13:22 -03:00
3cf79b047c fix(ci): use ubuntu-latest instead of alpine 2025-06-13 19:11:23 -03:00
66e37831fc fix(ci): use local instance actions 2025-06-13 19:11:21 -03:00
320cfecc58 fix(ci): tdg-forgejo-action uri 2025-06-13 19:11:17 -03:00
7d80cac994 feat(ci): add TODOs tracker 2025-06-13 19:11:15 -03:00
e5e9f1dea6 test 2025-06-12 19:17:51 -03:00
cd4acd5a98 feat(service,token): token.IsRevoke method 2025-06-10 19:06:32 -03:00
fbb4b1da53 feat(service,token): token.Revoke method 2025-06-10 19:06:25 -03:00
7bc60988c2 feat(service,token): token.Parse method 2025-06-10 19:06:15 -03:00
c81d9824cd feat(service,token): properly implement token.issue method 2025-06-10 19:06:01 -03:00
05e1b4b84d refactor(service,user): move user-service specific errors to user.go 2025-06-10 19:05:31 -03:00
9a110a814b feat(service,user): add logging to methods 2025-06-10 19:05:00 -03:00
4975a65406 feat(model,token): token model for repository 2025-06-10 19:04:13 -03:00
8e3152159f feat(repository,token): delete token method 2025-06-10 19:01:56 -03:00
27b2e37704 feat(repository,token): get token by user id method 2025-06-10 19:01:46 -03:00
aac89dc604 feat(repository,token): get token method 2025-06-10 19:01:18 -03:00
05eb5f79cc feat(repository,token): create token method 2025-06-10 19:01:01 -03:00
08ba62e469 feat(repository,token): new Token repository 2025-06-10 18:59:52 -03:00
9e87966e35 feat(service,user): generate ID for users on creation 2025-06-10 18:31:25 -03:00
b33b82b272 feat(service,user): update repository type 2025-06-10 18:31:07 -03:00
6357af3aa2 feat(service,user): add better context for errors 2025-06-10 18:30:39 -03:00
9b158f7b01 refactor(service,user): rename method receiver from s to svc 2025-06-10 18:30:13 -03:00
f2c0fba4b4 refactor(service,user): remove jwt token generation 2025-06-10 18:29:15 -03:00
72e227ac40 fix(repo,project): update queries 2025-06-10 15:08:32 -03:00
db876a9a17 feat(model,project): rename method receiver from m to p 2025-06-10 15:05:42 -03:00
97429ab7cf feat(repo,model,project): rename UUID field and row to just ID 2025-06-10 15:04:42 -03:00
d3589d2c63 feat(repo,model,project): rename UUID field and row to just ID 2025-06-10 15:04:13 -03:00
2d3afd2ad6 feat(repo,project): change Delete method to DeleteByID for clarity 2025-06-10 15:02:18 -03:00
baf602a811 feat(repo,user): rename ProjectRepository to Project (since the package is already named repository) 2025-06-10 15:01:25 -03:00
1189770e55 feat(repo,user): change Delete method to DeleteByID for clarity 2025-06-10 15:00:21 -03:00
1391e5fe9d feat(repo,user): wrap validate check error with ErrInvalidInput 2025-06-10 14:59:40 -03:00
114c00d3e2 feat(repo,user): rename UserRepository to User (since the package is already named repository) 2025-06-10 14:56:54 -03:00
dc2f769f93 feat(repo,user): query by id method 2025-06-10 14:56:03 -03:00
691472071e feat(repo): prefix errors to add context 2025-06-10 14:54:30 -03:00
f73d5918e5 refactor(repo,user): move scan logic to unexported method 2025-06-10 14:54:09 -03:00
5b4978b0ac feat(model,user): user fields validation 2025-06-10 14:53:33 -03:00
3690a4046b feat(repo,user): add IDs to users 2025-06-10 14:46:09 -03:00
00441f9844 feat(repo,user): dont use transactions on select queries 2025-06-10 14:40:07 -03:00
dc7e3aaf57 feat(repo,user): return more structured and contextualized errors 2025-06-10 14:39:47 -03:00
5b1dac140a refactor(repo,user): rename method receiver from r to repo 2025-06-10 14:38:39 -03:00
910b6cef1e refactor(repo,user): use baseRepository 2025-06-10 14:37:31 -03:00
39689ab702 refactor: rename files to their singular form 2025-06-10 10:34:14 -03:00
395f627e33 refactor(router): rename c receiver to ctrl 2025-06-09 19:27:46 -03:00
adf32c1666 feat(model): validate function and Model interface 2025-06-09 19:25:53 -03:00
9caf46ec9f feat(repo): delete projects 2025-06-09 19:25:20 -03:00
8f62d64ae0 feat(repo): update projects 2025-06-09 19:25:10 -03:00
991db9ea7a feat(repo): create projects 2025-06-09 19:24:59 -03:00
0c87bcbf3d feat(repo): projects repository 2025-06-09 19:24:45 -03:00
074ea2fdbc feat(repo): base repository to share a common constructor and logic 2025-06-09 19:24:06 -03:00
347a734df9 feat(model): project model 2025-06-09 19:23:21 -03:00
41a764939b feat: landing page template 2025-06-09 19:21:29 -03:00
dc61ed91d0 feat: update assertions contructor function call 2025-06-09 19:21:19 -03:00
3f767299e2 chore: new debugger make job 2025-06-09 19:20:46 -03:00
d5f13b563e chore: ignore air's tmp directory 2025-06-09 19:20:31 -03:00
7308097c61 chore(service): delete old all-service service 2025-06-09 19:20:06 -03:00
e3ce651288 refactor(router): rename c receiver to ctrl 2025-06-09 19:19:27 -03:00
0e7198f918 feat(router): set session token cookie 2025-06-09 19:19:00 -03:00
f4a971bdae feat(router): show landing page if user is not logged in 2025-06-09 19:18:29 -03:00
8403459cc8 fix(repo): send error value on user insert query exec 2025-06-09 19:17:26 -03:00
4e90fa0063 refactor(repo): rename repositoryDateFormat to dateFormat 2025-06-09 19:16:40 -03:00
f622f774e4 refactor: move shared variables to repository.go file 2025-06-09 19:14:28 -03:00
29f1e8cc8a chore: use fortify for hardeningDisable 2025-06-06 16:36:12 -03:00
0cea250fa4 chore: set environment variables on direnv enter 2025-06-06 16:35:53 -03:00
c3a0be5ec5 feat(templates): register page and form 2025-06-06 16:35:23 -03:00
72b884c2b3 feat(templates): login page and form 2025-06-06 16:35:16 -03:00
d38097a616 feat(router,users): register method for creating a new user 2025-06-06 16:34:59 -03:00
f7396dc12b feat(router,users): return token cookie on login 2025-06-06 16:34:41 -03:00
149823a5fc fix(router,users): correct username form value name on error 2025-06-06 16:34:16 -03:00
56e2214311 feat(router): handle /login and /register routes 2025-06-06 16:33:29 -03:00
a52caf6580 feat(router): provide UserService to router 2025-06-06 16:32:49 -03:00
30eb1a0065 feat(user,service): return signed token of user 2025-06-06 16:32:02 -03:00
106c612e63 feat(user,service): return error on incorect construct parameter 2025-06-06 16:31:31 -03:00
06807b0623 chore(router,service): remove editor and projects endpoint and services
They will be reimplemented later
2025-06-06 16:30:50 -03:00
12844eafee chore: format launch dev debug profile 2025-06-06 16:29:41 -03:00
d5668af2df fix(repo,users): incorrect syntax for columns in select 2025-06-06 16:29:25 -03:00
4bb32f9757 feat(repo,users): add assertions check for struct values 2025-06-06 16:29:11 -03:00
52ac9ed3bc fix(repo,users): missing context value on struct initiation 2025-06-06 16:28:45 -03:00
2bce92e51c fix(repo,users): trailing comma in create table query 2025-06-06 16:28:11 -03:00
28ed7379de feat: user controller 2025-05-30 18:05:40 -03:00
16322b3afd revert: remove database abstraction 2025-05-30 18:05:24 -03:00
5fbe9cd1ad chore: update submodule 2025-05-30 18:05:01 -03:00
f7f2a7fbb8 chore: update deps 2025-05-30 18:04:46 -03:00
b29bfdd1df feat(templates): login page 2025-05-30 18:04:39 -03:00
deaf9089b2 feat(users): init token service 2025-05-30 18:04:28 -03:00
ffad82b32c feat(users): user service 2025-05-30 18:04:16 -03:00
dbf30a9908 feat(users): user repository 2025-05-30 18:03:56 -03:00
acda6dbd24 feat(ast,ipub): remove marshalling and unmarshalling logic from ast 2025-05-26 09:28:20 -03:00
a4fc9176cd feat(attr,ipub): small mock test to test unmarshalling and marshalling 2025-05-22 11:10:38 -03:00
9ecacc3808 feat(attr,ipub): ElementChildren to provide a universal unmarshalling of child elements 2025-05-22 11:09:58 -03:00
7f6f9f7682 feat(attr,ipub): Element interface and ElementKind to prepare unmarshalling of un-structured childre 2025-05-22 11:09:15 -03:00
884133941f feat(attr,ipub): attr package to add structured typing for XML Attributes 2025-05-22 11:05:06 -03:00
c05445f702 feat(element,ipub): new element package to take care of XML Marshalling and Unmarshalling 2025-05-22 11:03:43 -03:00
1466c35e39 chore(ipub): small mock test for unmarshalling 2025-05-20 10:12:48 -03:00
1ade2d8f63 chore(ipub): small mock test for marshalling 2025-05-20 10:12:37 -03:00
eb72bab886 feat(ipub,ast): image element 2025-05-20 10:12:02 -03:00
294513a772 feat(ipub,ast)!: BaseElement marshaller 2025-05-20 10:11:27 -03:00
87e7a74dd3 feat(ipub,ast): ElementKind xml.MarshallerAttr and xml.UnmarshallerAttr implementations 2025-05-20 10:08:10 -03:00
f7704b4f18 feat(ipub,ast): Name() method to determina XML element/tag name 2025-05-20 10:05:06 -03:00
b1f6bde29f feat(ipub,ast): xml.Unmarshaller implementation for Elements 2025-05-16 15:17:56 -03:00
fbe01ad098 feat(ipub,ast): Content Element definition 2025-05-16 15:14:02 -03:00
50b387ccf2 feat(ipub,ast): Body Element definition 2025-05-16 15:13:38 -03:00
f1912240a0 feat(ast): ElementKind list to keep track of all possible ast elements
This is will be useful for being able to marshal and unmarshal the ast,
since we can't easily know what implementation of the Element interface
is supposed to be used.
2025-05-16 15:12:02 -03:00
b9cb8948fc feat(ast): default (partial) implementation of Element
BaseElement to be used by other Elements as a default implementation of
common ast functions
2025-05-16 15:10:22 -03:00
5dc04d29d9 feat(ast): create Element interface 2025-05-16 15:08:06 -03:00
70b6491565 fix(templates): extra div closing tag 2025-03-28 17:19:55 -03:00
b329d8cfba feat(templates): delete interactions via the editor 2025-03-28 17:12:39 -03:00
2862824b7b feat(router): endpoint to delete interactions 2025-03-28 16:44:11 -03:00
0bfc828caf feat(router,templates): add interactions to page via editor 2025-03-28 16:43:45 -03:00
3524eb2944 feat(service): UpdatePage method 2025-03-28 16:41:47 -03:00
cdcc410089 feat(service): add interaction to page struct 2025-03-28 16:41:31 -03:00
757ed62edd refactor(router): rename imgID to pageID 2025-03-28 16:40:51 -03:00
5c873a2707 refactor(service,router): return ProjectPage struct instead of just image reader 2025-03-28 16:39:32 -03:00
8af80c702f refactor(service): make project pages be a slice instead of map 2025-03-28 16:37:23 -03:00
7e78726bcb feat: delete pages of projects 2025-03-25 14:58:46 -03:00
bd5132354f fix(templates): z index of header being lower than content 2025-03-25 14:53:52 -03:00
788fdfd9e3 feat(templates): support for templates images in project page 2025-03-25 14:53:21 -03:00
e4d53084a6 fix: delete button in project card is not clickable 2025-03-25 14:52:52 -03:00
01eb5d90e0 fix: delete pages and images on project deletion 2025-03-25 14:52:28 -03:00
268e0a9d8b feat: page manipulation in projects 2025-03-25 14:33:42 -03:00
f13313da30 refactor(router): add trailing slash to all redirects 2025-03-25 14:31:35 -03:00
07460aaaca refactor(router): add trailing slash to all endpoints 2025-03-25 14:31:21 -03:00
845d4b40c3 feat(router): delete project route and method 2025-03-19 11:28:46 -03:00
b93ff0512f feat(service): delete project method 2025-03-19 11:28:05 -03:00
7c1246adb4 feat(db): delete project method 2025-03-19 11:27:48 -03:00
329b2ca953 feat(templates): list projects on dashboard 2025-03-17 16:21:23 -03:00
8273ff6a1d feat(templates): hot reloading templates
this is supposed to be mostly temporaly until a better templates
interface is made
2025-03-17 15:43:51 -03:00
82f2c7e67a fix(templates,layouts): update main stylesheet location 2025-03-17 11:20:34 -03:00
69a291d19e feat(comicverse): support for local assets files 2025-03-17 11:20:00 -03:00
fa66837cdd refactor(router): rename static files to assets 2025-03-17 11:19:34 -03:00
47c3de3c8f refactor(comicverse): rename static files to assets 2025-03-17 11:19:20 -03:00
e40896c53f chore: update build script for assets 2025-03-17 10:55:35 -03:00
bfe7a01aa5 chore: ignore output tailwind file 2025-03-17 10:50:06 -03:00
7c28a53965 chore: update dev/assets script 2025-03-17 10:49:51 -03:00
dac00296b7 chore: remove unused static directory 2025-03-17 10:48:53 -03:00
9579d83661 refator(assets): rename static package to assets 2025-03-17 10:47:37 -03:00
4ae94cfe7d chore(go): update to golang 1.24.1 2025-03-17 10:05:09 -03:00
de99160688 chore(deps): update to tailwind 4 2025-03-17 10:01:36 -03:00
c6d99690ed feat(router,service): list projects endpoint 2025-03-17 09:13:55 -03:00
99a76dcad3 refactor(router): remove debugging log 2025-03-12 14:50:40 -03:00
a2ca597578 refactor(router): move projects endpoint to dedicated file 2025-03-12 14:48:43 -03:00
9e5a15963e fix(router): remove unused error ErrProjectInvaldiUUID exception 2025-03-12 14:43:55 -03:00
2d9b3e29d6 fix(service): return error on failed ID generation 2025-03-12 10:51:35 -03:00
f45aff6d6f chore: add litecli to tools in devshell 2025-03-12 10:42:29 -03:00
e121bbde87 fix: debugger profiles not pointing to main package 2025-03-12 10:25:09 -03:00
8708a29a21 chore: update submodule 2025-03-12 10:24:04 -03:00
94e6396a6c chore: ignore database file 2025-03-12 10:23:54 -03:00
ab61af503e feat(router): endpoints for getting and creating projects 2025-03-12 10:22:55 -03:00
8fbb9e1671 feat(service): projects creation and getters implementation 2025-03-12 10:21:44 -03:00
ae10dfa7ca fix(router): conflict between /projects/ and /projects/{id} routes 2025-03-12 10:19:39 -03:00
ea8ca4284b feat(cmd): panic on assertions errors in developer mode 2025-03-12 10:16:58 -03:00
71cd17bb97 feat(database): make all database operation be methods instead of methods+structs
This is inspired by the output code generated by sqlc
2025-03-12 10:15:38 -03:00
9fbcbb96c0 fix(database): missing error join in database constructor 2025-03-12 10:14:38 -03:00
4aeeb8479b refactor(router): reorganize code in router constructor 2025-03-12 10:13:09 -03:00
6eb4825d1c feat(service): pass bucket name for service
This is probably temporaly, it would be better in the future to have a
abstraction on top of the S3 bucket, similar to the database
abstraction.
2025-03-12 10:12:33 -03:00
c8285833d4 feat: add groups to loggers 2025-03-12 10:06:06 -03:00
4ee46e2dc8 refactor(service): use service as struct instead of interface 2025-03-12 10:04:43 -03:00
3d346ca5fe fix(cmd): incorrect local database url 2025-03-12 10:01:59 -03:00
fca5ad29b9 feat(service,database): new Database abstraction to initiate and manipulate database 2025-03-11 14:19:21 -03:00
1c608b30be feat(service): pass context to service 2025-03-11 09:57:10 -03:00
789512f6e1 refactor(comicverse): rename context field to ctx 2025-03-11 09:56:26 -03:00
feaa21b827 chore: remove accidently commited empty .env 2025-03-11 09:47:39 -03:00
32329e1e17 feat: pass s3 client to service 2025-03-11 09:46:03 -03:00
2187848712 feat: pass sql database client to service 2025-03-11 09:45:41 -03:00
eb53285f03 feat(service): new service abstraction to directly interact with DBs and operations
This should make the router be just about HTML rendering, paramaters
validation and routing.
2025-03-11 09:40:48 -03:00
98c389cb0c fix: missing templates import 2025-03-07 20:52:02 -03:00
800412d315 feat: injection of templates into router 2025-03-07 20:51:43 -03:00
6e2664756b feat: static files injection into router 2025-03-07 20:48:11 -03:00
bc658d7dc8 refactor: new router module to comicverse app 2025-03-07 20:46:17 -03:00
5f88be7244 feat: comicverse app struc 2025-03-07 20:40:31 -03:00
4be737b292 fix: move main() function to cmd/cmd.go 2025-03-07 20:35:28 -03:00
b0c6d70406 fix: tailwind cli not properly watch files 2025-03-07 20:35:05 -03:00
fc757d36f0 feat: use cmd as main package 2025-03-07 20:34:31 -03:00
0ae642f17b feat: exit execution on error 2025-03-07 20:34:08 -03:00
145da05708 feat: rename host flag to hostname 2025-03-07 20:33:39 -03:00
8cb5ca3917 feat: remove proxy from live server 2025-03-07 20:32:57 -03:00
ee93e78b28 chore: remove unused index.html 2025-03-06 16:57:28 -03:00
f9e9d95c80 feat: dashboard endpoint 2025-03-06 16:56:31 -03:00
742287e522 feat: dashboard template 2025-03-06 16:56:21 -03:00
6ddba55413 chore: remove /panic endpoint 2025-03-06 16:56:01 -03:00
7de9126ea1 chore: remove /test endpoint 2025-03-06 16:55:41 -03:00
dc33adb733 feat: args func to pass multiple arguments to templates 2025-03-06 16:55:16 -03:00
6146819503 chore: remove test layout 2025-03-06 16:54:44 -03:00
ac4c681b7c feat: page layout 2025-03-06 16:54:36 -03:00
c25e8b0f1d feat: base layout 2025-03-06 16:54:14 -03:00
242559acc9 feat: add tailwindcss 2025-03-06 10:02:35 -03:00
c2bddbf32c chore: ignore .tmp folder 2025-03-06 10:02:13 -03:00
c5043a3527 style: format long line in New constructor 2025-03-06 09:45:01 -03:00
1351cd0cde refactor: import renamed excetion package 2025-03-05 13:52:00 -03:00
51bb55816c chore: update submodule 2025-03-05 13:47:05 -03:00
f13dc0fe0f feat: templates prototype 2025-03-05 13:45:19 -03:00
7b6796e2c1 feat: panic recovering on requests 2025-03-05 12:22:38 -03:00
4875f47f94 chore: rename routes log group to requests 2025-03-05 12:22:21 -03:00
fafd7f76bf chore: hotreloading on file change 2025-03-05 11:00:28 -03:00
489a696e82 feat: exceptions hello world 2025-03-05 10:43:42 -03:00
d2308b5a1b feat: hello world 2025-03-05 10:05:21 -03:00
1edc9c6ff0 chore: update submodule url 2025-03-04 17:42:05 -03:00
4063a6fb0d chore: fresh restart 2025-03-01 19:35:33 -03:00
e16e57f387 chore(deps): add loreddev/x submodule 2025-02-20 09:32:18 -03:00
f1e9468f09 refactor(router): rename parsePath to parsePattern 2024-12-18 14:13:26 -03:00
0af4ea3074 fix(router): trailing slash in mux rest pattern 2024-12-18 14:09:17 -03:00
67230ba75d fix(router): missing join for the rest of the path 2024-12-13 16:59:40 -03:00
c2bbd80dce feat(middlewares): use closures instead of structs for middleware constructors 2024-12-13 16:21:47 -03:00
23ac6d6220 fix(router): missing support for mux server in router constructor 2024-12-13 16:13:26 -03:00
5b5de7b206 feat(router): add support for http methods 2024-12-13 16:12:49 -03:00
b14b0be66b chore(eslint,dev): fix eslint errors in htmx files 2024-10-30 10:35:25 -03:00
a7b5ab174b fix(dev,eslint): remove @eslint/markdown since 'no-irregular-whitespace' breaks with it 2024-10-30 10:34:49 -03:00
478dd0216e feat(cookies): MarshalToWriter helper/alias function 2024-10-24 20:28:31 -03:00
c27b0a4e12 feat(cookies): UnmarshalIfRequest helper/alias function 2024-10-24 20:28:02 -03:00
11312ef5fe refactor(cookies): move errors to bottom of file 2024-10-24 20:12:16 -03:00
d74d71dfa3 feat(cookies,errors): error helper for cookie unmarshaling 2024-10-24 19:53:38 -03:00
6ceef9664a feat(forms): Unmarshaler interface 2024-10-24 19:40:25 -03:00
19cf6e13cc feat(forms,errors): error helper for form and query parsing 2024-10-24 19:39:50 -03:00
dd1d67207d feat(forms): forms and queries parsing and unmarshal 2024-10-24 19:39:11 -03:00
6372b20a0b feat(router,errors): BadRequest error 2024-10-24 19:36:35 -03:00
f27784a0b2 refactor(router,errors): move functiosn to alphabetical order 2024-10-24 19:35:43 -03:00
c05086a93e chore(jsdoc): fix jsdoc in htmx.js and htmx.d.ts files 2024-10-24 17:24:46 -03:00
3ae232a779 feat(cookies): add Marshaler and Unmarshaler interface for cookies 2024-10-24 11:06:01 -03:00
535b7aa975 chore(lint,format): make lint and fmt depend on build/templ 2024-10-23 19:08:03 -03:00
218b991caa refactor(lib): move app routing to lib package
These packages and functions under lib will one day be part of it's
dedicated library/framework, so separating them here helps for the
future.
2024-10-23 19:06:03 -03:00
0e96046259 style(imports,handlers): format imports in handlers 2024-10-23 18:58:13 -03:00
013ed1002a chore(dev,makefile): improve clean command 2024-10-23 18:55:28 -03:00
0ed5f27f92 feat(lib,htmx): install htmx 2024-10-23 18:36:02 -03:00
e769a59b57 refactor(javascript,assets): rename javascript directory to lib 2024-10-23 18:35:38 -03:00
7d49a0fd81 feat(errors,router): add error description in 500 errors 2024-10-22 20:34:35 -03:00
012d0b3137 feat(errors,router): add Endpoint field/data to all errors 2024-10-22 20:34:12 -03:00
1e00971f62 feat(cookies,router): new cookies marshaller and unmarshaller 2024-10-22 20:25:26 -03:00
853c86af0a fix(unocss): rename pages to handlers 2024-10-22 16:58:12 -03:00
96dc9ce119 feat(perf,layouts,css): place global css in style tag for instant loading 2024-10-22 16:57:52 -03:00
3ace8799e2 fix(javascript,assets): js to javascript src link 2024-10-22 16:57:17 -03:00
ea22eedd0e refactor(assets,embedded): rewrite embedded assets files handling 2024-10-22 16:56:50 -03:00
409cb86070 refactor(env,config): create DEV global variable 2024-10-22 16:56:10 -03:00
1034cc4906 refactor(javascript,assets): rename js directory to javascript 2024-10-22 16:55:11 -03:00
390774600f fix(logger,middlewares): status code not being read from response 2024-10-22 11:55:00 -03:00
5448517b67 refactor(pages): move pages to handlers package 2024-10-22 10:09:23 -03:00
50e0ee7bcb fix(dashboard): fix color and fonts 2024-10-22 09:04:20 -03:00
6a561f7d6f feat(dev): development page for testing 2024-10-22 09:03:58 -03:00
fbbbb39fc8 feat(fonts): add Karla and Playfair font families 2024-10-22 09:03:44 -03:00
d65abd3e6f feat(cache,middlewares): cache middlewares 2024-10-22 09:02:58 -03:00
27f29990f5 refactor(router): move Router type to interface 2024-10-21 14:57:53 -03:00
c12db37dc2 refactor(errors,middlwares): new error middleware implementation 2024-10-21 14:27:43 -03:00
a72b2d0482 refactor(router): new router implementation with route groups support 2024-10-21 14:26:46 -03:00
f80dd84784 feat(logger,middlewares): add move information for each request 2024-10-21 14:24:28 -03:00
2219de640d refactor(errors,middlewares): move html displaying to dedicated function 2024-10-18 09:12:45 -03:00
d3bb613252 chore: ignore _templ.txt files 2024-10-18 00:24:10 -03:00
fb97c490a8 fix(errors,middlewares): return plain text on 404 error 2024-10-18 00:23:42 -03:00
d74a13bfd6 fix(errors,middlewares): Accept header prefersHtml boolean operator 2024-10-18 00:23:09 -03:00
c55a516a3d feat(errors,middlwares): add error handler middleware 2024-10-17 23:47:35 -03:00
f3f060ddc8 feat(middlewares): middleware struct interface 2024-10-17 23:46:52 -03:00
65e34b4e29 feat(logger,middlewares): add group do logger in logger middleware 2024-10-17 23:46:08 -03:00
5157deda2a chore(dev): move final binary from dist to .dist directory 2024-10-15 15:33:16 -03:00
35dbf51fb0 feat(dev): development pages for testing and prototypes 2024-10-15 01:04:35 -03:00
8a44e0821d feat(ui,theme): dynamic theme css 2024-10-15 01:04:18 -03:00
bfd24e0dc7 feat(layouts): base layout for all pages 2024-10-15 01:03:39 -03:00
a183a5a069 feat(dev): development middleware 2024-10-15 01:02:31 -03:00
8a4a7f06ad feat(dev): logging middleware 2024-10-15 01:01:47 -03:00
0222d191e9 fix: check errors in error ServeHTTP 2024-10-15 00:59:43 -03:00
4ffe2f4e9e refactor: move Middleware interface to sinle function class 2024-10-15 00:57:06 -03:00
8388e2763e fix(dev): ignore json files from linting 2024-10-13 20:29:44 -03:00
36ab51b337 chore(dev): eslint command to make lint 2024-10-13 20:18:56 -03:00
cfac969a12 feat: hello world js test 2024-10-13 20:18:25 -03:00
09e1a0ce1e chore(dev): eslint configuration
I love Javascript :)
2024-10-13 20:18:04 -03:00
d9eb24b9cd feat(dev): debugger configuration 2024-10-11 22:30:13 -03:00
78718c29bf feat(app): static file serving 2024-10-11 22:19:07 -03:00
e70e18aee5 chore(deps): use eslint_d instead of eslint 2024-10-11 14:13:44 -03:00
518c712038 fix(dev): hot reloading 2024-10-11 14:13:34 -03:00
fe427c0394 fix: commands in makefile 2024-10-07 19:16:55 -03:00
a01742828e fix: incorrect fields in Route 2024-10-07 19:16:44 -03:00
34c6b7ccb4 feat: test page 2024-10-07 19:12:27 -03:00
f1d312bfce chore: configure makefile for development 2024-10-07 19:12:06 -03:00
32d393c1da feat(deps): add unocss 2024-10-07 19:11:45 -03:00
4f423794a3 chore(deps): add templ 2024-10-07 15:40:38 -03:00
47c570855f feat(router): error handling of routes 2024-10-07 15:40:26 -03:00
1c7131c216 feat(router): support for middlewares 2024-10-07 15:40:06 -03:00
bb0c4b371a feat: setup project router 2024-10-07 15:39:46 -03:00
9cd1e1b1e9 chore: add golang tools to flake.niix 2024-10-04 19:41:07 -03:00
67 changed files with 5496 additions and 11 deletions

9
.EXAMPLE.env Normal file
View File

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

View File

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

View File

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

View File

View File

@@ -0,0 +1,754 @@
"use strict";
class IPUBElement extends HTMLElement {
/**
* @protected
* @type {Readonly<string[]>}
*/
static observedAttributes = ["id", "debug"];
connectedCallback() {
this.#ensureID();
}
attributeChangedCallback(name, _oldValue, _newValue) {
switch (name) {
case "id":
this.#ensureID();
break;
case "debug":
if (this.hasAttribute("debug")) {
this.setDebug(true);
} else {
this.setDebug(false);
}
break;
}
}
/**
* @private
*/
#ensureID() {
if (!this.id) {
this.id = hashFromHTML(this);
}
}
/**
* @returns {boolean}
*/
getDebug() {
return this.hasAttribute("debug");
}
/**
* @param {boolean} state
*/
setDebug(state) {
if (state) {
if (!this.hasAttribute("debug")) {
this.setAttribute("debug", "true");
}
if (!this.style.getPropertyValue(IPUBElement.#PROPERTY_DEBUG_COLOR)) {
this.style.setProperty(
IPUBElement.#PROPERTY_DEBUG_COLOR,
`#${hashFromHTML(this)}`,
);
}
} else {
if (!state && this.hasAttribute("debug")) {
this.removeAttribute("debug");
}
if (
!state &&
this.style.getPropertyValue(IPUBElement.#PROPERTY_DEBUG_COLOR)
) {
this.style.removeProperty(IPUBElement.#PROPERTY_DEBUG_COLOR);
}
}
getAllDescendants(this)
.filter((el) => el.tagName.startsWith("ipub-"))
.forEach((el) => {
el.setDebug?.(state);
});
}
/**
* @private
* @type {Readonly<string>}
*/
static #PROPERTY_DEBUG_COLOR = "--ipub-debug-color";
}
globalThis.addEventListener("load", () => {
console.info("IPUB: Starting IPUB elements");
console.log("IPUB: Defining custom element <ipub-body>");
globalThis.customElements.define(IPUBBody.elementName, IPUBBody);
});
class IPUBBody extends IPUBElement {
static elementName = `ipub-body`;
connectedCallback() {
super.connectedCallback();
this.setAttribute("aria-busy", "true");
// TODO?: Move IPUBCover's "can-play" logic to here
console.log("IPUBBody: Defining custom element <ipub-cover>");
globalThis.customElements.define(IPUBCover.elementName, IPUBCover);
/** @type {IPUBCover} */
const cover = this.querySelector("ipub-cover");
if (!cover) {
// TODO: automatically create IPUBCover element if it doesn't exists
console.error("IPUBBody: Document doesn't has <ipub-cover> element");
this.#initElements();
this.setAttribute("aria-busy", "false");
return;
}
cover.onclose = () => {
this.#initElements();
};
cover.cover();
this.setAttribute("aria-busy", "false");
}
/**
* @private
*/
#initElements() {
for (const e of [
IPUBAudio,
IPUBBackground,
IPUBImage,
IPUBInteraction,
IPUBSoundtrack,
IPUBTrack,
IPUBTrackItem,
IPUBTrackItemPosition,
]) {
console.info(`IPUBBody: Defining custom element <${e.elementName}>`);
globalThis.customElements.define(e.elementName, e);
}
if (this.getDebug()) {
// HACK: Re-trigger IPUBElement debugging logic
console.debug("IPUBBody: triggeing debugger");
this.setAttribute("debug", "true");
}
}
}
class IPUBCover extends IPUBElement {
static elementName = `ipub-cover`;
/**
* @type {() => void} callback
*/
onclose = () => {};
connectedCallback() {
super.connectedCallback();
}
cover() {
console.debug("IPUBCover: Setting up cover");
this.setAttribute("aria-busy", "true");
const dialog = this.querySelector("dialog");
// HACK: Test if we can autoplay interactions, soundtracks, etc
/** @type {HTMLMediaElement | null} */
const media =
this.parentElement.querySelector("audio") ??
this.parentElement.querySelector("video");
if (!media) {
console.log("IPUBCover: no media element found, removing cover");
dialog.close();
this.onclose();
return;
}
const pastVolume = media.volume;
media.volume = 0.1; // don't let the user hear the test audio
media
.play()
.then(() => {
media.pause();
media.volume = pastVolume;
media.currentTime = 0;
console.debug("IPUBCover: Can autoplay interactions, removing cover");
dialog.close();
this.onclose();
})
.catch(() => {
console.debug(
"IPUBCover: Cannot autoplay interactions, covering content",
);
dialog.show();
dialog.parentElement.addEventListener("click", () => {
dialog.close();
this.onclose();
});
this.setAttribute("aria-busy", "false");
return;
});
}
}
class IPUBTrack extends IPUBElement {
static elementName = "ipub-track";
}
class IPUBTrackItem extends IPUBElement {
static elementName = `ipub-track-item`;
}
class IPUBTrackItemPosition extends IPUBElement {
static elementName = `ipub-track-item-position`;
}
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 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 {Element} el
* @returns {Element[]}
*/
function getAllDescendants(el) {
return Array.from(el.children).reduce(
(acc, current) => {
acc.push(current);
return acc.concat(getAllDescendants(current));
},
/** @type {Element[]} */ [],
);
}
/**
* @param {Readonly<Element>} el
* @param {number} [length=6]
* @returns {string}
*
* @copyright This function contains code from a hash algorithm by
* {@link https://stackoverflow.com/a/16348977|Joe Freeman} and
* {@link https://stackoverflow.com/a/3426956|Cristian Sanchez} at
* StackOverflow, licensed under {@link https://creativecommons.org/licenses/by-sa/3.0/|CC BY-SA 3.0}.
*/
function hashFromHTML(el, length = 6) {
const hexLength = length / 2;
if (hexLength % 2 !== 0) {
hexLength + 1;
}
let hash = 0;
for (const char of el.innerHTML) {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
}
let hex = "";
for (let i = 0; i < hexLength; i++) {
hex += ((hash >> (i * 8)) & 0xff).toString(16).padStart(2, "0");
}
return hex.substring(0, length);
}
/**
* @param {Element} element
* @returns {number}
*/
function getPercentageInView(element) {
const viewTop = globalThis.pageYOffset;
const viewBottom = viewTop + globalThis.innerHeight;
const rect = element.getBoundingClientRect();
const elementTop = rect.y + viewTop;
const elementBottom = rect.y + rect.height + viewTop;
if (viewTop > elementBottom || viewBottom < elementTop) {
return 0;
}
if (
(viewTop < elementTop && viewBottom > elementBottom) ||
(elementTop < viewTop && elementBottom > viewBottom)
) {
return 100;
}
let inView = rect.height;
if (elementTop < viewTop) {
inView = rect.height - (globalThis.pageYOffset - elementTop);
}
if (elementBottom > viewBottom) {
inView = inView - (elementBottom - viewBottom);
}
return Math.round((inView / globalThis.innerHeight) * 100);
}

View File

@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="x-ipub-version" content="0.1" />
<meta name="viewport"
content="initial-scale=1,width=device-width,height=device-height,viewport-fit=contain" />
<link href="../styles/stylesheet.css" rel="stylesheet" type="text/css" />
<!-- <script type="module" src="../scripts/ipub.js" fetchpriority="high"></script> -->
<script defer="true" src="../scripts/ipub.js" fetchpriority="high">
<!---->
</script>
</head>
<body xmlns:epub="http://www.idpf.org/2007/ops" class="body">
<ipub-body style="--ipub-padding: 10%;" debug="">
<ipub-cover>
<dialog>
<header>
<h1>Test comic</h1>
<form method="dialog">
<p>Click anywhere to
<button>start Reading</button></p>
</form>
</header>
</dialog>
</ipub-cover>
<main id="content">
<ipub-track>
<ipub-track-item>
<ipub-track-item-position style="--ipub-track-item-position: 0%;" />
<ipub-soundtrack style="--ipub-color: cyan">
<figure>
<label>
<input type="checkbox" />
<figcaption>Soundtrack 1</figcaption>
</label>
<ipub-audio>
<audio controls="true" volume="0" controlslist="nofullscreen"
disableremoteplayback="true">
<source src="../audios/track1.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
</ipub-track-item>
<ipub-track-item>
<ipub-track-item-position style="--ipub-track-item-position: 50%;" />
<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="true">
<source src="../audios/track2.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
</ipub-track-item>
</ipub-track>
<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> -->--&gt;
<!-- <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>

View File

@@ -0,0 +1,345 @@
.body {
-epub-writing-mode: horizontal-tb;
-webkit-writing-mode: horizontal-tb;
direction: ltr;
/* direction: rtl; */
writing-mode: horizontal-tb;
position: relative;
margin: 0;
max-width: 100vw;
max-height: 100vh;
overflow: clip;
display: flex;
:root {
--z-controls: 10;
--z-cover: 9;
--z-overlays: 6;
}
[debug] {
--ipub-debug-bg-opacity: 30%;
background-color: rgba(
from var(--ipub-debug-color) r g b / var(--ipub-debug-bg-opacity, 30%)
);
outline-color: var(--ipub-debug-color);
outline-width: 1px;
outline-style: solid;
}
ipub-track {
z-index: 1000;
position: absolute;
display: inline-block;
top: 0;
left: 0;
width: 100%;
height: 100%;
ipub-track-item {
top: 0;
left: 0;
display: inline-block;
position: absolute;
width: 100%;
height: 100%;
*:not(ipub-offset) {
position: sticky;
top: 0;
left: 0;
}
ipub-track-item-position {
width: 5rem;
display: inline-block;
height: var(--ipub-track-item-position);
}
}
}
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%;
}
}

View File

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

View File

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

1
.epub/example/mimetype Normal file
View File

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

View 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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.dist
out.css
.tmp
.env
*.db
*.epub
tmp

6
.gitmodules vendored Normal file
View File

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

20
.golangci.yml Normal file
View File

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

20
.vscode/launch.json vendored Normal file
View File

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

22
assets/assets.go Normal file
View File

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

View File

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

207
cmd/cmd.go Normal file
View File

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

196
comicverse.go Normal file
View File

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

6
flake.lock generated
View File

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

View File

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

29
go.mod Normal file
View File

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

48
go.sum Normal file
View File

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

7
go.work Normal file
View File

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

47
go.work.sum Normal file
View 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=

View File

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

View File

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

195
ipub/ast/ast.go Normal file
View File

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

77
ipub/ast/ast_test.go Normal file
View File

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

31
ipub/ast/content.go Normal file
View File

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

11
ipub/ast/package.go Normal file
View File

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

20
ipub/ast/section.go Normal file
View File

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

29
ipub/element/attr/attr.go Normal file
View File

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

View File

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

View File

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

112
ipub/element/element.go Normal file
View File

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

View File

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

41
ipub/element/sections.go Normal file
View File

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

69
makefile Normal file
View File

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

66
model/model.go Normal file
View File

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

145
model/permission.go Normal file
View File

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

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

41
model/user.go Normal file
View File

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

283
repository/permission.go Normal file
View File

@@ -0,0 +1,283 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Permissions struct {
baseRepostiory
}
// Must be initiated after [User] and [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
View File

@@ -0,0 +1,282 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type 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
}

51
repository/repository.go Normal file
View File

@@ -0,0 +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 (
// 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
View File

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

211
repository/user.go Normal file
View File

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

132
router/projects.go Normal file
View File

@@ -0,0 +1,132 @@
package router
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"code.capytal.cc/capytal/comicverse/service"
"code.capytal.cc/capytal/comicverse/templates"
"code.capytal.cc/loreddev/smalltrip/problem"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type 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)
}

156
router/router.go Normal file
View File

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

293
router/user.go Normal file
View File

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

124
service/project.go Normal file
View File

@@ -0,0 +1,124 @@
package service
import (
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/capytal/comicverse/repository"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type 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
}

5
service/service.go Normal file
View File

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

188
service/token.go Normal file
View File

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

95
service/user.go Normal file
View File

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

1
smalltrip Submodule

Submodule smalltrip added at 3d201d2122

59
templates/dashboard.html Normal file
View File

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

20
templates/landing.html Normal file
View File

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

View File

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

View File

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

32
templates/login.html Normal file
View File

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

View File

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

75
templates/project.html Normal file
View File

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

32
templates/register.html Normal file
View File

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

77
templates/templates.go Normal file
View File

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

1
x Submodule

Submodule x added at 6ea200aa64