170 Commits
Author SHA1 Message Date
Florian Sylvain 0d03a27f31 docs: release notes for v3.2.1-beta 2026-10-13 22:39:42 +02:00
Florian Sylvain 63c1e7076e feat(media): pagination 2026-10-13 19:54:59 +02:00
Florian Sylvain 04dc151ed7 feat(variant): image preview on variant creation with a real sample image 2026-10-13 17:29:56 +02:00
Florian Sylvain a1cdfce529 fix(integration): missing french translations 2026-10-13 16:54:06 +02:00
Florian Sylvain c41e6dbca5 fix(SPA): activity list saves pagination too 2026-10-13 16:36:42 +02:00
Florian Sylvain 9d9453c1b3 fix(SPA): save article list pagination in query url 2026-10-13 16:20:37 +02:00
Florian Sylvain 54f27ac98f docs: release notes for v3.2.0-beta and version references cleanup 2026-10-13 03:26:04 +02:00
Florian Sylvain 0632bf53cb feat(SPA): "<page> · RenewCMS" as pages titles, instead of being stuck on whatever lands on first loading 2026-10-13 03:19:25 +02:00
Florian Sylvain fe254112a2 fix(register): onSubmit now calls await session.retry() before going to /register/pending 2026-10-13 02:49:43 +02:00
Florian Sylvain 160c0a49ce feat(media): align visual checks and variant UI with merged design 2026-10-13 02:35:20 +02:00
Florian Sylvain e6746f2be7 feat(integration): add image variants section and standardize code snippets 2026-10-12 23:27:41 +02:00
Florian Sylvain 6f9ae6207a feat(editor): link insertion full UI and flow 2026-10-12 17:12:36 +02:00
Florian Sylvain 03b39e64f2 feat(activity): update summary cards to toggles and introduce export menu 2026-10-12 05:13:25 +02:00
Florian Sylvain 73f4b539fe feat: articles pagination 2026-10-11 19:14:37 +02:00
Florian Sylvain a2d224240c fix: small design harmonization and overhaul 2026-10-11 17:17:22 +02:00
Florian Sylvain d6566743a7 fix: prevent air from deleting the whole tmp folder, used for visual diff task 2026-10-11 08:49:31 +02:00
Florian Sylvain 10ec74c8cf feat: remove the 10,000-character article body limit and make the New article body optional 2026-10-11 08:49:11 +02:00
Florian Sylvain 5e05b606a7 chore: 2 big design passes to harmonize and fix stuff 2026-10-11 07:45:17 +02:00
Florian Sylvain 67b3ff03d0 fix: linux dev workflow 2026-10-11 06:31:49 +02:00
fsylvain ce9c876db6 refac: removed ugly multi-line method chaining 2026-10-10 20:49:05 +02:00
fsylvain e8dff6bb5f chore: improved mutate script so we don't end up with hunders of GBs of gremlins cache after a few slices 2026-10-10 00:23:39 +02:00
fsylvain c484d5bd12 release: v3.1.0-beta Image variants 2026-10-08 21:15:43 +02:00
fsylvain d768668014 feat: image variants cache panel 2026-10-08 21:13:32 +02:00
fsylvain 951fef18ba feat: image variants per image 2026-10-08 19:57:51 +02:00
fsylvain f567866161 feat: image variants settings screens 2026-10-08 13:57:41 +02:00
fsylvain f1d56ef50e feat: image variants engine 2026-10-08 02:33:32 +02:00
fsylvain f0845de643 chore: media compression feature groundwork 2026-10-08 00:26:31 +02:00
fsylvain f914eae3c5 feat: setup logs retention by max file size 2026-10-07 22:02:13 +02:00
fsylvain 70c8492f4f fix: design harmonization on last features 2026-10-07 18:20:18 +02:00
fsylvain 953be7342d fix: jsdom doesn't implement Range.prototype.getClientRects 2026-10-07 16:51:57 +02:00
fsylvain 28d2d5c826 fix: missing integration page routes 2026-10-07 16:50:44 +02:00
fsylvain 59c72a84ce release: v3.0.0-beta Activity log, more databases and S3 storage 2026-10-07 16:04:23 +02:00
fsylvain 43469ef08d feat: recent activity card on Home and Retention card on Settings 2026-10-07 16:01:28 +02:00
fsylvain 35db442cd3 feat: activity screen in the SPA, route /activity 2026-10-07 01:48:21 +02:00
fsylvain e37a46a027 feat: activity log producers 2026-10-06 22:25:23 +02:00
fsylvain 50b1649965 feat: activity log backend foundation 2026-10-06 20:35:03 +02:00
fsylvain 8907bb49fe chore: groundwork for activity page 2026-10-06 14:23:51 +02:00
fsylvain dc5d12348c feat: PostgreSQL driver support 2026-10-06 13:35:10 +02:00
fsylvain eb9bfabed5 feat: optional S3-compatible storage for uploaded images, instead of the local upload directory 2026-10-06 12:13:24 +02:00
fsylvain d752a61e52 feat: optional MySQL/MariaDB database driver for deployments that cannot use local SQLite 2026-10-06 04:08:33 +02:00
fsylvain 81e299f09a refac: decouple persistence and image storage behind driver registries 2026-10-06 02:13:35 +02:00
fsylvain 421cfafec3 release: v2.9.0-beta Media duplicate purge 2026-10-05 23:58:54 +02:00
fsylvain 2303b12377 feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button) 2026-10-05 23:57:50 +02:00
fsylvain 1a55a39425 feat: media duplicate purge, part 2 of 3: the automatic purge scheduler (Go only) 2026-10-05 19:44:15 +02:00
fsylvain e82d0ce254 feat: media duplicate purge, part 1 of 3: persisted purge settings and the purge API 2026-10-05 18:24:03 +02:00
fsylvain 161ddc0f7d chore: groundwork for media purge system 2026-10-05 17:16:06 +02:00
fsylvain c1e73bda81 release: v2.8.3-beta Security policy fixes for the web app 2026-10-05 16:33:31 +02:00
fsylvain 508a8ab77e fix: CSP policy missing front components like icons, nextui color mode, wasm 2026-10-05 16:32:19 +02:00
fsylvain 1e5f324579 release: v2.8.2-beta Security headers and clearer log in errors 2026-10-05 16:24:12 +02:00
fsylvain 1d4f4bd2cc fix: missing security headers on every response, including the SPA and uploaded images 2026-10-05 16:19:37 +02:00
fsylvain d8f171b8cc fix: more complete log in username error messages 2026-10-05 16:04:24 +02:00
fsylvain 8ad3941f6d release: v2.8.1-beta Docker deployment 2026-10-05 04:56:12 +02:00
fsylvain d275ba3c14 feat: complete dockerization (optional) 2026-10-05 04:52:49 +02:00
fsylvain 3e01c29657 release: v2.8.0-beta API keys and API reference 2026-10-05 02:08:26 +02:00
fsylvain acd392c29d feat: integration page (PART 3/3) 2026-10-05 02:03:24 +02:00
fsylvain 514a082c96 feat(SPA): screen to manage API keys (PART 2/3) 2026-10-05 00:05:12 +02:00
fsylvain 844842fdae feat: integration v2 and API key PART 1/3 2026-10-04 21:56:14 +02:00
fsylvain 7e9bd9ba30 feat: articles format option with markdown new default 2026-10-04 19:04:12 +02:00
fsylvain 68401d66e0 refac: revamp the update flow in settings so the user has better visibility that it has to restart when update downloaded 2026-10-04 17:42:46 +02:00
fsylvain daf497576f fix: widened the theme selector in auth shell 2026-10-04 17:10:49 +02:00
fsylvain 0490a4c494 fix: language buttons real design / conformance 2026-10-04 17:08:43 +02:00
fsylvain 2c8f39273c release: v2.6.2-beta English and French interface 2026-10-03 02:08:52 +02:00
fsylvain 721311a643 feat(SPA): i18n internationzalization, english and french 2026-10-03 01:53:49 +02:00
fsylvain 7f1e8e572d docs: deployement guide with new ui update settings 2026-10-02 17:11:04 +02:00
fsylvain 02a0a36dc3 release: v2.6.0-beta signed updates and browser restart 2026-10-02 16:56:04 +02:00
fsylvain 5a430c08f4 chore: ai fest 2026-10-02 16:44:04 +02:00
fsylvain 54e8fd5fed feat: restart to apply update, privileged helper, rollback 2026-10-01 21:36:40 +02:00
fsylvain 70460e4170 feat: update button (download, verify, stage) 2026-10-01 18:13:45 +02:00
fsylvain 834493a4f5 feat: signed releases, release feed, notification (read-only) 2026-10-01 15:01:53 +02:00
fsylvain a49eec909c chore: groundwork for update from settings page 2026-10-01 06:04:02 +02:00
fsylvain e10a5e57e2 docs: release notes for v2.5.0-beta 2026-10-01 05:32:13 +02:00
fsylvain ff0854f331 fix: deleted image wrongfully caching 2026-10-01 05:27:25 +02:00
fsylvain b3793d5dbb feat: media picker and image removal 2026-10-01 05:04:58 +02:00
fsylvain 008b0dfbb9 feat: media page T1 2026-10-01 01:36:35 +02:00
fsylvain 67d456baf6 feat(SPA): media page T0 2026-09-30 19:45:47 +02:00
fsylvain 0d99544579 feat(API): media library 2026-09-30 18:35:28 +02:00
fsylvain 95ae043d3e chore: groundwork for media library feature 2026-09-30 17:48:15 +02:00
fsylvain e0fa433ff9 docs: release notes format and patch notes for v2.0.0-beta to v2.4.0-beta 2026-09-30 03:11:37 +02:00
fsylvain 142325730d feat: users row actions "Cancel invite" on Pending rows and "Renew invite" on Expired rows 2026-09-30 02:48:49 +02:00
fsylvain 1ece778caf feat: UsersTable and UserCardList get Email, Display name (dash when empty), Role (new RoleBadge), Invite status and Created 2026-09-30 02:00:46 +02:00
fsylvain 153fd8719a feat: invite management API: DELETE /v1/user/invite/:id (204, cancels a pending invite) and POST /v1/user/invite/:id/renew (200, new code, expires_at = now+3d, created_at = now, resends the invitation email, old link dead) 2026-09-30 01:36:43 +02:00
fsylvain 461e93e3b5 feat: add Role (owner|admin) to the user domain, persistence model and mapper 2026-09-30 00:58:58 +02:00
fsylvain 162fea4afb feat: settings page, sidebar and drawer destination "Settings" (icon settings, after Integration), route /settings (verified), showing only the running build's Version 2026-09-30 00:19:29 +02:00
fsylvain b146dfead7 fix: desktop account menu icons, labels and check mark are now centred in their 32 px rows 2026-09-29 23:36:42 +02:00
fsylvain 27dadbbfa6 fix: account menu matches the redesigned board: System, Light, Dark stay in the menu and the sheet, and the visual-check exception on profile "Account menu" in web/visual/states.ts is removed 2026-09-29 23:31:04 +02:00
fsylvain 6dc47789ef chore: groundwork for new design and features related to users and settings 2026-09-29 23:19:41 +02:00
fsylvain 65ced64568 fix: "save X min ago" only min to min/hours/days 2026-09-29 22:56:05 +02:00
fsylvain c40522a09f fix: scrollable toolbar in mobile editor 2026-09-29 22:53:49 +02:00
fsylvain da36c78bc0 refac: hardening pass with multiple overhauls 2026-09-29 10:55:23 +02:00
fsylvain b0f7b7892b feat(SPA): profile page, display name and the new account menu 2026-09-29 10:13:09 +02:00
fsylvain 55525c06d6 feat: invitation accept screen and api 2026-09-29 09:21:53 +02:00
fsylvain 310cfd528e chore: new design for invitations accept 2026-09-29 08:41:54 +02:00
fsylvain 6afffc1984 feat(SPA): users page and invite modal 2026-09-29 08:35:14 +02:00
fsylvain acdef18b37 feat(API): user invitations and emails 2026-09-29 07:15:17 +02:00
fsylvain 7aae704d0a feat(API): optional display name on users 2026-09-29 06:52:58 +02:00
fsylvain 792aba433d chore: new design groundwork for next features 2026-09-29 06:32:46 +02:00
fsylvain b29d0ed045 feat: editor image removal QOLs 2026-09-29 02:44:31 +02:00
fsylvain 0fc00ceeff refac: cleaning round for image cropping/resizing 2026-09-28 07:56:13 +02:00
fsylvain e15f444603 feat: image cropping / resizing T0 2026-09-28 07:46:12 +02:00
fsylvain cb05b38f36 feat: password reset full design and flow 2026-09-28 05:06:16 +02:00
fsylvain 938263cd6a feat: image upload on articles 2026-09-28 02:28:20 +02:00
fsylvain d4fcfb2bba fix: multiple fixes all accross the SPA, missing buttons actions, styling etc. 2026-09-27 23:16:53 +02:00
fsylvain ad55137883 feat: integration screen 2026-09-27 18:04:43 +02:00
fsylvain a9a208b80a fix: workflow now ignores time senstive visual checks 2026-09-27 17:15:22 +02:00
fsylvain f6d0d01092 feat: articles list screen 2026-09-27 17:12:23 +02:00
fsylvain ca3fe20519 fix: browser was running two copies of ProseMirror's state module (prosemirror-state) at once 2026-09-27 06:31:45 +02:00
fsylvain c47d33eaa9 feat: article creation modal 2026-09-27 06:26:41 +02:00
fsylvain a4734b2c91 feat: article editor core 2026-09-27 02:11:38 +02:00
fsylvain d493a181e1 fix: visuals reference matching overhaul and better workflow 2026-09-26 21:04:51 +02:00
fsylvain 930b836241 feat: home screen and navigation design 2026-09-26 17:46:42 +02:00
fsylvain 30a4df47b1 feat: new emails design 2026-09-26 05:41:34 +02:00
fsylvain 4e41d60142 feat: register T1 (pending, verify) 2026-09-26 04:56:48 +02:00
fsylvain 3c3f7c6194 feat: register T0 2026-09-26 02:48:16 +02:00
fsylvain a5873f8886 fix: changed frontend workflow and fixed login reference matching for real 2026-09-25 22:31:44 +02:00
fsylvain f1e22f7073 fix: login match design reference 2026-09-25 21:28:12 +02:00
fsylvain 001f9f4774 feat: login page T0 2026-09-25 20:15:29 +02:00
fsylvain 12faab7f92 feat: typed API client (useApi) and session store (useSession) with the global 401, unverified-403, 429 and offline handling from states.md 2026-09-25 17:33:40 +02:00
fsylvain eed661bb24 feat: PrimeVue replaced by Nuxt UI using token files from web/src/theme, with AuthShell, DashboardShell, AppBoot and the route guards from routes.md 2026-09-25 17:09:34 +02:00
fsylvain 24f95a8fd7 chore: Vitest + Vue Test Utils test runner exposed as task test:web 2026-09-25 16:12:50 +02:00
fsylvain 64f50074bd chore: setup design references 2026-09-25 16:04:12 +02:00
fsylvain 2a64991916 refac: GET /v1/auth/registration 2026-09-25 15:54:42 +02:00
fsylvain 61828783a1 refac: GET /static/uploadedImages/{filename} 2026-09-25 15:40:21 +02:00
fsylvain 8d17a12e92 refac: GET /v1/auth/me 2026-09-25 15:17:39 +02:00
fsylvain 0e050c36ff fix: rate limiting X-Real-IP from the real peer 2026-09-25 06:49:55 +02:00
fsylvain 9b47685002 refac: split the auth handler into registration and login, account verification, and password reset handlers, each with its own dependencies 2026-09-25 06:14:53 +02:00
fsylvain 3282b0580c fix: remove the unused user use cases, fix the specs that no longer match the code, include api/middleware and api/pkg/claims in mutation testing, log failures that password reset requests currently ignore, and reject user_id claims that don't fit a user id 2026-09-25 05:58:37 +02:00
fsylvain cbb403011a fix: login and password reset requests are rate-limited per client (not in the README; there is currently no brute-force protection on the public auth routes) 2026-09-25 05:33:02 +02:00
fsylvain f3ec3d2fa2 fix: resetting the password or cancelling a registration ends existing sessions: a token issued before the change is refused 2026-09-25 05:03:34 +02:00
fsylvain 81c4b62175 fix: reading a single article follows the same rule as the list: anyone can read an online article, a verified user can also read an offline one, and an unknown or hidden id returns 404 2026-09-24 16:32:40 +02:00
fsylvain 2ee28203ab fix: API responses never include a user's password, verification code or reset code hashes, or their expiry dates; verifying an account returns only the public user fields 2026-09-24 15:39:50 +02:00
fsylvain aa865af2e9 refac: reamaining auth surface rewrite, T0 2026-09-24 05:11:49 +02:00
fsylvain 6d510d2805 refac: register only open while no verified acc exists, enforced by a use case rather than handler & sends verification email whose link uses the conf pub URL whose code expires after 2h 2026-09-24 03:36:37 +02:00
fsylvain 6e9e108033 refac: several unverified users can exist at once: an empty reset or verification code is stored as no value so it doesn't break the unique constraint, and deleting a user removes the row so the same username and email can register again 2026-09-24 01:59:24 +02:00
fsylvain 0b4e614ec9 refac: runtime settings are read once at startup and passed in through the composition root; no handler, router or repository reads environment variables 2026-09-24 01:30:00 +02:00
fsylvain e474f03d91 refac: authenticated user can upload an image to an existing article and receives its public location; an unknown article returns 404 and a disallowed type returns 400 2026-09-24 00:33:26 +02:00
fsylvain 5992c8598b refac: allowed image types and the size limit are a domain rule checked by the use case, not by the persistence layer 2026-09-24 00:03:29 +02:00
fsylvain 8a8a24434a refac: deleting an article also deletes its images, and deleting a missing article returns 404 2026-09-23 23:42:18 +02:00
fsylvain c16fdbd26d refac: authenticated user can edit an article's body; an unknown id returns 404 2026-09-23 23:24:03 +02:00
fsylvain 8822bdddd0 refac: authenticated user can publish and unpublish an article; an unknown id returns 404 2026-09-23 22:54:44 +02:00
fsylvain 790d4e6da6 refac: authenticated user can list all articles, including offline ones, while the public list only shows online ones 2026-09-23 22:36:41 +02:00
fsylvain 6a2ca07b66 refac: repository updates, image attachment and deletes report the domain not-found error for an unknown id, the same for articles, users and images, and the test fakes stop returning GORM errors 2026-09-23 22:09:06 +02:00
fsylvain 4278d96882 refac: article title rules are the same in the API request and the domain; an invalid title returns 400 instead of 500 2026-09-23 20:04:09 +02:00
fsylvain ff8cd5f0ec refac: password and code hashing & checking go through a domain hasher interface used by the use cases instead of bcrypt in the user repository and the auth handler 2026-09-23 19:29:20 +02:00
fsylvain 8460a078e3 refac: missing user or article is reported with a domain not-found error instead of a raw GORM error, and the API answers 404 for it 2026-09-23 18:55:05 +02:00
fsylvain 7c57173497 feat: characterized the existing use cases as a baseline; every repo fake behaves like the real ones and can be told to fail, every use case is covered; gremlins runs clean on useCases 2026-09-23 18:21:46 +02:00
fsylvain 09bd5afa4a refac: rethought the complete workflow and deployement flow ; docs etc. 2026-09-23 17:36:36 +02:00
Floriansylvain 4a6209a8db feat: api serve frontend, build workflow, router readyness 2025-12-24 01:05:58 +01:00
Floriansylvain aee595dee9 feat: primevue base 2025-12-23 23:14:04 +01:00
Floriansylvain 9ecaa80fcf feat: initial vuejs frontend commit 2025-12-22 17:45:19 +01:00
Floriansylvain fbbce403e2 fix: allowed register condition 2025-12-22 03:07:30 +01:00
Floriansylvain 9c80cb9885 feat: unit testing mocked repos / domain 2025-12-22 02:49:57 +01:00
Floriansylvain ce787d26ff fix: remove low file code from domain 2025-12-22 02:41:09 +01:00
Floriansylvain 76245c47c9 fix: added some business rules to article creation 2025-12-22 01:18:50 +01:00
Floriansylvain 5282770caa refac: better error handling & moved IsOnline logic to cleaner place 2025-12-22 00:59:36 +01:00
Floriansylvain b50efae8a8 refac: mappers, file names 2025-12-22 00:06:17 +01:00
Floriansylvain e0a6706d4e fix: removed domain article coupling with infra 2025-12-21 21:53:29 +01:00
Floriansylvain 0eba18e367 fix: removed useless file 2025-12-21 21:34:07 +01:00
Floriansylvain 61a84201d8 refac: remove frontend & clearer DDD 2025-12-21 21:31:30 +01:00
Floriansylvain 6796ce63cb fix: missing verification code handling 2025-12-13 18:53:30 +01:00
Floriansylvain 99812ddba4 fix: update file path separator in .air.toml and improve server start message 2025-12-13 18:53:17 +01:00
Florian Sylvain 0238c7511d refac: useless little refactor for clean verificationCode placement 2025-12-09 22:27:59 +01:00
Florian Sylvain d7ee0a488c refac: cleaner DI function names 2025-11-17 20:24:51 +01:00
Florian Sylvain c57394d4fd mod: upgraded Go version and all dependencies to latest stable versions 2025-11-17 20:05:50 +01:00
Florian Sylvain f47f145b6c fix: unused param and missing return 2025-11-17 19:51:38 +01:00
Florian Sylvain 83a1dec8ae refac: renamed 'GoCMS' -> 'RenewCMS' 2025-11-17 19:46:53 +01:00
Florian Sylvain b470bfcc67 refac: renamed 'post' -> 'article' 2025-11-17 19:27:48 +01:00
Florian Sylvain cb9a431488 fix: reverted the bourbier done to DI, reflection is bad 2025-03-29 01:52:22 +01:00
Florian Sylvain 2292a8ef34 fix: DI wrong approach wasn't working 2025-03-29 01:45:48 +01:00
Florian Sylvain a77a5ad314 refac: fixed, better, smarter, correct dependency injection 2025-03-29 00:51:01 +01:00
Florian Sylvain d895477b8c tech: refact build script & added arm64 builds 2025-03-13 21:49:59 +01:00
1330 changed files with 200220 additions and 14714 deletions

No files matched your search

+10 -41
View File
@@ -1,51 +1,20 @@
root = "." root = "."
testdata_dir = "testdata" tmp_dir = "tmp/air"
tmp_dir = "tmp"
[build] [build]
args_bin = [] cmd = "go build -tags nofrontend -o ./tmp/air/renewcms ./cmd/server"
bin = "tmp\\main.exe" bin = "tmp/air/renewcms"
cmd = "go build -o ./tmp/main.exe ./main" delay = 500
delay = 1000 exclude_dir = ["tmp", "tmp/air", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"]
exclude_dir = ["assets", "tmp", "vendor", "testdata", "api\\static\\tinymce", "api\\static\\bootstrap-icons", "api\\static\\uploadedImages"]
exclude_file = []
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
exclude_unchanged = false include_ext = ["go", "html"]
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log" log = "build-errors.log"
poll = false send_interrupt = true
poll_interval = 0 kill_delay = "2s"
post_cmd = [] stop_on_error = true
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log] [log]
main_only = false
time = false time = false
[misc] [misc]
clean_on_exit = false clean_on_exit = true
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true
+17
View File
@@ -0,0 +1,17 @@
.git
.gitignore
.dockerignore
.env
docker.env
tmp/
bin/
data/
node_modules
web/node_modules
internal/infrastructure/assets/dist
.task/
.claude/
.idea
.vscode
*.db
*.exe
+40 -9
View File
@@ -1,11 +1,42 @@
ENVIRONMENT=development PUBLIC_URL=http://localhost:5173
HOST=example.com JWT_SECRET=change-me-to-a-long-random-string
PORT=8080
JWT_SECRET=abc123
CORS_ALLOWED_ORIGINS=*
DB_FILE=./gocms.db
DOCKER_DB_FOLDER=./data
SMTP_EMAIL=a@b.c
SMTP_PASSWORD=12341234
SMTP_HOST=mail.service.abc SMTP_HOST=mail.service.abc
SMTP_PORT=465 SMTP_PORT=465
SMTP_LOGIN=a@b.c
SMTP_PASSWORD=12341234
SMTP_SENDER=a@b.c
ENVIRONMENT=development
PORT=8080
DB_FILE=./data/renewcms.db
UPLOAD_DIR=./data/uploads
CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
IMAGE_VARIANT_MAX_SOURCE_PIXELS=50000000
IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=4
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_TLS=false
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_SECONDS=300
STORAGE_DRIVER=local
S3_BUCKET=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_KEY_PREFIX=
S3_PATH_STYLE=false
S3_TLS=true
+4 -1
View File
@@ -1 +1,4 @@
api/static/** linguist-vendored * text=auto eol=lf
*.ico binary
*.png binary
*.jpg binary
-40
View File
@@ -1,40 +0,0 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
environment: test
env:
ENVIRONMENT: ${{ secrets.ENVIRONMENT }}
HOST: ${{ secrets.HOST }}
PORT: ${{ secrets.PORT }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
CORS_ALLOWED_ORIGINS: ${{ secrets.CORS_ALLOWED_ORIGINS }}
DB_FILE: ${{ secrets.DB_FILE }}
DOCKER_DB_FOLDER: ${{ secrets.DOCKER_DB_FOLDER }}
SMTP_EMAIL: ${{ secrets.SMTP_EMAIL }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.23.0"
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
+13
View File
@@ -4,14 +4,27 @@
# DB # DB
*.db *.db
/data/
# Build # Build
bin bin
dist/
# Environment # Environment
.env .env
docker.env
# Generated files # Generated files
tmp/ tmp/
.task/
build-errors.log
uploadedImages/ uploadedImages/
*.exe *.exe
# AI fest
.claude/
CLAUDE.md
.codex/
.agents/
AGENTS.md
codex-workflow.md
+100
View File
@@ -0,0 +1,100 @@
# Coding Standards
These rules apply to every file in this repo , for humans and agents alike.
## General rules
1. Follow standard conventions.
2. Keep it simple stupid. Simpler is always better. Reduce complexity as much as possible.
3. Boy scout rule. Leave the campground cleaner than you found it.
4. Always find root cause. Always look for the root cause of a problem.
## Design rules
1. Keep configurable data at high levels.
2. Prefer polymorphism to if/else or switch/case.
3. Separate multi-threading code.
4. Prevent over-configurability.
5. Use dependency injection.
6. Follow Law of Demeter. A class should know only its direct dependencies.
## Understandability tips
1. Be consistent. If you do something a certain way, do all similar things in the same way.
2. Use explanatory variables.
3. Encapsulate boundary conditions. Boundary conditions are hard to keep track of. Put the processing for them in one place.
4. Prefer dedicated value objects to primitive types.
5. Avoid logical dependency. Don't write methods which work correctly only depending on something else in the same class.
6. Avoid negative conditionals.
## Names rules
1. Choose descriptive and unambiguous names.
2. Make meaningful distinctions.
3. Use pronounceable names.
4. Use searchable names.
5. Replace magic numbers with named constants.
6. Avoid encodings. Don't append prefixes or type information.
## Functions rules
1. Small.
2. Do one thing.
3. Use descriptive names.
4. Prefer fewer arguments.
5. Have no side effects.
6. Don't use flag arguments. Split the method into several independent methods that can be called from the client without the flag.
## Comments rules
1. No comments in the codebase. This covers Go, TypeScript/Vue, HTML templates and config files (YAML, TOML, `.env.example`, `.gitattributes`, …).
2. Explain yourself in code instead: descriptive names, small functions, named constants, explanatory variables.
3. The only exceptions are tool directives the code needs to work, such as `//go:build` and `//go:embed`.
4. Don't comment out code; just remove it.
5. Prose belongs in `docs/` and `README.md`, not in source files.
## Source code structure
1. Separate concepts vertically.
2. Related code should appear vertically dense.
3. Declare variables close to their usage.
4. Dependent functions should be close.
5. Similar functions should be close.
6. Place functions in the downward direction (caller above callee).
7. Keep lines short.
8. Don't use horizontal alignment.
9. Use white space to associate related things and disassociate weakly related ones.
10. Don't break indentation.
## Objects and data structures
1. Hide internal structure.
2. Prefer data structures when there's no behavior to attach.
3. Avoid hybrid structures (half object, half data).
4. Keep classes small.
5. Each class should do one thing.
6. Keep the number of instance variables small.
7. Base classes should know nothing about their derivatives.
8. Prefer many small functions over one function with a parameter that selects behavior.
9. Prefer non-static methods to static methods.
## Tests
1. One assert per test.
2. Readable.
3. Fast.
4. Independent.
5. Repeatable.
## Code smells to watch for
1. **Rigidity** — the software is difficult to change; a small change causes a cascade of subsequent changes.
2. **Fragility** — the software breaks in many places due to a single change.
3. **Immobility** — you cannot reuse parts of the code in other projects because of the risk and effort involved.
4. **Needless complexity**.
5. **Needless repetition**.
6. **Opacity** — the code is hard to understand.
## Credits
Thanks to [@wojtekluwojteklu/clean_code.md](https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29)
+26 -12
View File
@@ -1,16 +1,30 @@
FROM golang:1.20.5-alpine FROM oven/bun:1-alpine AS web
WORKDIR /src/web
WORKDIR /app COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web/ ./
RUN bun run build-only
FROM golang:1.27.1-alpine AS server
ARG VERSION=dev
ENV CGO_ENABLED=0
WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
COPY adapters ./adapters
COPY api ./api
COPY domain ./domain
COPY main ./main
COPY useCases ./useCases
COPY .env ./.env
RUN go mod download RUN go mod download
RUN go build -o startServer ./main COPY . .
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
CMD ["./startServer"] FROM alpine:3.22
RUN addgroup -S -g 10001 renewcms \
&& adduser -S -u 10001 -G renewcms -H -s /sbin/nologin renewcms \
&& mkdir /data \
&& chown renewcms:renewcms /data
COPY --from=server /out/renewcms /usr/local/bin/renewcms
ENV RENEWCMS_CONTAINER=true
USER renewcms
WORKDIR /data
VOLUME /data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["wget", "-qO-", "http://127.0.0.1:8080/healthz"]
ENTRYPOINT ["/usr/local/bin/renewcms"]
+37 -45
View File
@@ -1,65 +1,57 @@
# GoCMS # RenewCMS
## 🚧 This project is under development ! A small self-hosted CMS: a Go API and a Vue 3 admin/front SPA, shipped as **a single executable** backed by a SQLite file.
The features are not complete, not fully tested and subject to many changes. > 🚧 **Under development.** Features are incomplete, not fully tested and subject to change.
## Setup ## Quick start
Don't forget to setup the [Environment variables](#environment-variables)! ### Run a release binary
### Use with Docker ```sh
cp .env.example .env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
#### Compose ./renewcms # → http://localhost:8080
The easiest way to run the project is to use docker-compose.
You still need to set up the environment variables, but you can use the `.env` file for that.
Note that some supplementary variables are required for docker-compose to work, like `DOCKER_DB_FOLDER`.
```bash
docker compose up --build -d
``` ```
### Build and run it yourself Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**.
#### Build ### Run with Docker
There are 2 scripts to build the project: ```sh
cp docker.env.example docker.env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
docker compose pull
docker compose up -d # http://localhost:8080
```
- `super_build.sh` Upgrade with `docker compose pull && docker compose up -d`. The image is `git.floriansylvain.fr/fsylvain/renewcms`. Every release is a pre-release for now, so the default tag is the moving `beta`. Pin a version with `RENEWCMS_IMAGE`. Tags, building from source and publishing are in **[docs/docker.md](docs/docker.md)**.
- `super_build.bat`
They will build the project for windows, linux and macOS. The binaries will be in the `bin` folder. ### Updates from the browser
You can also really just use `go build` to build the project for your current OS.
#### Run On Linux with systemd, install v2.6.0-beta or newer manually, then follow [the browser-update setup guide](docs/deployment.md#browser-updates). It covers finding the actual database directory, installing the helper, testing a restart and troubleshooting. Other platforms use manual upgrades.
Once the project is built, you can simply run the binary for your OS. Setting the environment variables is ### Develop
of course required, but not necessarily via the `.env` file.
### Environment variables Requires Go 1.27+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev).
| Name | Type | Description | Comment | ```sh
|----------------------|--------|:----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| cp .env.example .env
| ENVIRONMENT | string | environment the API is running in | required, `development` or `production` | task setup
| HOST | string | the host domain name for emails and callbacks | required | task dev # API with hot reload + Vite HMR → http://localhost:5173
| PORT | int | port the API will use | required | ```
| JWT_SECRET | string | secret for the jwt auth | required |
| CORS_ALLOWED_ORIGINS | string | allowed origins for CORS | required, semicolon separated list |
| DB_FILE | string | path to the sqlite db file | required, can be at the root but name still required (e.g. `./gocms.db`) ; have to end up with `.db` |
| DOCKER_DB_FOLDER | string | path to the sqlite db file's folder | required with docker, this will basically be the host machine folder (e.g. `./data`) that contains the sqlite db file |
| SMTP_EMAIL | string | sender email | required |
| SMTP_PASSWORD | string | smtp account password | required |
| SMTP_HOST | string | smtp server address | required |
| SMTP_PORT | int | smtp server port | required |
## API Usage ## Documentation
TODO | Doc | Contents |
|---|---|
## Demo | [docs/development.md](docs/development.md) | Dev setup, how Vite and the API fit together, tests, lint, `task` commands |
| [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
TODO | [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing |
| [docs/configuration.md](docs/configuration.md) | Every environment variable |
| [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
| [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log |
| [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas |
| [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache |
| [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one |
## TODOs ## TODOs
+423
View File
@@ -0,0 +1,423 @@
version: '3'
vars:
BINARY: renewcms
DIST_DIR: internal/infrastructure/assets/dist
VERSION:
sh: git describe --tags --always --dirty
LDFLAGS: -s -w -X main.version={{.VERSION}}
RELEASE_TARGETS: [windows/amd64, windows/arm64, linux/amd64, linux/arm64, darwin/amd64, darwin/arm64]
env:
CGO_ENABLED: '0'
__COMPAT_LAYER: RunAsInvoker
tasks:
default:
cmds:
- task --list
silent: true
setup:
desc: Install dev tools, Go modules and frontend dependencies
cmds:
- go install github.com/air-verse/air@latest
- go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
- go mod download
- task: web:install
dev:
desc: Run the API (air, hot reload) and the SPA (Vite, HMR) together → http://localhost:5173
deps: [dev:api, dev:web]
dev:api:
desc: Run the API with hot reload on :8080 (SPA not embedded)
cmds:
- air
dev:web:
desc: Run the Vite dev server on :5173, proxying /v1 to the API
dir: web
deps: [web:install]
cmds:
- bun dev
test:
desc: Run the Go tests
cmds:
- go test -tags nofrontend ./test/... -count=1 {{.CLI_ARGS}}
test:mysql:
desc: Run the MySQL tests against real servers (mysql:8.4 then mariadb:11 in Docker, removed afterwards)
cmds:
- cmd: |
PASSWORD=renewcms-test-root
STATUS=0
run_engine() {
NAME="renewcms-test-$1"
IMAGE="$2"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e MYSQL_ROOT_PASSWORD="$PASSWORD" -p 127.0.0.1::3306 "$IMAGE" >/dev/null || return 1
MAPPING="$(docker port "$NAME" 3306/tcp)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 90 ]; do
if docker exec "$NAME" sh -c "\$(command -v mariadb || command -v mysql) -h127.0.0.1 -uroot -p$PASSWORD -e 'SELECT 1'" >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "$IMAGE did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
return 1
fi
echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_MYSQL_DSN="root:$PASSWORD@tcp(127.0.0.1:$PORT)/mysql" go test -tags nofrontend ./test/... -run MySQL -count=1 {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT
}
run_engine mysql mysql:8.4 || STATUS=1
run_engine mariadb mariadb:11 || STATUS=1
exit $STATUS
silent: true
test:postgres:
desc: Run the PostgreSQL tests against real servers (postgres:16 then postgres:17 in Docker, removed afterwards)
cmds:
- cmd: |
PASSWORD=renewcms-test-postgres
STATUS=0
run_engine() {
NAME="renewcms-test-postgres-$1"
IMAGE="postgres:$1"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e POSTGRES_PASSWORD="$PASSWORD" -p 127.0.0.1::5432 "$IMAGE" >/dev/null || return 1
MAPPING="$(docker port "$NAME" 5432/tcp | head -n 1)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 90 ]; do
if docker exec "$NAME" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && docker exec "$NAME" psql -h 127.0.0.1 -U postgres -c 'SELECT 1' >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "$IMAGE did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
return 1
fi
echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run Postgres -count=1 {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT
}
run_engine 16 || STATUS=1
run_engine 17 || STATUS=1
exit $STATUS
silent: true
test:s3:
desc: Run the S3 storage integration test against a throwaway SeaweedFS in Docker (skipped when Docker is unavailable)
cmds:
- cmd: |
if ! docker info >/dev/null 2>&1; then
echo "Docker is not available, skipping the S3 integration test"
exit 0
fi
export MSYS_NO_PATHCONV=1
NAME=renewcms-test-seaweedfs
IMAGE=chrislusf/seaweedfs:4.48
BUCKET=renewcms-test
ACCESS_KEY=renewcms-test
SECRET_KEY=renewcms-test-secret
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e AWS_ACCESS_KEY_ID="$ACCESS_KEY" -e AWS_SECRET_ACCESS_KEY="$SECRET_KEY" -p 127.0.0.1::8333 "$IMAGE" server -dir=/data -s3 -s3.port=8333 >/dev/null || exit 1
MAPPING="$(docker port "$NAME" 8333/tcp | head -n 1)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 60 ]; do
if docker run --rm --network "container:$NAME" --entrypoint sh "$IMAGE" -c "echo 's3.bucket.create -name $BUCKET' | timeout 30 weed shell -master=127.0.0.1:9333 && echo s3.bucket.list | timeout 30 weed shell -master=127.0.0.1:9333 | grep -q $BUCKET" >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "SeaweedFS did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
exit 1
fi
echo "== SeaweedFS on 127.0.0.1:$PORT"
RENEWCMS_TEST_S3_ENDPOINT="http://127.0.0.1:$PORT" RENEWCMS_TEST_S3_BUCKET="$BUCKET" RENEWCMS_TEST_S3_ACCESS_KEY="$ACCESS_KEY" RENEWCMS_TEST_S3_SECRET_KEY="$SECRET_KEY" go test -tags nofrontend ./test/... -run S3StorageIntegration -count=1 -v {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
exit $RESULT
silent: true
cover:
desc: Go test coverage of the use cases → tmp/coverage.out
cmds:
- mkdir -p tmp
- go test -tags nofrontend ./test/... -count=1 -coverpkg=./internal/infrastructure/useCases/... -coverprofile=tmp/coverage.out
- go tool cover -func=tmp/coverage.out
mutate:
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
cmds:
- bash scripts/mutate.sh {{.CLI_ARGS}}
test:web:
desc: Run the SPA test suite (Vitest) in run mode
dir: web
deps: [web:install]
cmds:
- bun run vitest run
visual:check:
desc: Screenshot a design screen's states next to the built SPA (Playwright) → tmp/visual/<screen>/
dir: web
deps: [web:install]
cmds:
- bun visual/check.ts {{.CLI_ARGS}}
lint:
desc: gofmt check, go vet, eslint (--fix) and vue-tsc
cmds:
- task: lint:go
- task: lint:web
lint:go:
cmds:
- cmd: test -z "$(gofmt -l .)" || (gofmt -l . && echo "unformatted Go files, run 'task fmt'" && exit 1)
silent: true
- go vet -tags nofrontend ./...
lint:web:
dir: web
deps: [web:install]
cmds:
- bun lint
- bun run type-check
fmt:
desc: Format Go and frontend sources
cmds:
- gofmt -w .
- bun run --cwd web format
web:install:
internal: true
dir: web
sources: [package.json, bun.lock]
generates: ['node_modules/.bin/vite*']
cmds:
- bun install --frozen-lockfile
build:web:
desc: Type-check and build the SPA into {{.DIST_DIR}}
dir: web
deps: [web:install]
sources: [src/**/*, public/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock]
generates: ['../{{.DIST_DIR}}/index.html']
cmds:
- bun run build
build:
desc: Production binary for this machine → bin/{{.BINARY}}
deps: [build:web]
cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
release:
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
cmds:
- task: release:check
- task: build:web
- for: { var: RELEASE_TARGETS }
task: build:target
vars:
GOOS: '{{index (splitList "/" .ITEM) 0}}'
GOARCH: '{{index (splitList "/" .ITEM) 1}}'
- go run ./cmd/releasesign checksums bin --match "{{.BINARY}}_{{.VERSION}}_*"
- go run ./cmd/releasesign sign bin/checksums.txt
- go run ./cmd/releasesign verify bin/checksums.txt
release:check:
internal: true
cmds:
- cmd: 'test -n "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY is not set. Point it at the private release key file (see task release:keygen)." >&2; exit 1; }'
silent: true
- cmd: 'test -f "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY points to a missing file: $RELEASE_SIGNING_KEY" >&2; exit 1; }'
silent: true
- mkdir -p tmp
- echo probe > tmp/release-probe.txt
- defer: rm -f tmp/release-probe.txt tmp/release-probe.txt.sig
- go run ./cmd/releasesign sign tmp/release-probe.txt
release:keygen:
desc: One-time release key pair. Usage, task release:keygen -- <private key path outside the repository>
cmds:
- cmd: 'test -n "{{.CLI_ARGS}}" || { echo "usage: task release:keygen -- <private key path outside the repository>" >&2; exit 1; }'
silent: true
- go run ./cmd/releasesign keygen {{.CLI_ARGS}}
build:target:
internal: true
vars:
EXT: '{{if eq .GOOS "windows"}}.exe{{end}}'
env:
GOOS: '{{.GOOS}}'
GOARCH: '{{.GOARCH}}'
cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}_{{.VERSION}}_{{.GOOS}}_{{.GOARCH}}{{.EXT}} ./cmd/server
run:
desc: Build and run the production binary locally
deps: [build]
cmds:
- ./bin/{{.BINARY}}{{exeExt}}
docker:build:
desc: Build the Docker image tagged with the git describe version and latest
cmds:
- docker build --build-arg VERSION={{.VERSION}} -t renewcms:{{.VERSION}} -t renewcms:latest .
docker:run:
desc: Run the image once on a named volume, reading docker.env, on http://localhost:8080
cmds:
- docker run --rm --name renewcms -p 8080:8080 --env-file docker.env -e DB_FILE=/data/renewcms.db -e UPLOAD_DIR=/data/uploads -v renewcms-data:/data --stop-timeout 15 renewcms:latest
docker:up:
desc: Build from the local sources and start the Compose service in the background
env:
VERSION: '{{.VERSION}}'
cmds:
- docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
docker:pull:
desc: Pull the published image (RENEWCMS_IMAGE, default the beta tag)
cmds:
- docker compose pull
docker:upgrade:
desc: Pull the published image and recreate the Compose service on it
cmds:
- docker compose pull
- docker compose up -d
docker:down:
desc: Stop and remove the Compose service (the data volume is kept)
cmds:
- docker compose down
docker:logs:
desc: Follow the Compose service logs
cmds:
- docker compose logs -f renewcms
docker:publish:
desc: 'Build and push the multi-arch image to the registry. Usage, task docker:publish [-- --dry-run]'
vars:
REGISTRY_IMAGE:
sh: echo "${REGISTRY_IMAGE-git.floriansylvain.fr/fsylvain/renewcms}"
cmds:
- cmd: |
IMAGE='{{.REGISTRY_IMAGE}}'
V='{{.VERSION}}'
ARGS='{{.CLI_ARGS}}'
fail() { echo "docker:publish refused: $1" >&2; exit 1; }
DRY=no
case "$ARGS" in
"") ;;
"--dry-run") DRY=yes ;;
*) fail "unknown argument '$ARGS' (only --dry-run is accepted)" ;;
esac
[ -n "$IMAGE" ] || fail "REGISTRY_IMAGE is empty"
case "$V" in
v*) ;;
*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>" ;;
esac
R="${V#v}"
CORE="${R%%-*}"
PRE=""
[ "$CORE" = "$R" ] || PRE="${R#*-}"
MAJOR="${CORE%%.*}"
REST="${CORE#*.}"
MINOR="${REST%%.*}"
PATCH="${REST#*.}"
for N in "$MAJOR" "$MINOR" "$PATCH"; do
case "$N" in
""|*[!0-9]*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>" ;;
esac
done
[ "$CORE" = "$MAJOR.$MINOR.$PATCH" ] || fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>"
if [ "$R" != "$CORE" ]; then
case "$PRE" in
""|*[!0-9A-Za-z.-]*) fail "VERSION '$V' has an invalid pre-release part" ;;
esac
PRENAME="${PRE%%[!A-Za-z]*}"
[ -n "$PRENAME" ] || fail "VERSION '$V' pre-release must start with letters, for example -beta or -rc.1"
fi
if [ "$DRY" = no ]; then
[ -z "$(git status --porcelain)" ] || fail "the work tree has uncommitted changes"
[ "$(git describe --tags --exact-match 2>/dev/null)" = "$V" ] || fail "VERSION '$V' is not an exact git tag on HEAD (an override is accepted only with --dry-run)"
fi
TAGS="$V $R"
if [ -n "$PRE" ]; then
TAGS="$TAGS $PRENAME"
else
TAGS="$TAGS $MAJOR.$MINOR latest"
fi
ARGS_TAGS=""
LIST=""
for T in $TAGS; do
ARGS_TAGS="$ARGS_TAGS -t $IMAGE:$T"
LIST="$LIST, $T"
done
echo "image: $IMAGE"
echo "tags: ${LIST#, }"
if [ "$DRY" = yes ]; then
echo "dry run: building for the host platform only, loading locally, pushing nothing"
docker buildx build --load --build-arg VERSION="$V" $ARGS_TAGS .
exit 0
fi
BUILDER="$(docker buildx inspect 2>&1)" || fail "docker buildx is not available or no builder is selected (see docs/docker.md, Maintainer: publishing)"
STORE="$(docker info 2>&1)"
case "$BUILDER" in
*linux/amd64*) ;;
*) fail "the selected buildx builder does not support linux/amd64 (see docs/docker.md, Maintainer: publishing)" ;;
esac
case "$BUILDER" in
*linux/arm64*) ;;
*) fail "the selected buildx builder does not support linux/arm64: enable QEMU/binfmt (see docs/docker.md, Maintainer: publishing)" ;;
esac
case "$BUILDER" in
*"Driver:"*"docker-container"*|*"Driver:"*"kubernetes"*|*"Driver:"*"remote"*) ;;
*)
case "$STORE" in
*io.containerd.snapshotter*) ;;
*) fail "the selected builder uses the plain docker driver without the containerd image store, which cannot build multi-platform images: run 'docker buildx create --use' (see docs/docker.md, Maintainer: publishing)" ;;
esac
;;
esac
docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION="$V" $ARGS_TAGS --push .
silent: true
clean:
desc: Remove build output, the built SPA and tmp/
cmds:
- rm -rf bin tmp {{.DIST_DIR}}
@@ -1,85 +0,0 @@
package gateways
import (
entity "GoCMS/adapters/secondary/gateways/models"
"GoCMS/domain/gateways"
domain "GoCMS/domain/image"
"errors"
"github.com/google/uuid"
"gorm.io/gorm"
"mime/multipart"
"os"
)
type ImageRepository struct {
db *gorm.DB
}
func NewImageRepository(db *gorm.DB) *ImageRepository {
return &ImageRepository{db}
}
var contentTypeExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
func mapImageToDomain(image entity.Image) domain.Image {
return domain.FromDB(
image.ID,
image.Path,
image.PostID,
image.CreatedAt,
image.UpdatedAt,
)
}
func (i ImageRepository) Create(file multipart.File, fileHeader multipart.FileHeader) (domain.Image, error) {
fileBytes := make([]byte, fileHeader.Size)
_, err := file.Read(fileBytes)
if err != nil {
return domain.Image{}, err
}
err = os.MkdirAll("api/static/uploadedImages/", 0666)
if err != nil {
return domain.Image{}, err
}
contentType := fileHeader.Header.Get("Content-Type")
extension := contentTypeExtensions[contentType]
if extension == "" {
return domain.Image{}, errors.New("the file must be a PNG, JPEG, WEBP, or SVG image")
}
newName := uuid.NewString() + extension
err = os.WriteFile("api/static/uploadedImages/"+newName, fileBytes, 0666)
if err != nil {
return domain.Image{}, err
}
newImage := i.db.Create(&domain.Image{Path: "/static/uploadedImages/" + newName})
var createdImage entity.Image
newImage.Scan(&createdImage)
return mapImageToDomain(createdImage), nil
}
func (i ImageRepository) Delete(id uint32) error {
var image entity.Image
err := i.db.Model(&entity.Image{}).First(&image, id).Error
if err != nil {
return err
}
err = os.Remove("api" + image.Path)
if err != nil {
return err
}
return i.db.Delete(&entity.Image{}, id).Error
}
var _ gateways.IImageRepository = &ImageRepository{}
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<body>
<h1>
<img src="{{.Host}}/static/gocms-favicon-128.png" alt="G" style="width: 2.5rem"/>
GoCMS
</h1>
<main>
<p>You or someone tried to create a GoCMS admin account with this e-mail address. If you are not responsible for
this, please do not validate the account creation.</p>
<p><b>If you want to validate the account creation, please <a
href="{{.Host}}/register/validate?c={{.VerificationCode}}" target="_blank">click HERE</a>.</b>
</p>
<p>If you can't click the link:</p>
<p>{{.Host}}/register/validate?c={{.VerificationCode}}</p>
</main>
</body>
</html>
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<body>
<h1>
<img src="{{.Host}}/static/gocms-favicon-128.png" alt="G" style="width: 2.5rem"/>
GoCMS
</h1>
<main>
<p>You or someone tried to create reset your GoCMS account with this e-mail address. If you are not responsible for
this, please do not validate the password reset.</p>
<p><b>If you want to reset your password, please <a
href="{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}" target="_blank">click HERE</a>.</b>
</p>
<p>If you can't click the link:</p>
<p>{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}</p>
</main>
</body>
</html>
@@ -1,59 +0,0 @@
package gateways
import (
"GoCMS/domain/gateways"
"bytes"
"embed"
"gopkg.in/gomail.v2"
"html/template"
"log"
"os"
"strconv"
)
type MailRepository struct{}
//go:embed mail/templates/*
var mailTemplateFiles embed.FS
func (m MailRepository) Send(receiverAddress string, templateName string, data interface{}) error {
from := os.Getenv("SMTP_EMAIL")
password := os.Getenv("SMTP_PASSWORD")
smtpHost := os.Getenv("SMTP_HOST")
smtpPort, _ := strconv.Atoi(os.Getenv("SMTP_PORT"))
d := gomail.NewDialer(smtpHost, smtpPort, from, password)
tmpl, err := template.ParseFS(mailTemplateFiles, "mail/templates/"+templateName+".html")
if err != nil {
log.Println("error when template.ParseFS:", err)
return err
}
var body bytes.Buffer
err = tmpl.Execute(&body, data)
if err != nil {
log.Println("error when tmpl.Execute:", err)
return err
}
msg := gomail.NewMessage()
msg.SetHeaders(map[string][]string{
"From": {"GoCMS <" + from + ">"},
"To": {receiverAddress},
"MIME-version": {"1.0"},
"Content-Type": {"text/html"},
"charset": {"UTF-8"},
"Subject": {"GoCMS | Action required"},
})
msg.SetBody("text/html", body.String())
if err := d.DialAndSend(msg); err != nil {
log.Println("error when sending email:", err)
return err
}
return nil
}
var _ gateways.IMailRepository = &MailRepository{}
@@ -1,15 +0,0 @@
package models
import (
"gorm.io/gorm"
"time"
)
type Image struct {
gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Path string
PostID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -1,20 +0,0 @@
package models
import (
"gorm.io/gorm"
"time"
)
type User struct {
gorm.Model
ID uint32 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"unique;not null"`
Password string `gorm:"not null"`
PasswordResetCode string `gorm:"unique"`
Email string `gorm:"unique;not null"`
IsVerified bool `gorm:"default=false;not null"`
VerificationCode string `gorm:"unique"`
VerificationExpiration time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -1,32 +0,0 @@
package gateways
import (
"GoCMS/domain/gateways"
"bytes"
"embed"
"html/template"
)
type PageRepository struct{}
//go:embed web/templates/*
var webTemplateFiles embed.FS
func NewPageRepository() *PageRepository {
return &PageRepository{}
}
func (p *PageRepository) Get(name string, data interface{}) ([]byte, error) {
var processedHTML bytes.Buffer
tmpl, err := template.ParseFS(webTemplateFiles, "web/templates/"+name+".html")
if err != nil {
return nil, err
}
err = tmpl.Execute(&processedHTML, data)
if err != nil {
return nil, err
}
return processedHTML.Bytes(), nil
}
var _ gateways.IPageRepository = &PageRepository{}
@@ -1,139 +0,0 @@
package gateways
import (
entity "GoCMS/adapters/secondary/gateways/models"
"GoCMS/domain/gateways"
domain "GoCMS/domain/post"
"gorm.io/gorm"
)
type PostRepository struct {
db *gorm.DB
}
func NewPostRepository(db *gorm.DB) *PostRepository {
return &PostRepository{db}
}
func mapPostToDomain(post entity.Post) domain.Post {
return domain.FromDb(post.ID, post.Title, post.Body, post.Images, post.IsOnline, post.CreatedAt, post.UpdatedAt)
}
func (a *PostRepository) Get(id uint32) (domain.Post, error) {
var post entity.Post
err := a.db.Model(&entity.Post{}).Preload("Images").First(&post, id).Error
if err != nil {
return domain.Post{}, err
}
return mapPostToDomain(post), nil
}
func (a *PostRepository) GetByName(name string) (domain.Post, error) {
var post entity.Post
err := a.db.Model(&entity.Post{}).Where("title = ?", name).First(&post).Error
if err != nil {
return domain.Post{}, err
}
return mapPostToDomain(post), nil
}
func (a *PostRepository) Create(post domain.Post) (domain.Post, error) {
creationResult := a.db.Create(&entity.Post{
Title: post.Title,
Body: post.Body,
})
if creationResult.Error != nil {
return domain.Post{}, creationResult.Error
}
var createdPost entity.Post
creationResult.Scan(&createdPost)
return mapPostToDomain(createdPost), nil
}
func (a *PostRepository) GetAll() []domain.Post {
var posts []entity.Post
err := a.db.Model(&entity.Post{}).Find(&posts).Error
if err != nil {
return []domain.Post{}
}
var domainPosts = make([]domain.Post, 0)
for _, post := range posts {
domainPosts = append(domainPosts, mapPostToDomain(post))
}
return domainPosts
}
func (a *PostRepository) UpdateBody(id uint32, body string) (domain.Post, error) {
var localPost entity.Post
err := a.db.Model(&entity.Post{}).First(&localPost, id).Error
if err != nil {
return domain.Post{}, err
}
localPost.Body = body
err = a.db.Save(&localPost).Error
if err != nil {
return domain.Post{}, err
}
newPost := domain.FromDb(
localPost.ID,
localPost.Title,
localPost.Body,
localPost.Images,
localPost.IsOnline,
localPost.CreatedAt,
localPost.UpdatedAt,
)
return newPost, nil
}
func (a *PostRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Post, error) {
var localPost entity.Post
err := a.db.Model(&entity.Post{}).First(&localPost, id).Error
if err != nil {
return domain.Post{}, err
}
localPost.IsOnline = isOnline
err = a.db.Save(&localPost).Error
if err != nil {
return domain.Post{}, err
}
return mapPostToDomain(localPost), nil
}
func (a *PostRepository) Delete(id uint32) error {
return a.db.Delete(&entity.Post{}, id).Error
}
func (a *PostRepository) AddImage(postId uint32, imageId uint32) error {
var localPost entity.Post
err := a.db.Model(&entity.Post{}).First(&localPost, postId).Error
if err != nil {
return err
}
var localImage entity.Image
err = a.db.Model(&entity.Image{}).First(&localImage, imageId).Error
if err != nil {
return err
}
err = a.db.Model(&localPost).Association("Images").Append(&localImage)
if err != nil {
return err
}
return nil
}
var _ gateways.IPostRepository = &PostRepository{}
@@ -1,152 +0,0 @@
package gateways
import (
entity "GoCMS/adapters/secondary/gateways/models"
"GoCMS/domain/gateways"
"GoCMS/domain/user"
domain "GoCMS/domain/user"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db}
}
func mapUserToDomain(user entity.User) domain.User {
return domain.FromDb(
user.ID,
user.Username,
user.Password,
user.PasswordResetCode,
user.Email,
user.IsVerified,
user.VerificationCode,
user.VerificationExpiration,
user.CreatedAt, user.UpdatedAt,
)
}
func (u *UserRepository) Get(id uint32) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, id).Error
if err != nil {
return domain.User{}, err
}
return mapUserToDomain(localUser), nil
}
func (u *UserRepository) Create(user domain.User) (domain.User, error) {
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(user.Password), 12)
hashedVerificationCode, _ := bcrypt.GenerateFromPassword([]byte(user.VerificationCode), 12)
creationResult := u.db.Create(&entity.User{
Username: user.Username,
Password: string(hashedPassword),
Email: user.Email,
VerificationCode: string(hashedVerificationCode),
VerificationExpiration: user.VerificationExpiration,
})
if creationResult.Error != nil {
return domain.User{}, creationResult.Error
}
var createdUser entity.User
creationResult.Scan(&createdUser)
return mapUserToDomain(createdUser),
nil
}
func (u *UserRepository) Delete(id uint32) error {
return u.db.Delete(&user.User{}, id).Error
}
func (u *UserRepository) GetAll() []domain.User {
var users []entity.User
u.db.Model(&entity.User{}).Find(&users)
var domainUsers []domain.User
for _, localUser := range users {
domainUsers = append(domainUsers, mapUserToDomain(localUser))
}
return domainUsers
}
func (u *UserRepository) GetByUsername(username string) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).Where("username = ?", username).First(&localUser).Error
if err != nil {
return domain.User{}, err
}
return mapUserToDomain(localUser), nil
}
func (u *UserRepository) GetByEmail(email string) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).Where("email = ?", email).First(&localUser).Error
if err != nil {
return domain.User{}, err
}
return mapUserToDomain(localUser), nil
}
func (u *UserRepository) UpdateVerificationStatus(userId uint32, isVerified bool) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domain.User{}, err
}
localUser.IsVerified = isVerified
err = u.db.Save(&localUser).Error
if err != nil {
return domain.User{}, err
}
return mapUserToDomain(localUser), nil
}
func (u *UserRepository) UpdatePassword(userId uint32, password string) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domain.User{}, err
}
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), 12)
localUser.Password = string(hashedPassword)
err = u.db.Save(&localUser).Error
if err != nil {
return domain.User{}, err
}
return mapUserToDomain(localUser), nil
}
func (u *UserRepository) UpdatePasswordResetCode(userId uint32, code string) (domain.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
if err != nil {
return domain.User{}, err
}
hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), 12)
localUser.PasswordResetCode = string(hashedCode)
err = u.db.Save(&localUser).Error
if err != nil {
return domain.User{}, err
}
return mapUserToDomain(localUser), nil
}
var _ gateways.IUserRepository = &UserRepository{}
@@ -1,35 +0,0 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container">
<div class="d-flex gap-2 justify-content-center align-items-center">
<img src="/static/gocms-favicon-128.png" alt="Logo" style="width: 2.5rem"/>
<a class="navbar-brand h1 my-auto" href="https://github.com/Floriansylvain/GoCMS" target="_blank">
GoCMS
</a>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/home">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/post">Posts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/integration">Integration</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link text-body-tertiary text-decoration-underline" href="https://www.paypal.com/donate/?hosted_button_id=S6LUFUYK84CYY">support me!</a>
</li>
<li class="nav-item">
<a class="nav-link disconnect-link" href="/logout">Log out</a>
</li>
</ul>
</div>
</div>
</nav>
@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Home</title>
{{.Head}}
</head>
<body class="text-dark">
{{.Navbar}}
<div class="container mt-3 text-black-50">
<h1>Accueil</h1>
<p>Welcome on GoCMS !</p>
<a href="/post">
<button class="btn btn-outline-primary">Posts &raquo;</button>
</a>
</div>
</body>
</html>
@@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Home</title>
{{.Head}}
</head>
<body class="text-dark">
{{.Navbar}}
<div class="container mt-3 text-black-50">
<h1>Implementation</h1>
<p>How to use headless API</p>
<div class="mt-5">
<section>
<h2>Introduction</h2>
<p>This documentation explains how to integrate our headless CMS API to retrieve post data via the <code>/post</code>
route.</p>
</section>
<section>
<h2>/v1/post</h2>
<p>The <code>{{ .Host }}/v1/post</code> route returns a list of posts in JSON format. Each post has the
following
structure:</p>
<pre>
<code>[
{
"id": 12,
"title": "This is a post!",
"body": "&lt;p&gt;?&lt;/p&gt;\r\n&lt;p&gt;&lt;img src=\"../../static/uploadedImages/649691a8-4f2e-48ca-9abe-27a60621d2e4.png\"
alt=\"\" width=\"391\" height=\"465\"&gt;&lt;/p&gt;",
"created_at": "2024-06-13T15:30:55.3563238+02:00",
"updated_at": "2024-06-13T15:31:17.5508931+02:00"
}
]</code>
</pre>
</section>
<section>
<h2>/v1/post/{id}</h2>
<p>The <code>{{ .Host }}/v1/post/{id}</code> route returns a single post in JSON format. The post has the
following
structure:</p>
<pre>
<code>{
"id": 12,
"title": "ravioli sans le ra et le i",
"body": "<\p>?<\/p>\r\n<\p><\img src=\"../../static/uploadedImages/649691a8-4f2e-48ca-9abe-27a60621d2e4.png\" alt=\"\"
width=\"391\" height=\"465\"><\/p>",
"images": [
{
"id": 17,
"path": "/static/uploadedImages/649691a8-4f2e-48ca-9abe-27a60621d2e4.png",
"post_id": 0,
"created_at": "2024-06-13T15:31:09.728359+02:00",
"updated_at": "2024-06-13T15:31:09.728359+02:00"
}
],
"created_at": "2024-06-13T15:30:55.3563238+02:00",
"updated_at": "2024-06-13T15:31:17.5508931+02:00"
}</code>
</pre>
</section>
</div>
</div>
</body>
</html>
@@ -1,108 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Login</title>
{{.Head}}
<style>
.form-container {
max-width: 24rem;
}
</style>
</head>
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
<div class="container">
<div class="d-flex flex-column m-auto form-container">
<div>
<h1>GoCMS</h1>
<h2>Login</h2>
</div>
<form action="login" method="POST" class="mt-5" id="loginForm">
<div class="d-flex flex-column gap-4">
<div class="form-floating">
<input class="form-control {{ if .PageError.IsError }} is-invalid {{ end }}"
id="username"
name="username"
placeholder="Username"
required
type="text"
value="{{.Username}}">
<label for="username">Username</label>
</div>
<div class="form-floating">
<input class="form-control {{ if .PageError.IsError }} is-invalid {{ end }}"
id="password"
name="password"
placeholder="Password"
required
type="password">
<label for="password">Password</label>
<div class="invalid-feedback">{{.PageError.Message}}</div>
</div>
<button id="loginFormButton" class="btn btn-primary" disabled type="submit">
<span class="loginFormButtonLoading visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span class="loginFormButtonDefault">Log in</span>
</button>
</div>
</form>
<div class="d-flex gap-2 my-2 w-100">
<a href="register" class="btn btn-outline-secondary w-100" type="submit">
First setup? Register
</a>
<a href="register/reset/request" class="btn btn-outline-secondary w-100" type="submit">
Forgot password
</a>
</div>
{{ if .Success }}
<div class="alert alert-success my-4" role="alert">
{{ .Success }}
</div>
{{ else if .Failure }}
<div class="alert alert-danger my-4" role="alert">
{{ .Failure }}
</div>
{{ end }}
</div>
</div>
<script>
const button = document.querySelector("#loginFormButton")
const inputs = document.querySelectorAll('input')
const form = document.querySelector("#loginForm")
function formFieldsEmpty() {
return Array.from(inputs).some((input) => input.value === "")
}
function isFormValid() {
return !formFieldsEmpty() && validateEmail()
}
function setButtonDisabled() {
button.disabled = isFormValid() ? "disabled" : ""
}
function setButtonLoading() {
button.classList.add("disabled")
button.querySelector(".loginFormButtonDefault").classList.add("visually-hidden")
button.querySelector(".loginFormButtonLoading").classList.remove("visually-hidden")
}
function onLoginFormSubmit(event) {
event.preventDefault()
setButtonLoading()
event.target.submit()
}
function onInput(event) {
if (event.target.tagName === "INPUT") setButtonDisabled()
}
form.addEventListener('submit', onLoginFormSubmit)
window.addEventListener('input', onInput)
setButtonDisabled()
</script>
</body>
</html>
@@ -1,98 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Password reset</title>
{{.Head}}
<style>
.form-container {
max-width: 24rem;
}
</style>
</head>
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
<div class="container">
<div class="d-flex flex-column m-auto form-container">
<div>
<h1>GoCMS</h1>
<h2>Admin account password reset</h2>
</div>
<form action="request" method="POST" class="mt-5" id="registerForm">
<div class="d-flex flex-column gap-4">
<div class="form-floating">
<input class="form-control {{ if .PageError.Email }} is-invalid {{ end }}"
id="email"
name="email"
placeholder="E-mail"
required
type="email"
value="{{.Email}}"
onblur="validateEmail()"
{{ if .Email }} disabled {{ end }}>
<label for="email">E-mail</label>
</div>
<button id="registerFormButton" class="btn btn-primary" disabled type="submit">
<span class="registerFormButtonLoading visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span class="registerFormButtonDefault">Send e-mail</span>
</button>
</div>
</form>
{{ if .Success }}
<div class="alert alert-success my-4" role="alert">
{{ .Success }}
</div>
{{ end }}
</div>
</div>
<script>
const docElems = {
form: document.querySelector("#registerForm"),
button: document.querySelector("#registerFormButton"),
inputs: document.querySelectorAll("input"),
email: document.querySelector("#email"),
}
function formSomeFieldsEmpty() {
return Array.from(docElems.inputs).some((input) => input.value === "")
}
function setButtonLoading() {
docElems.button.classList.add("disabled")
docElems.button.querySelector(".registerFormButtonDefault").classList.add("visually-hidden")
docElems.button.querySelector(".registerFormButtonLoading").classList.remove("visually-hidden")
}
function onRegisterFormSubmit(event) {
event.preventDefault()
setButtonLoading()
if (formSomeFieldsEmpty()) return
event.target.submit()
}
function validateEmail() {
const email = docElems.email.value.trim()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
docElems.email.classList.add("is-invalid")
return false
} else {
docElems.email.classList.remove("is-invalid")
return true
}
}
function onInput(event) {
if (event.target.tagName === "INPUT") {
docElems.button.disabled = !validateEmail() || formSomeFieldsEmpty()
}
}
docElems.form.addEventListener('submit', onRegisterFormSubmit)
window.addEventListener('input', onInput)
</script>
</body>
</html>
@@ -1,140 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Password reset</title>
{{.Head}}
<style>
.form-container {
max-width: 24rem;
}
</style>
</head>
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
<div class="container">
<div class="d-flex flex-column m-auto form-container">
<div>
<h1>GoCMS</h1>
<h2>Admin account password reset</h2>
</div>
<form action="validate" method="POST" class="mt-5" id="registerForm">
<div class="d-flex flex-column gap-4">
<div class="form-floating">
<input class="form-control"
id="code"
name="code"
placeholder="Code"
type="text"
value="{{.Code}}"
readonly>
<label for="code">Code</label>
</div>
<div class="form-floating">
<input class="form-control"
id="email"
name="email"
placeholder="E-mail"
type="email"
value="{{.Email}}"
readonly>
<label for="email">E-mail</label>
</div>
<div class="form-floating">
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
id="password"
name="password"
placeholder="Password"
required
type="password">
<label for="password">Password</label>
<div class="invalid-feedback">The password must contain at least 8 characters.</div>
</div>
<div class="form-floating">
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
id="confirmPassword"
name="confirmPassword"
placeholder="Password confirmation"
required
type="password"
onblur="validatePasswordMatch()"
oninput="validatePasswordMatch()">
<label for="confirmPassword">Password confirmation</label>
<div class="invalid-feedback">The passwords do not match.</div>
</div>
<button id="registerFormButton" class="btn btn-primary" disabled type="submit">
<span class="registerFormButtonLoading visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span class="registerFormButtonDefault">Confirm changes</span>
</button>
</div>
</form>
{{ if .Error.Message }}
<div class="alert alert-danger my-4" role="alert">
{{ .Error.Message }}
</div>
{{ end }}
</div>
</div>
<script>
const docElems = {
form: document.querySelector("#registerForm"),
button: document.querySelector("#registerFormButton"),
inputs: document.querySelectorAll("input"),
email: document.querySelector("#email"),
password: document.querySelector("#password"),
confirmPassword: document.querySelector("#confirmPassword"),
}
function formSomeFieldsEmpty() {
return Array.from(docElems.inputs).some((input) => input.value === "")
}
function setButtonLoading() {
docElems.button.classList.add("disabled")
docElems.button.querySelector(".registerFormButtonDefault").classList.add("visually-hidden")
docElems.button.querySelector(".registerFormButtonLoading").classList.remove("visually-hidden")
}
function onRegisterFormSubmit(event) {
event.preventDefault()
setButtonLoading()
if (formSomeFieldsEmpty()) return
event.target.submit()
}
function validateEmail() {
const email = docElems.email.value.trim()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
docElems.email.classList.add("is-invalid")
return false
} else {
docElems.email.classList.remove("is-invalid")
return true
}
}
function onInput(event) {
if (event.target.tagName === "INPUT") {
docElems.button.disabled = !validateEmail() || formSomeFieldsEmpty()
}
}
function validatePasswordMatch() {
if (docElems.password.value !== docElems.confirmPassword.value) {
docElems.confirmPassword.classList.add("is-invalid")
return false
} else {
docElems.confirmPassword.classList.remove("is-invalid")
return true
}
}
docElems.form.addEventListener('submit', onRegisterFormSubmit)
window.addEventListener('input', onInput)
</script>
</body>
</html>
@@ -1,66 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Create post</title>
{{.Head}}
<style>
form {
max-width: 24rem;
}
</style>
</head>
<body class="text-dark">
{{.Navbar}}
<div class="container mt-3 text-black-50">
<h1>Post - creation</h1>
<p>Create a new post</p>
<form action="create" method="post" class="d-flex align-items-center flex-column gap-3" id="createPostForm">
<div class="form-floating w-100">
<input type="text" class="form-control {{ if .PageError.IsError }} is-invalid {{ end }}" id="name"
name="name" placeholder="Chose the post name" value="{{ .Name }}">
<label for="name">Name</label>
<div class="invalid-feedback">{{ .PageError.Message }}</div>
</div>
<button id="createPostButton" class="btn btn-primary w-100" disabled type="submit">
<span class="createPostFormButtonLoading visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span class="createPostFormButtonDefault">Create</span>
</button>
</form>
</div>
<script>
const button = document.querySelector("#createPostButton")
const inputs = document.querySelectorAll('input')
const form = document.querySelector("#createPostForm")
function formFieldsEmpty() {
return Array.from(inputs).some((input) => input.value === "")
}
function setButtonDisabled() {
button.disabled = formFieldsEmpty() ? "disabled" : ""
}
function setButtonLoading() {
button.classList.add("disabled")
button.querySelector(".createPostFormButtonDefault").classList.add("visually-hidden")
button.querySelector(".createPostFormButtonLoading").classList.remove("visually-hidden")
}
function onCreatePostFormSubmit(event) {
setButtonLoading()
event.target.submit()
}
function onInput(event) {
if (event.target.tagName === "INPUT") setButtonDisabled()
}
form.addEventListener('submit', onCreatePostFormSubmit)
window.addEventListener('input', onInput)
setButtonDisabled()
</script>
</body>
</html>
@@ -1,94 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Post edition</title>
{{.Head}}
</head>
<body class="text-dark vh-100 d-flex flex-column">
{{.Navbar}}
<form class="container mt-3 text-black-50 d-flex flex-column h-100" action="edit" method="post">
<div class="d-flex align-items-center justify-content-between">
<div>
<h1>Post - edition</h1>
<label for="postBody">Edition</label>
</div>
{{ if .Alert.Message }}
<div class="alert {{ if .Alert.IsError }} alert-danger {{ else }} alert-success {{ end }} alert-dismissible"
role="alert">
{{ .Alert.Message }}
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="alert"></button>
</div>
{{ end }}
<div class="d-flex justify-content-center align-items-center">
<button class="btn btn-success d-flex justify-content-center align-items-center gap-1" style="fill: white;"
type="submit">
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<path d="M840-680v480q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h480l160 160Zm-80 34L646-760H200v560h560v-446ZM480-240q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35ZM240-560h360v-160H240v160Zm-40-86v446-560 114Z"/>
</svg>
Save
</button>
</div>
</div>
<div class="text-black-50 d-flex align-items-center justify-content-center h-100 py-3">
<textarea id="postBody" name="postBody">{{.Post.Body}}</textarea>
</div>
</form>
<script src="/static/tinymce/js/tinymce/tinymce.min.js"></script>
<script>
tinymce.init({
selector: '#postBody',
promotion: false,
plugins: 'image lists visualblocks',
toolbar: 'undo redo | formatselect | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist | outdent indent | removeformat | image',
width: '100%',
height: '100%',
setup: function (editor) {
editor.on('init', function () {
console.log("LOADED")
});
},
license_key: 'gpl',
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', "/post/{{.Post.ID}}/image/create");
xhr.upload.onprogress = (e) => {
progress(e.loaded / e.total * 100);
};
xhr.onload = () => {
if (xhr.status === 403) {
reject({message: 'HTTP Error: ' + xhr.status, remove: true});
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject('HTTP Error: ' + xhr.status);
return;
}
const json = JSON.parse(xhr.responseText);
if (!json || typeof json.location != 'string') {
reject('Invalid JSON: ' + xhr.responseText);
return;
}
resolve(`http{{ if .Secured }}s{{ end }}://${window.location.host}${json.location}`);
};
xhr.onerror = () => {
reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
};
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
xhr.send(formData);
}),
})
</script>
</body>
</html>
@@ -1,112 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Posts</title>
{{.Head}}
<style>
.btn-outline-info:hover > *,
.btn-outline-danger:hover > * {
fill: #fff !important;
transition: fill ease-in-out 50ms;
}
</style>
</head>
<body class="text-dark">
{{.Navbar}}
<div class="container mt-3 text-black-50">
<h1>Posts</h1>
<p>List of your posts</p>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Creation Date</th>
<th scope="col">Edition Date</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-center">
<form action="post/create" method="get">
<button class="btn btn-sm btn-link w-100 h-100" type="submit">Create a new post...</button>
</form>
</td>
</tr>
{{ range $post := .Posts }}
<tr>
<td>{{ $post.Title }}</td>
<td class="date">{{ $post.CreatedAt }}</td>
<td class="date">{{ $post.UpdatedAt }}</td>
<td>{{ if $post.IsOnline }}🟢 Online{{ else }}🟠 Offline{{ end }}</td>
<td>
<a href="/post/{{ $post.ID }}/edit" class="btn btn-outline-primary btn-sm">
<svg width="12px" height="12px" fill="currentColor">
<use xlink:href="/static/bootstrap-icons.svg#pen"/>
</svg>
</a>
{{ if $post.IsOnline }}
<a href="/post/{{ $post.ID }}/unpublish" class="btn btn-outline-info btn-sm">
<svg width="12px" height="12px" fill="currentColor">
<use xlink:href="/static/bootstrap-icons.svg#eye-slash"/>
</svg>
</a>
{{ else }}
<a href="/post/{{ $post.ID }}/publish" class="btn btn-outline-info btn-sm">
<svg width="12px" height="12px" fill="currentColor">
<use xlink:href="/static/bootstrap-icons.svg#eye"/>
</svg>
</a>
{{ end }}
<a href="/post/{{ $post.Title }}/delete" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal"
data-bs-target="#{{ $post.ID }}">
<svg width=" 12px" height="12px" fill="currentColor">
<use xlink:href="/static/bootstrap-icons.svg#trash"/>
</svg>
</a>
<div class="modal fade" id="{{ $post.ID }}" tabindex="-1" aria-labelledby="{{ $post.ID }}Label"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="{{ $post.ID }}Label">Delete post</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<p>You are about to delete the post <b>« {{ $post.Title }} »</b>, this is definitive,
are you sure?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel
</button>
<a href="/post/{{$post.ID}}/delete" class="btn btn-danger">Delete</a>
</div>
</div>
</div>
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
<script>
const datesElements = document.querySelectorAll("td.date")
datesElements.forEach(date => {
date.innerText = new Date(date.innerText).toLocaleDateString(undefined, {
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
})
</script>
</body>
</html>
@@ -1,147 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Setup</title>
{{.Head}}
<style>
.form-container {
max-width: 24rem;
}
</style>
</head>
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
<div class="container">
<div class="d-flex flex-column m-auto form-container">
<div>
<h1>GoCMS</h1>
<h2>Admin account creation</h2>
</div>
<form action="register" method="POST" class="mt-5" id="registerForm">
<div class="d-flex flex-column gap-4">
<div class="form-floating">
<input class="form-control {{ if .PageError.Email }} is-invalid {{ end }}"
id="email"
name="email"
placeholder="E-mail"
required
type="email"
value="{{.Email}}"
onblur="validateEmail()">
<label for="email">E-mail</label>
<div class="invalid-feedback">The e-mail must be a valid e-mail and not already in use.</div>
</div>
<div class="form-floating">
<input class="form-control {{ if .PageError.Username }} is-invalid {{ end }}"
id="username"
name="username"
placeholder="Username"
required
type="text"
value="{{.Username}}">
<label for="username">Username</label>
<div class="invalid-feedback">The username must be between 3 and 20 characters long.</div>
</div>
<div class="form-floating">
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
id="password"
name="password"
placeholder="Password"
required
type="password">
<label for="password">Password</label>
<div class="invalid-feedback">The password must contain at least 8 characters.</div>
</div>
<div class="form-floating">
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
id="confirmPassword"
name="confirmPassword"
placeholder="Password confirmation"
required
type="password"
onblur="validatePasswordMatch()"
oninput="validatePasswordMatch()">
<label for="confirmPassword">Password confirmation</label>
<div class="invalid-feedback">The passwords do not match.</div>
</div>
<button id="registerFormButton" class="btn btn-primary" disabled type="submit">
<span class="registerFormButtonLoading visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span class="registerFormButtonDefault">Next</span>
</button>
</div>
</form>
<form action="login" method="get" class="mt-2 w-100">
<button class="btn btn-outline-primary w-100" type="submit">
Already registered? Log in!
</button>
</form>
</div>
</div>
<script>
const docElems = {
form: document.querySelector("#registerForm"),
button: document.querySelector("#registerFormButton"),
inputs: document.querySelectorAll("input"),
email: document.querySelector("#email"),
password: document.querySelector("#password"),
confirmPassword: document.querySelector("#confirmPassword"),
}
function formSomeFieldsEmpty() {
return Array.from(docElems.inputs).some((input) => input.value === "")
}
function isFormValid() {
return !formSomeFieldsEmpty() && validateEmail() && validatePasswordMatch()
}
function setButtonLoading() {
docElems.button.classList.add("disabled")
docElems.button.querySelector(".registerFormButtonDefault").classList.add("visually-hidden")
docElems.button.querySelector(".registerFormButtonLoading").classList.remove("visually-hidden")
}
function onRegisterFormSubmit(event) {
event.preventDefault()
setButtonLoading()
if (!isFormValid()) return
event.target.submit()
}
function onInput(event) {
if (event.target.tagName === "INPUT") {
docElems.button.disabled = !isFormValid()
}
}
function validateEmail() {
const email = docElems.email.value.trim()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
docElems.email.classList.add("is-invalid")
return false
} else {
docElems.email.classList.remove("is-invalid")
return true
}
}
function validatePasswordMatch() {
if (docElems.password.value !== docElems.confirmPassword.value) {
docElems.confirmPassword.classList.add("is-invalid")
return false
} else {
docElems.confirmPassword.classList.remove("is-invalid")
return true
}
}
docElems.form.addEventListener('submit', onRegisterFormSubmit)
window.addEventListener('input', onInput)
</script>
</body>
</html>
@@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Setup</title>
{{.Head}}
<style>
.form-container {
max-width: 24rem;
}
</style>
</head>
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
<div class="container">
<div class="d-flex flex-column m-auto form-container">
<div>
<h1>GoCMS</h1>
<h2>Verify your email</h2>
</div>
<p>An e-mail with the validation link was sent to the address you just registered.</p>
<p>If you misspelled the address or just didn't receive any e-mails, please check your spams or cancel the
account creation.</p>
<form action="" method="post" class="d-flex">
<button class="btn btn-outline-primary w-100" type="submit">
<span class="visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span>Cancel</span>
</button>
</form>
</div>
</div>
<script>
const button = document.querySelector("button")
function setButtonLoading() {
button.classList.add("disabled")
button.querySelector("button > span + span").classList.add("visually-hidden")
button.querySelector("button > span:first-child").classList.remove("visually-hidden")
}
window.addEventListener('submit', setButtonLoading)
button.addEventListener('click', setButtonLoading)
</script>
</body>
</html>
@@ -1,49 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoCMS | Setup</title>
{{.Head}}
<style>
.form-container {
max-width: 24rem;
}
</style>
</head>
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
<div class="container">
<div class="d-flex flex-column gap-5 m-auto form-container">
<div>
<h1>GoCMS</h1>
<h2>E-mail verification</h2>
</div>
{{ if .PageError.IsError }}
<p>{{.PageError.Message}}</p>
{{ else }}
<p>Your e-mail was successfully validated!</p>
<form action="../home" method="get" class="d-flex">
<button class="btn btn-primary w-100" type="submit">
<span class="visually-hidden spinner-border spinner-border-sm"
role="status"></span>
<span>Continue</span>
</button>
</form>
{{ end }}
</div>
</div>
<script>
const button = document.querySelector("button")
function setButtonLoading() {
button.classList.add("disabled")
button.querySelector("button > span + span").classList.add("visually-hidden")
button.querySelector("button > span:first-child").classList.remove("visually-hidden")
}
window.addEventListener('submit', setButtonLoading)
button.addEventListener('click', setButtonLoading)
</script>
</body>
</html>
@@ -1,7 +0,0 @@
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="stylesheet" href="/static/bootstrap.min.css" type="text/css">
<script src="/static/bootstrap.min.js"
type="application/javascript"
defer></script>
-201
View File
@@ -1,201 +0,0 @@
package auth
import (
"GoCMS/api"
"GoCMS/domain/user"
"GoCMS/useCases"
"encoding/json"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type RegisterCredentials struct {
Username string `json:"username" validate:"required,min=3,max=20"`
Password string `json:"password" validate:"required,min=8"`
Email string `json:"email" validate:"required,email"`
}
type LoginCredentials struct {
Username string `json:"username" validate:"required,min=3,max=20"`
Password string `json:"password" validate:"required,min=8"`
}
var shouldCookieBeSecure = os.Getenv("ENVIRONMENT") == "production"
var Token *jwtauth.JWTAuth
// TODO Move into its own file or package that handles api errors
const LogsErrorMessage = "Access to the requested resource is forbidden due to incorrect password and/or username."
const BodyErrorMessage = "The request cannot be processed due to a mismatch in the format of the body."
func SetJwtCookie(w *http.ResponseWriter, userId uint32) error {
_, tokenString, err := Token.Encode(map[string]interface{}{"user_id": userId})
if err != nil {
return err
}
http.SetCookie(*w, &http.Cookie{
Name: "jwt",
Value: tokenString,
Expires: time.Now().Add(24 * time.Hour),
Secure: shouldCookieBeSecure,
HttpOnly: true,
Path: "/",
})
return nil
}
func SomeUsersVerified() bool {
users := api.Container.ListUsersUseCase.ListUsers()
for _, localUser := range users {
if localUser.IsVerified {
return true
}
}
return false
}
func IsUserTableEmpty() bool {
users := api.Container.ListUsersUseCase.ListUsers()
return len(users) == 0
}
func IsLoggedIn(r *http.Request) bool {
token, err := jwtauth.VerifyRequest(
Token,
r,
jwtauth.TokenFromCookie,
jwtauth.TokenFromHeader,
jwtauth.TokenFromQuery)
return token != nil && err == nil
}
func IsVerified(r *http.Request) bool {
token, err := jwtauth.VerifyRequest(
Token,
r,
jwtauth.TokenFromCookie,
jwtauth.TokenFromHeader,
jwtauth.TokenFromQuery)
if err != nil {
return false
}
userId := token.PrivateClaims()["user_id"].(float64)
currentUser, _ := api.Container.GetUserUseCase.GetUser(uint32(userId))
return currentUser.IsVerified
}
func GetUserFromCredentials(credentials LoginCredentials) (user.User, error) {
dbUser, err := api.Container.GetUserUseCase.GetUserByUsername(credentials.Username)
if err != nil {
return user.User{}, err
}
err = bcrypt.CompareHashAndPassword([]byte(dbUser.Password), []byte(credentials.Password))
if err != nil {
return user.User{}, err
}
return dbUser, nil
}
func GetNewUser(newUserCredentials RegisterCredentials, verificationCode string) (user.User, error) {
createdUser, err := api.Container.CreateUserUseCase.CreateUser(useCases.CreateUserCommand{
Username: newUserCredentials.Username,
Password: newUserCredentials.Password,
Email: newUserCredentials.Email,
VerificationCode: verificationCode,
})
if err != nil {
return user.User{}, err
}
return createdUser, nil
}
func login(w http.ResponseWriter, r *http.Request) {
var credentials LoginCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, BodyErrorMessage, http.StatusBadRequest)
return
}
err = api.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
dbUser, err := GetUserFromCredentials(credentials)
if err != nil {
http.Error(w, LogsErrorMessage, http.StatusForbidden)
}
_ = SetJwtCookie(&w, dbUser.ID)
message, _ := json.Marshal(map[string]interface{}{"message": "User logged in! HTTPonly jwt cookie created"})
_, _ = w.Write(message)
}
func register(w http.ResponseWriter, r *http.Request) {
if !IsLoggedIn(r) && !SomeUsersVerified() {
http.Error(w, "You are not allowed to create a user. Log in or reset database.", http.StatusForbidden)
return
}
var credentials RegisterCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, BodyErrorMessage, http.StatusBadRequest)
return
}
err = api.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
verificationCode := uuid.NewString()
createdUser, err := GetNewUser(credentials, verificationCode)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = SetJwtCookie(&w, createdUser.ID)
message, _ := json.Marshal(map[string]interface{}{"message": "User registered! HTTPonly jwt cookie created"})
_, _ = w.Write(message)
}
func RemoveJwtCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: shouldCookieBeSecure,
HttpOnly: true,
Path: "/",
})
}
func logout(w http.ResponseWriter, _ *http.Request) {
RemoveJwtCookie(w)
message, _ := json.Marshal(map[string]interface{}{"message": "User logged out! HTTPonly jwt cookie deleted"})
_, _ = w.Write(message)
}
func NewAuthRouter() http.Handler {
r := chi.NewRouter()
r.Post("/register", register)
r.Post("/login", login)
r.Post("/logout", logout)
return r
}
-40
View File
@@ -1,40 +0,0 @@
package image
import (
"GoCMS/api"
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
func PostImage(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
id := chi.URLParam(r, "id")
idInt, _ := strconv.Atoi(id)
file, fileHeader, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newImage, err := api.Container.CreateImageUseCase.CreateImage(file, *fileHeader)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = api.Container.UpdatePostUseCase.AddImage(uint32(idInt), newImage.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
newJson := map[string]interface{}{"location": newImage.Path}
newJsonBytes, _ := json.Marshal(newJson)
_, _ = w.Write(newJsonBytes)
}
-16
View File
@@ -1,16 +0,0 @@
package pages
import (
"GoCMS/api"
"html/template"
"net/http"
)
func GetHomePage(w http.ResponseWriter, _ *http.Request) {
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
homeTmpl, _ := api.Container.GetPageUseCase.GetPage("home", map[string]interface{}{
"Navbar": template.HTML(navbarTmpl),
"Head": headTmpl,
})
_, _ = w.Write(homeTmpl)
}
-18
View File
@@ -1,18 +0,0 @@
package pages
import (
"GoCMS/api"
"html/template"
"net/http"
"os"
)
func GetPageIntegration(w http.ResponseWriter, r *http.Request) {
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
templ, _ := api.Container.GetPageUseCase.GetPage("integration", map[string]interface{}{
"Navbar": template.HTML(navbarTmpl),
"Head": headTmpl,
"Host": os.Getenv("HOST"),
})
_, _ = w.Write(templ)
}
-79
View File
@@ -1,79 +0,0 @@
package pages
import (
"GoCMS/api"
"GoCMS/api/controllers/auth"
"net/http"
"net/url"
)
const LoginRoute = "/login"
type LoginPage struct {
PageError *PageError `json:"error"`
Username string `json:"username"`
}
var EmptyLoginPage = &LoginPage{
PageError: NewPageError(""),
Username: "",
}
func GetLoginPageHandler(loginPage *LoginPage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if auth.IsLoggedIn(r) {
http.Redirect(w, r, "/home", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
PostLoginPage(w, r)
return
}
if auth.IsUserTableEmpty() {
http.Redirect(w, r, "/register", http.StatusSeeOther)
return
}
success, _ := url.QueryUnescape(r.URL.Query().Get("success"))
failure, _ := url.QueryUnescape(r.URL.Query().Get("failure"))
bs, _ := api.Container.GetPageUseCase.GetPage("login", map[string]interface{}{
"PageError": loginPage.PageError,
"Username": loginPage.Username,
"Head": headTmpl,
"Success": success,
"Failure": failure,
})
_, _ = w.Write(bs)
}
}
func PostLoginPage(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
credentials := auth.LoginCredentials{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
}
err := api.Validate.Struct(credentials)
if err != nil {
r.Method = http.MethodGet
GetLoginPageHandler(&LoginPage{
PageError: NewPageError("Invalid username or password format."),
Username: r.FormValue("username"),
})(w, r)
return
}
dbUser, err := auth.GetUserFromCredentials(credentials)
if err != nil {
r.Method = http.MethodGet
GetLoginPageHandler(&LoginPage{
PageError: NewPageError("Invalid username or password combination."),
Username: r.FormValue("username"),
})(w, r)
return
}
_ = auth.SetJwtCookie(&w, dbUser.ID)
http.Redirect(w, r, "/home", http.StatusSeeOther)
}
-162
View File
@@ -1,162 +0,0 @@
package pages
import (
"GoCMS/api"
"GoCMS/api/controllers/auth"
"GoCMS/api/controllers/image"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
)
var headTmpl template.HTML
var contentTypes = map[string]string{
".css": "text/css",
".js": "application/javascript",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
}
type PageError struct {
Message string `json:"message"`
IsError bool `json:"isError"`
}
func NewPageError(message string) *PageError {
return &PageError{
Message: message,
IsError: strings.Compare(message, "") != 0,
}
}
func IsLoggedInMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !auth.IsLoggedIn(r) {
http.Redirect(w, r, LoginRoute, http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func IsVerifiedMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !auth.IsVerified(r) {
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func IsNotVerifiedMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth.IsVerified(r) {
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func GetLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, LoginRoute, http.StatusPermanentRedirect)
}
func GetLogout(w http.ResponseWriter, r *http.Request) {
auth.RemoveJwtCookie(w)
http.Redirect(w, r, LoginRoute, http.StatusSeeOther)
}
func StaticFileServerWithContentType(fsys http.FileSystem) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
filePath := filepath.Join("api/static", r.URL.Path)
fileInfo, err := os.Stat(filePath)
if err == nil && fileInfo.IsDir() {
http.Error(w, "Folder direct access is disabled.", http.StatusForbidden)
return
}
if ext := filepath.Ext(path); ext != "" {
if ct, ok := contentTypes[ext]; ok {
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("Content-Type", ct)
}
}
http.FileServer(fsys).ServeHTTP(w, r)
})
}
func InitHeadTmpl() {
headTmplHtml, _ := api.Container.GetPageUseCase.GetPage("utilsHead", nil)
headTmpl = template.HTML(headTmplHtml)
}
func NewPageRouter() http.Handler {
r := chi.NewRouter()
InitHeadTmpl()
fileServer := StaticFileServerWithContentType(http.Dir("api/static"))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
r.Get("/", GetLogin)
r.Get(LoginRoute, GetLoginPageHandler(EmptyLoginPage))
r.Post(LoginRoute, GetLoginPageHandler(EmptyLoginPage))
r.Get("/register", GetRegisterPageHandler(EmptyRegisterPage))
r.Post("/register", GetRegisterPageHandler(EmptyRegisterPage))
r.Get("/logout", GetLogout)
r.Get("/register/reset/request", GetPasswordResetRequest)
r.Post("/register/reset/request", PostPasswordResetRequest)
r.Get("/register/reset/validate", GetPasswordResetValidate)
r.Post("/register/reset/validate", PostPasswordResetValidate)
r.Group(func(r chi.Router) {
r.Use(IsLoggedInMiddleware)
r.Use(IsNotVerifiedMiddleware)
r.Get("/register/pending", GetRegisterPendingPage)
r.Post("/register/pending", PostRegisterPendingPage)
r.Get("/register/validate", GetRegisterValidatePage)
})
r.Group(func(r chi.Router) {
r.Use(IsLoggedInMiddleware)
r.Use(IsVerifiedMiddleware)
r.Get("/home", GetHomePage)
r.Get("/post", GetPostsPage)
r.Get("/post/{id}/edit", GetPostEditPage)
r.Post("/post/{id}/edit", PostPostEditPage)
r.Get("/post/{id}/delete", GetPostDeletePage)
r.Get("/post/create", GetPostCreatePage)
r.Post("/post/create", PostPostCreatePage)
r.Post("/post/{id}/image/create", image.PostImage)
r.Get("/post/{id}/publish", GetPostPublishPage)
r.Get("/post/{id}/unpublish", GetPostUnpublishPage)
r.Get("/integration", GetPageIntegration)
})
return r
}
@@ -1,58 +0,0 @@
package pages
import (
"GoCMS/api"
"net/http"
"os"
"github.com/google/uuid"
)
type PasswordResetRequest struct {
Email string `json:"email"`
}
var successMessage = "An email has been sent to the provided email address if it exists."
func GetPasswordResetRequest(w http.ResponseWriter, r *http.Request) {
success := r.URL.Query().Get("success")
email := r.URL.Query().Get("email")
bs, _ := api.Container.GetPageUseCase.GetPage("passwordResetRequest", map[string]interface{}{
"Head": headTmpl,
"Email": email,
"Success": success,
})
_, _ = w.Write(bs)
}
func PostPasswordResetRequest(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
passResetReq := PasswordResetRequest{
Email: r.FormValue("email"),
}
var getRedirectUrl = "/register/reset/request?success=" + successMessage + "&email=" + passResetReq.Email
err := api.Validate.Struct(passResetReq)
if err != nil {
http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther)
return
}
fetchedUser, err := api.Container.GetUserUseCase.GetUserByEmail(passResetReq.Email)
if err != nil {
http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther)
return
}
verificationCode := uuid.NewString()
updatedUser, _ := api.Container.UpdateUserUseCase.UpdatePasswordResetCode(fetchedUser.ID, verificationCode)
_ = api.Container.SendMailUseCase.SendMail(updatedUser.Email, "passwordReset", map[string]string{
"Host": os.Getenv("HOST"),
"VerificationCode": verificationCode,
"Email": updatedUser.Email,
})
http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther)
}
@@ -1,57 +0,0 @@
package pages
import (
"GoCMS/api"
"net/http"
"golang.org/x/crypto/bcrypt"
)
var PasswordLinkErrorMessage = "The reset password link you used is invalid."
var PasswordLinkSuccessMessage = "Your account password was successfully updated."
func GetPasswordResetValidate(w http.ResponseWriter, r *http.Request) {
failure := r.URL.Query().Get("failure")
pageError := NewPageError("")
if failure != "" {
pageError = NewPageError(failure)
}
template, _ := api.Container.GetPageUseCase.GetPage("passwordResetValidate", map[string]interface{}{
"Head": headTmpl,
"Error": pageError,
"Email": r.URL.Query().Get("email"),
"Code": r.URL.Query().Get("c"),
})
_, _ = w.Write(template)
}
func PostPasswordResetValidate(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
email := r.FormValue("email")
password := r.FormValue("password")
resetCode := r.FormValue("code")
var redirectionErrorLink = "/register/reset/validate?email=" + email + "&c=" + resetCode
if len(password) < 8 {
http.Redirect(w, r, redirectionErrorLink+"&failure=Password length should be at least 8 characters.", http.StatusSeeOther)
return
}
fetchedUser, err := api.Container.GetUserUseCase.GetUserByEmail(email)
if err != nil {
http.Redirect(w, r, redirectionErrorLink+"&failure="+PasswordLinkErrorMessage, http.StatusSeeOther)
return
}
err = bcrypt.CompareHashAndPassword([]byte(fetchedUser.PasswordResetCode), []byte(resetCode))
if err != nil {
http.Redirect(w, r, redirectionErrorLink+"&failure="+PasswordLinkErrorMessage, http.StatusSeeOther)
return
}
_, _ = api.Container.UpdateUserUseCase.UpdatePassword(fetchedUser.ID, password)
_, _ = api.Container.UpdateUserUseCase.UpdatePasswordResetCode(fetchedUser.ID, "")
http.Redirect(w, r, "/login?success="+PasswordLinkSuccessMessage, http.StatusSeeOther)
}
-58
View File
@@ -1,58 +0,0 @@
package pages
import (
"GoCMS/api"
"GoCMS/useCases"
"html/template"
"net/http"
"regexp"
"strconv"
)
type PostCreatePageError struct {
IsError bool
Message string
}
func GetPostCreatePageTemplate(postName string, errorMessage string) ([]byte, error) {
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
return api.Container.GetPageUseCase.GetPage("postCreate", map[string]interface{}{
"Navbar": template.HTML(navbarTmpl),
"Head": headTmpl,
"PageError": PostCreatePageError{
IsError: errorMessage != "",
Message: errorMessage,
},
"Name": postName,
})
}
func PostPostCreatePage(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
postName := r.FormValue("name")
pattern := regexp.MustCompile("^[a-zA-Z0-9À-ÖØ-öø-ÿĀ-ſḀ-ỿ ]{3,50}$")
if !pattern.MatchString(postName) {
postsTmpl, _ := GetPostCreatePageTemplate(postName, "Name should be alphanumeric, and between 3 and 50 characters.")
_, _ = w.Write(postsTmpl)
return
}
post, err := api.Container.CreatePostUseCase.CreatePost(useCases.CreatePostCommand{
Title: postName,
Body: "",
})
if err != nil {
postsTmpl, _ := GetPostCreatePageTemplate(postName, "Something went wrong when creating the post, please contact admin.")
_, _ = w.Write(postsTmpl)
return
}
http.Redirect(w, r, "/post/"+strconv.Itoa(int(post.ID))+"/edit", http.StatusSeeOther)
}
func GetPostCreatePage(w http.ResponseWriter, _ *http.Request) {
postsTmpl, _ := GetPostCreatePageTemplate("", "")
_, _ = w.Write(postsTmpl)
}
-42
View File
@@ -1,42 +0,0 @@
package pages
import (
"GoCMS/api"
"GoCMS/api/controllers/post"
"fmt"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
func GetPostDeletePage(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, post.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
localPost, err := api.Container.GetPostUseCase.GetPost(uint32(id))
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
fmt.Println(localPost.Images)
for _, image := range localPost.Images {
err = api.Container.DeleteImageUseCase.DeleteImage(image.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
}
err = api.Container.DeletePostUseCase.DeletePost(uint32(id))
if err != nil {
http.Error(w, http.StatusText(400), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/post", http.StatusSeeOther)
}
-75
View File
@@ -1,75 +0,0 @@
package pages
import (
"GoCMS/api"
"GoCMS/domain/post"
"html/template"
"net/http"
"os"
"strconv"
"github.com/go-chi/chi/v5"
)
type PostEditPageAlert struct {
IsError bool
Message string
}
func getPostEditPageTemplate(post post.Post, alert PostEditPageAlert) []byte {
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
postTmpl, _ := api.Container.GetPageUseCase.GetPage("postEdit", map[string]interface{}{
"Navbar": template.HTML(navbarTmpl),
"Head": headTmpl,
"Post": post,
"Alert": alert,
"Secured": os.Getenv("ENVIRONMENT") == "production",
})
return postTmpl
}
func PostPostEditPage(w http.ResponseWriter, r *http.Request) {
postID := chi.URLParam(r, "id")
postIDint, err := strconv.Atoi(postID)
if err != nil {
_, _ = w.Write(getPostEditPageTemplate(post.Post{}, PostEditPageAlert{
IsError: true,
Message: "Could not find the requested post.",
}))
return
}
_ = r.ParseForm()
postBody := r.FormValue("postBody")
getPost, _ := api.Container.GetPostUseCase.GetPost(uint32(postIDint))
updatedPost, err := api.Container.UpdatePostUseCase.UpdateBody(getPost.ID, postBody)
if err != nil {
_, _ = w.Write(getPostEditPageTemplate(post.Post{Body: postBody}, PostEditPageAlert{
IsError: true,
Message: "Could not save the post: " + err.Error(),
}))
return
}
_, _ = w.Write(getPostEditPageTemplate(updatedPost, PostEditPageAlert{
IsError: false,
Message: "Post successfully edited!",
}))
}
func GetPostEditPage(w http.ResponseWriter, r *http.Request) {
postID := chi.URLParam(r, "id")
postIDint, err := strconv.Atoi(postID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
getPost, _ := api.Container.GetPostUseCase.GetPost(uint32(postIDint))
_, _ = w.Write(getPostEditPageTemplate(getPost, PostEditPageAlert{
IsError: false,
Message: "",
}))
}
@@ -1,44 +0,0 @@
package pages
import (
"GoCMS/api"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
func updateIsOnline(postId string, isOnline bool) (error, int) {
postIdInt, err := strconv.Atoi(postId)
if err != nil {
return errors.New("the server expects the ID to be in the format of an unsigned 32-bit integer (uint32)"), http.StatusBadRequest
}
_, err = api.Container.UpdatePostUseCase.UpdateIsOnline(uint32(postIdInt), isOnline)
if err != nil {
return errors.New("the requested resource, identified by its unique ID, could not be found on the server"), http.StatusNotFound
}
return nil, http.StatusOK
}
func GetPostUnpublishPage(w http.ResponseWriter, r *http.Request) {
postId := chi.URLParam(r, "id")
err, statusCode := updateIsOnline(postId, false)
if err != nil {
http.Error(w, err.Error(), statusCode)
return
}
http.Redirect(w, r, "/post", http.StatusSeeOther)
}
func GetPostPublishPage(w http.ResponseWriter, r *http.Request) {
postId := chi.URLParam(r, "id")
err, statusCode := updateIsOnline(postId, true)
if err != nil {
http.Error(w, err.Error(), statusCode)
return
}
http.Redirect(w, r, "/post", http.StatusSeeOther)
}
-31
View File
@@ -1,31 +0,0 @@
package pages
import (
"GoCMS/api"
"html/template"
"net/http"
)
func GetPostsPage(w http.ResponseWriter, _ *http.Request) {
posts := api.Container.ListPostsUseCase.ListPosts()
var formattedPosts []map[string]interface{}
for _, post := range posts {
formattedPosts = append(formattedPosts, map[string]interface{}{
"ID": post.ID,
"Title": post.Title,
"IsOnline": post.IsOnline,
"CreatedAt": post.CreatedAt.Format("2006-01-02 15:04:05"),
"UpdatedAt": post.UpdatedAt.Format("2006-01-02 15:04:05"),
})
}
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
postsTmpl, _ := api.Container.GetPageUseCase.GetPage("posts", map[string]interface{}{
"Navbar": template.HTML(navbarTmpl),
"Head": headTmpl,
"Posts": formattedPosts,
})
_, _ = w.Write(postsTmpl)
}
-107
View File
@@ -1,107 +0,0 @@
package pages
import (
"GoCMS/api"
"GoCMS/api/controllers/auth"
"net/http"
"os"
"github.com/google/uuid"
)
type RegisterPageError struct {
Email bool `json:"email"`
Password bool `json:"password"`
Username bool `json:"username"`
}
type RegisterPage struct {
PageError *RegisterPageError `json:"error"`
Username string `json:"username"`
Email string `json:"email"`
}
var EmptyRegisterPage = &RegisterPage{
PageError: &RegisterPageError{
Email: false,
Password: false,
Username: false,
},
Username: "",
Email: "",
}
func GetRegisterPageHandler(registerPage *RegisterPage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !auth.IsLoggedIn(r) && auth.SomeUsersVerified() {
http.Redirect(w, r, "/login?failure=There is already a verified account, please login.", http.StatusSeeOther)
}
if (auth.IsLoggedIn(r) && auth.IsVerified(r)) || auth.SomeUsersVerified() {
http.Redirect(w, r, "/home", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
PostRegisterPage(w, r)
return
}
bs, err := api.Container.GetPageUseCase.GetPage("register", map[string]interface{}{
"PageError": registerPage.PageError,
"Username": registerPage.Username,
"Email": registerPage.Email,
"Head": headTmpl,
})
if err != nil {
_, _ = w.Write([]byte(err.Error()))
}
_, _ = w.Write(bs)
}
}
func PostRegisterPage(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
credentials := auth.RegisterCredentials{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
Email: r.FormValue("email"),
}
err := api.Validate.Struct(credentials)
if err != nil {
r.Method = http.MethodGet
GetRegisterPageHandler(&RegisterPage{
PageError: &RegisterPageError{
Email: true,
Password: true,
Username: true,
},
Username: r.FormValue("username"),
Email: r.FormValue("email"),
})(w, r)
return
}
verificationCode := uuid.NewString()
createdUser, err := auth.GetNewUser(credentials, verificationCode)
if err != nil {
r.Method = http.MethodGet
GetRegisterPageHandler(&RegisterPage{
PageError: &RegisterPageError{
Email: true,
Password: false,
Username: false,
},
Username: r.FormValue("username"),
Email: r.FormValue("email"),
})(w, r)
return
}
_ = api.Container.SendMailUseCase.SendMail(createdUser.Email, "mailValidation", map[string]string{
"Host": os.Getenv("HOST"),
"VerificationCode": verificationCode,
})
_ = auth.SetJwtCookie(&w, createdUser.ID)
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
}
-36
View File
@@ -1,36 +0,0 @@
package pages
import (
"GoCMS/api"
"GoCMS/api/controllers/auth"
"log"
"net/http"
"github.com/go-chi/jwtauth/v5"
)
func PostRegisterPendingPage(w http.ResponseWriter, r *http.Request) {
token, _ := jwtauth.VerifyRequest(
auth.Token, r,
jwtauth.TokenFromCookie,
jwtauth.TokenFromHeader,
jwtauth.TokenFromQuery)
userId := token.PrivateClaims()["user_id"].(float64)
err := api.Container.DeleteUserUseCase.DeleteUser(uint32(userId))
if err != nil {
log.Println(err)
r.Method = http.MethodGet
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
}
auth.RemoveJwtCookie(w)
http.Redirect(w, r, "/register", http.StatusSeeOther)
}
func GetRegisterPendingPage(w http.ResponseWriter, _ *http.Request) {
registerPendingTmpl, _ := api.Container.GetPageUseCase.GetPage("registerPending", map[string]interface{}{
"Head": headTmpl,
})
_, _ = w.Write(registerPendingTmpl)
}
-46
View File
@@ -1,46 +0,0 @@
package pages
import (
"GoCMS/api"
"GoCMS/api/controllers/auth"
"net/http"
"time"
"github.com/go-chi/jwtauth/v5"
"golang.org/x/crypto/bcrypt"
)
func GetRegisterValidatePage(w http.ResponseWriter, r *http.Request) {
queryVerificationCode := r.URL.Query().Get("c")
if queryVerificationCode == "" {
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
return
}
token, _ := jwtauth.VerifyRequest(
auth.Token,
r,
jwtauth.TokenFromCookie,
jwtauth.TokenFromHeader,
jwtauth.TokenFromQuery)
userId := token.PrivateClaims()["user_id"].(float64)
user, _ := api.Container.GetUserUseCase.GetUser(uint32(userId))
errorMessage := ""
err := bcrypt.CompareHashAndPassword([]byte(user.VerificationCode), []byte(queryVerificationCode))
if err != nil || user.VerificationExpiration.Before(time.Now()) {
errorMessage = "Verification link is incorrect or has expired."
} else {
_, err := api.Container.UpdateUserUseCase.UpdateVerificationStatus(user.ID, true)
if err != nil {
errorMessage = "Something went wrong server-side. User account may not exist."
}
}
registerValidateTmpl, _ := api.Container.GetPageUseCase.GetPage("registerValidate", map[string]interface{}{
"Head": headTmpl,
"PageError": NewPageError(errorMessage),
})
_, _ = w.Write(registerValidateTmpl)
}
-99
View File
@@ -1,99 +0,0 @@
package post
import (
"GoCMS/api"
"GoCMS/api/controllers/auth"
"GoCMS/domain/post"
"GoCMS/useCases"
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type PostPost struct {
Title string `json:"title" validate:"required,min=3,max=50"`
Body string `json:"body" validate:"required,max=10000"`
}
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
func getPost(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, IdUint32ErrorMessage, http.StatusBadRequest)
return
}
localPost, err := api.Container.GetPostUseCase.GetPost(uint32(id))
if err != nil || !localPost.IsOnline {
http.Error(w, "The requested resource, identified by its unique ID, could not be found on the server.", http.StatusNotFound)
return
}
postJson, _ := json.Marshal(localPost)
_, _ = w.Write(postJson)
}
func postPost(w http.ResponseWriter, r *http.Request) {
var localPost PostPost
err := json.NewDecoder(r.Body).Decode(&localPost)
if err != nil {
http.Error(w, auth.BodyErrorMessage, http.StatusBadRequest)
return
}
err = api.Validate.Struct(localPost)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
createdPost, err := api.Container.CreatePostUseCase.CreatePost(useCases.CreatePostCommand{
Title: localPost.Title,
Body: localPost.Body,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
postJson, _ := json.Marshal(createdPost)
_, _ = w.Write(postJson)
}
func listPosts(w http.ResponseWriter, _ *http.Request) {
posts := api.Container.ListPostsUseCase.ListPosts()
onlinePosts := make([]post.Post, 0)
for _, localPost := range posts {
if localPost.IsOnline {
onlinePosts = append(onlinePosts, localPost)
}
}
postsJson, _ := json.Marshal(onlinePosts)
_, _ = w.Write(postsJson)
}
func deletePost(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, IdUint32ErrorMessage, http.StatusBadRequest)
}
err = api.Container.DeletePostUseCase.DeletePost(uint32(id))
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
}
_, _ = w.Write([]byte("post deleted"))
}
func NewPostRouter() http.Handler {
r := chi.NewRouter()
r.Get("/{id}", getPost)
r.Post("/", postPost)
r.Get("/", listPosts)
r.Delete("/{id}", deletePost)
return r
}
+448 -54
View File
@@ -1,73 +1,467 @@
package api package api
import ( import (
"GoCMS/adapters/secondary/gateways/models" domainAPIKey "RenewCMS/internal/domain/apikey"
"GoCMS/useCases" domainArticle "RenewCMS/internal/domain/article"
"github.com/glebarez/sqlite" domainAudit "RenewCMS/internal/domain/audit"
"go.uber.org/dig" domainImage "RenewCMS/internal/domain/image"
"gorm.io/gorm" domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge"
domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/auditing"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/usage"
"RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519"
"log"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"sync"
"time"
"github.com/go-chi/jwtauth/v5"
) )
type UseCases struct { type UseCases struct {
CreatePostUseCase *useCases.CreatePostUseCase apiKeyAuthOnce sync.Once
GetPostUseCase *useCases.GetPostUseCase apiKeyAuthMiddleware func(http.Handler) http.Handler
ListPostsUseCase *useCases.ListPostsUseCase Token *jwtauth.JWTAuth
UpdatePostUseCase *useCases.UpdatePostUseCase PurgeScheduler *scheduler.Runner
DeletePostUseCase *useCases.DeletePostUseCase ActivityCleanupScheduler *scheduler.Runner
GetUserUseCase *useCases.GetUserUseCase CreateArticleUseCase *useCases.CreateArticleUseCase
CreateUserUseCase *useCases.CreateUserUseCase GetArticleUseCase *useCases.GetArticleUseCase
UpdateUserUseCase *useCases.UpdateUserUseCase ListArticlesUseCase *useCases.ListArticlesUseCase
DeleteUserUseCase *useCases.DeleteUserUseCase UpdateArticleUseCase *useCases.UpdateArticleUseCase
ListUsersUseCase *useCases.ListUsersUseCase DeleteArticleUseCase *useCases.DeleteArticleUseCase
GetPageUseCase *useCases.GetPageUseCase GetUserUseCase *useCases.GetUserUseCase
SendMailUseCase *useCases.SendMailUseCase CreateUserUseCase *useCases.CreateUserUseCase
CreateImageUseCase *useCases.CreateImageUseCase AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase VerifyUserUseCase *useCases.VerifyUserUseCase
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
ListActivityUseCase *useCases.ListActivityUseCase
GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase
ExportActivityUseCase *useCases.ExportActivityUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
ListVariantsUseCase *useCases.ListVariantsUseCase
CreateVariantUseCase *useCases.CreateVariantUseCase
UpdateVariantUseCase *useCases.UpdateVariantUseCase
SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase
DeleteVariantUseCase *useCases.DeleteVariantUseCase
PurgeVariantUseCase *useCases.PurgeVariantUseCase
ServeVariantUseCase *useCases.ServeVariantUseCase
ListImageVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateImageVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllImageVariantsUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeImageVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateVariantsOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
GetVariantCacheStatsUseCase *useCases.GetVariantCacheStatsUseCase
PurgeVariantCacheUseCase *useCases.PurgeVariantCacheUseCase
VariantUsageTracker *usage.Tracker
} }
var Container *UseCases func (c *UseCases) RunSchedulers(ctx context.Context) {
var running sync.WaitGroup
func getDb() *gorm.DB { for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler} {
dbName := os.Getenv("DB_FILE") if runner == nil {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil { continue
panic("Unable to create necessary subdirectories: " + err.Error()) }
running.Add(1)
go func() {
defer running.Done()
runner.Run(ctx)
}()
} }
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) running.Wait()
}
type Repositories struct {
ArticleRepo domainArticle.Repository
UserRepo domainUser.Repository
ImageRepo domainImage.Repository
VariantRepo domainImage.VariantRepository
VariantFileRepo domainImage.VariantFileRepository
VariantCacheRepo domainImage.VariantCacheRepository
VariantUsageRepo domainImage.VariantUsageRepository
VariantRenderer domainImage.VariantRenderer
InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository
PurgeSettingsRepo domainPurge.Repository
AuditRepo domainAudit.Repository
AuditSettingsRepo domainAudit.SettingsRepository
MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore
UpdateJobs domainUpdate.JobStore
UpdateHelper domainUpdate.HelperDetector
AssetFetcher domainUpdate.AssetFetcher
SignatureVerifier domainUpdate.SignatureVerifier
StagedRunner domainUpdate.StagedBinaryRunner
DatabaseBackup domainUpdate.DatabaseBackup
UpdateResults domainUpdate.ResultStore
RestartRequests domainUpdate.RestartRequestStore
RunningInfo domainUpdate.RunningInfoStore
}
const (
updateStatusFileName = "update-status.json"
updateJobFileName = "update-job.json"
updateHelperFileName = "update-helper.json"
updatesDirName = "updates"
backupsDirName = "backups"
)
func imageStorageConfig(settings config.Settings) storage.Config {
if settings.Storage.Driver == config.StorageDriverS3 {
s3 := settings.Storage.S3
return storage.Config{
Driver: storage.DriverS3,
S3: storage.S3Config{
Bucket: s3.Bucket,
Region: s3.Region,
Endpoint: s3.Endpoint,
AccessKeyID: s3.AccessKeyID,
SecretAccessKey: s3.SecretAccessKey,
KeyPrefix: s3.KeyPrefix,
PathStyle: s3.PathStyle,
TLS: s3.TLS,
},
}
}
return storage.Config{Driver: storage.DriverLocal, Location: settings.UploadDir}
}
func openImageStorage(settings config.Settings) domainImage.Storage {
imageStorage, err := storage.Open(imageStorageConfig(settings))
if err != nil {
panic("Unable to open the image storage: " + err.Error())
}
return imageStorage
}
func databaseConfig(settings config.Settings) database.Config {
switch settings.Database.Driver {
case config.DatabaseDriverMySQL:
return mysqlDatabaseConfig(settings.Database)
case config.DatabaseDriverPostgres:
return postgresDatabaseConfig(settings.Database)
case "", config.DatabaseDriverSQLite:
return database.Config{Driver: database.DriverSQLite, Source: settings.DBFile}
default:
return database.Config{Driver: settings.Database.Driver}
}
}
func mysqlDatabaseConfig(settings config.DatabaseSettings) database.Config {
return database.Config{
Driver: database.DriverMySQL,
Source: database.MySQLDSN(database.MySQLConfig{
Host: settings.Host,
Port: settings.Port,
User: settings.User,
Password: settings.Password,
Name: settings.Name,
TLS: settings.TLS,
}),
Pool: &database.PoolSettings{
MaxOpenConns: settings.MaxOpenConns,
MaxIdleConns: settings.MaxIdleConns,
ConnMaxLifetime: settings.ConnMaxLifetime,
},
}
}
func postgresDatabaseConfig(settings config.DatabaseSettings) database.Config {
return database.Config{
Driver: database.DriverPostgres,
Source: database.PostgresDSN(database.PostgresConfig{
Host: settings.Host,
Port: settings.Port,
User: settings.User,
Password: settings.Password,
Name: settings.Name,
SSLMode: settings.SSLMode,
}),
Pool: &database.PoolSettings{
MaxOpenConns: settings.MaxOpenConns,
MaxIdleConns: settings.MaxIdleConns,
ConnMaxLifetime: settings.ConnMaxLifetime,
},
}
}
func openDatabase(settings config.Settings) *database.Connection {
connection, err := database.Connect(databaseConfig(settings))
if err != nil { if err != nil {
panic("Unable to open the database: " + err.Error()) panic("Unable to open the database: " + err.Error())
} }
return db if err := database.Migrate(connection.DB); err != nil {
panic("Failed to migrate database: " + err.Error())
}
return connection
} }
func InitContainer() { func releasePublicKey() ed25519.PublicKey {
digContainer := dig.New() key, err := update.EmbeddedPublicKey()
database := getDb()
_ = database.AutoMigrate(&models.Post{}, &models.User{})
_ = digContainer.Provide(func() *gorm.DB { return database })
_ = digContainer.Provide(func(db *gorm.DB) *UseCases {
return &UseCases{
CreatePostUseCase: useCases.NewCreatePostUseCase(db),
GetPostUseCase: useCases.NewGetPostUseCase(db),
ListPostsUseCase: useCases.NewListPostsUseCase(db),
UpdatePostUseCase: useCases.NewUpdatePostUseCase(db),
DeletePostUseCase: useCases.NewDeletePostUseCase(db),
GetUserUseCase: useCases.NewGetUserUseCase(db),
CreateUserUseCase: useCases.NewCreateUserUseCase(db),
UpdateUserUseCase: useCases.NewUpdateUserUseCase(db),
DeleteUserUseCase: useCases.NewDeleteUserUseCase(db),
ListUsersUseCase: useCases.NewListUsersUseCase(db),
GetPageUseCase: useCases.NewGetPageUseCase(),
SendMailUseCase: useCases.NewSendMailUseCase(),
CreateImageUseCase: useCases.NewCreateImageUseCase(db),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(db),
}
})
err := digContainer.Invoke(func(useCases *UseCases) { Container = useCases })
if err != nil { if err != nil {
panic("Unable to invoke container: " + err.Error()) panic("Unable to read the embedded release key: " + err.Error())
}
return key
}
func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
db := connection.DB
dataDir := settings.DataDir()
variantFiles := persistence.NewVariantFileRepository(db)
return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db),
VariantRepo: persistence.NewVariantRepository(db),
VariantFileRepo: variantFiles,
VariantCacheRepo: variantFiles,
VariantUsageRepo: persistence.NewVariantUsageRepository(db),
VariantRenderer: imaging.NewVariantRenderer(),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
AuditRepo: persistence.NewAuditEventRepository(db),
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp),
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
UpdateJobs: update.NewJSONJobStore(filepath.Join(dataDir, updateJobFileName)),
UpdateHelper: update.NewContainerHelperDetector(update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)), settings.Container),
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
StagedRunner: update.NewExecStagedBinaryRunner(),
DatabaseBackup: connection.NewBackup(filepath.Join(dataDir, backupsDirName), time.Now),
UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)),
RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)),
RunningInfo: update.NewJSONRunningInfoStore(filepath.Join(dataDir, domainUpdate.RunningInfoFileName)),
} }
} }
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder)
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder)
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
usageTracker := usage.NewTracker(repos.VariantUsageRepo, time.Now)
serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}).WithUsageTracker(usageTracker)
return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now, recorder),
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now),
ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder),
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, repos.VariantRenderer, time.Now, recorder),
SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(repos.VariantRepo, repos.VariantFileRepo, time.Now, recorder),
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
ServeVariantUseCase: serveVariant,
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer),
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant),
GenerateAllImageVariantsUseCase: useCases.NewGenerateAllImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant, recorder),
PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(repos.VariantRepo, serveVariant, log.Default()),
GetVariantCacheStatsUseCase: useCases.NewGetVariantCacheStatsUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.VariantCacheRepo, repos.ImageRepo, repos.VariantUsageRepo, usageTracker, time.Now),
PurgeVariantCacheUseCase: useCases.NewPurgeVariantCacheUseCase(repos.VariantRepo, repos.ImageRepo, repos.VariantCacheRepo, repos.VariantFileRepo, imageStorage, time.Now, recorder),
VariantUsageTracker: usageTracker,
}
}
func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings, recorder domainAudit.Recorder) *useCases.StartUpdateDownloadUseCase {
return useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{
Store: repos.UpdateStore,
Jobs: repos.UpdateJobs,
Helper: repos.UpdateHelper,
Results: repos.UpdateResults,
Requests: repos.RestartRequests,
Fetcher: repos.AssetFetcher,
Verifier: repos.SignatureVerifier,
Runner: repos.StagedRunner,
Backup: repos.DatabaseBackup,
Staging: update.NewOSStagingArea(filepath.Join(settings.DataDir(), updatesDirName)),
Logger: log.Default(),
CurrentVersion: settings.Version,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
FeedURL: settings.UpdateFeedURL,
Now: time.Now,
Recorder: recorder,
})
}
func backfillImageMetadata(imageRepo domainImage.Repository, imageStorage domainImage.Storage, settings config.Settings) error {
run := func(readable domainImage.Storage) error {
return useCases.NewBackfillImageMetadataUseCase(imageRepo, readable).Run()
}
if settings.Storage.Driver == config.StorageDriverS3 {
return storage.RunBackfillReportingReads(imageStorage, log.Default(), run)
}
return run(imageStorage)
}
func InitContainer(settings config.Settings) (*UseCases, func() error) {
imageStorage := openImageStorage(settings)
connection := openDatabase(settings)
repos := getRepositories(connection, settings)
if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
container := getUseCases(repos, imageStorage, settings)
return container, container.runUsageTracker(connection.Close)
}
func (c *UseCases) runUsageTracker(closeDatabase func() error) func() error {
ctx, stop := context.WithCancel(context.Background())
stopped := make(chan struct{})
go func() {
defer close(stopped)
c.VariantUsageTracker.Run(ctx)
}()
return func() error {
stop()
<-stopped
return closeDatabase()
}
}
type BrowserUpdateCommands struct {
Enable *useCases.EnableBrowserUpdatesUseCase
Disable *useCases.DisableBrowserUpdatesUseCase
}
func InitBrowserUpdateCommands() *BrowserUpdateCommands {
units := update.NewSystemdUnits()
dependencies := useCases.ManageBrowserUpdatesDependencies{
Probe: update.NewSystemMachineProbe(),
Locator: units,
Units: units,
Manager: update.NewSystemctlControl(domainUpdate.ServiceUnitName),
Status: update.NewJSONHelperStatusWriter(),
Now: time.Now,
}
return &BrowserUpdateCommands{
Enable: useCases.NewEnableBrowserUpdatesUseCase(dependencies),
Disable: useCases.NewDisableBrowserUpdatesUseCase(dependencies),
}
}
func InitApplyUpdateCommand(options domainUpdate.InstallOptions) *useCases.ApplyRestartRequestUseCase {
layout := domainUpdate.HelperLayout{DataDir: options.DataDir, BinaryPath: options.Binary}
files := update.NewNoFollowFilesystem()
return useCases.NewApplyRestartRequestUseCase(useCases.ApplyRestartRequestDependencies{
Requests: update.NewFSRestartRequestStore(files, layout.RestartRequestPath()),
Results: update.NewFSResultStore(files, layout.ResultPath()),
Running: update.NewJSONRunningInfoStore(filepath.Join(options.DataDir, domainUpdate.RunningInfoFileName)),
Files: files,
Service: update.NewSystemctlControl(options.Service),
Health: update.NewHTTPHealthChecker(options.Port),
Verifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
Clock: update.NewSystemClock(),
Logger: log.Default(),
Layout: layout,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
})
}
+243
View File
@@ -0,0 +1,243 @@
package activity
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"time"
)
const (
csvContentType = "text/csv; charset=utf-8"
csvContentDisposition = `attachment; filename="activity.csv"`
)
type Handler struct {
ListUseCase *useCases.ListActivityUseCase
SummaryUseCase *useCases.GetActivitySummaryUseCase
ExportUseCase *useCases.ExportActivityUseCase
}
type filterQuery struct {
Q string `validate:"max=200"`
Actor string `validate:"max=40"`
Category string `validate:"omitempty,oneof=articles media users api_keys auth system"`
Outcome string `validate:"omitempty,oneof=all success failed"`
Security string `validate:"omitempty,oneof=true"`
Range string `validate:"omitempty,oneof=today 7d 30d all custom"`
From string `validate:"max=10"`
To string `validate:"max=10"`
}
type listQuery struct {
filterQuery
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
}
type actorResponse struct {
Type audit.ActorType `json:"type"`
ID *uint32 `json:"id"`
Name string `json:"name"`
}
type targetResponse struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Exists bool `json:"exists"`
}
type itemResponse struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
Category audit.Category `json:"category"`
Actor actorResponse `json:"actor"`
Target *targetResponse `json:"target"`
Outcome audit.Outcome `json:"outcome"`
FailureReason *string `json:"failure_reason"`
Security bool `json:"security"`
Source audit.Source `json:"source"`
IP *string `json:"ip"`
UserAgent *string `json:"user_agent"`
Params map[string]any `json:"params"`
Changes []audit.Change `json:"changes"`
}
type listResponse struct {
Items []itemResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func NewHandler(list *useCases.ListActivityUseCase, summary *useCases.GetActivitySummaryUseCase, export *useCases.ExportActivityUseCase) *Handler {
return &Handler{ListUseCase: list, SummaryUseCase: summary, ExportUseCase: export}
}
func readFilterQuery(values url.Values) filterQuery {
return filterQuery{
Q: values.Get("q"),
Actor: values.Get("actor"),
Category: values.Get("category"),
Outcome: values.Get("outcome"),
Security: values.Get("security"),
Range: values.Get("range"),
From: values.Get("from"),
To: values.Get("to"),
}
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intParam(values, "page", 1)
if err != nil {
return listQuery{}, err
}
size, err := intParam(values, "size", 25)
if err != nil {
return listQuery{}, err
}
return listQuery{filterQuery: readFilterQuery(values), Page: page, Size: size}, nil
}
func intParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func toActivityQuery(query filterQuery) useCases.ActivityQuery {
return useCases.ActivityQuery{
Q: query.Q,
Actor: query.Actor,
Category: query.Category,
Outcome: query.Outcome,
Security: query.Security,
Range: query.Range,
From: query.From,
To: query.To,
}
}
func writeActivityError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, audit.ErrInvalidFilter):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, audit.ErrActorNotAllowed):
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
viewerId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
activityQuery := toActivityQuery(query.filterQuery)
activityQuery.Page, activityQuery.Size = query.Page, query.Size
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Query: activityQuery})
if err != nil {
writeActivityError(w, err)
return
}
items := make([]itemResponse, 0, len(page.Items))
for _, entry := range page.Items {
items = append(items, toItemResponse(entry))
}
writeJSON(w, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func toItemResponse(entry useCases.ActivityEntry) itemResponse {
event := entry.Event
item := itemResponse{
ID: event.ID,
CreatedAt: event.CreatedAt,
Action: event.Action,
Category: event.Category,
Actor: actorResponse{Type: event.Actor.Type, ID: event.Actor.ID, Name: event.Actor.Name},
Outcome: event.Outcome,
FailureReason: event.FailureReason,
Security: event.Security,
Source: event.Source,
IP: event.IP,
UserAgent: event.UserAgent,
Params: event.Params,
Changes: event.Changes,
}
if item.Params == nil {
item.Params = map[string]any{}
}
if item.Changes == nil {
item.Changes = []audit.Change{}
}
if event.Target.Type != "" {
item.Target = &targetResponse{Type: event.Target.Type, ID: event.Target.ID, Name: event.Target.Name, Exists: entry.TargetExists}
}
return item
}
func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
query := readFilterQuery(r.URL.Query())
if validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
summary, err := h.SummaryUseCase.GetActivitySummary(toActivityQuery(query))
if err != nil {
writeActivityError(w, err)
return
}
writeJSON(w, summary)
}
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
query := readFilterQuery(r.URL.Query())
if validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
prepared, err := h.ExportUseCase.Prepare(toActivityQuery(query))
if err != nil {
writeActivityError(w, err)
return
}
w.Header().Set("Content-Type", csvContentType)
w.Header().Set("Content-Disposition", csvContentDisposition)
w.Header().Set("Cache-Control", "no-store")
_ = prepared.WriteCSV(w)
}
func writeJSON(w http.ResponseWriter, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
+176
View File
@@ -0,0 +1,176 @@
package apikey
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateAPIKeyUseCase
ListUseCase *useCases.ListAPIKeysUseCase
RevokeUseCase *useCases.RevokeAPIKeyUseCase
}
type CreateAPIKeyRequest struct {
Name string `json:"name" validate:"required,max=60"`
ExpiresAt *time.Time `json:"expires_at"`
}
type createdKeyResponse struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Key string `json:"key"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at"`
}
type keyListItem struct {
ID uint32 `json:"id"`
UserID uint32 `json:"user_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Status domain.Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedByName *string `json:"created_by_name,omitempty"`
}
func NewHandler(create *useCases.CreateAPIKeyUseCase, list *useCases.ListAPIKeysUseCase, revoke *useCases.RevokeAPIKeyUseCase) *Handler {
return &Handler{CreateUseCase: create, ListUseCase: list, RevokeUseCase: revoke}
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request CreateAPIKeyRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
created, err := h.CreateUseCase.CreateAPIKey(r.Context(), useCases.CreateAPIKeyCommand{
UserID: userId,
Name: request.Name,
ExpiresAt: request.ExpiresAt,
})
if err != nil {
if errors.Is(err, domain.ErrExpiryNotInFuture) {
http.Error(w, apperr.APIKeyExpiryErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(createdKeyResponse{
ID: created.Key.ID,
Name: created.Key.Name,
Prefix: created.Key.Prefix,
Key: created.RawKey,
CreatedAt: created.Key.CreatedAt,
ExpiresAt: created.Key.ExpiresAt,
})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(body)
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
entries, err := h.ListUseCase.ListAPIKeys(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]keyListItem, 0, len(entries))
for _, entry := range entries {
key := entry.Key
item := keyListItem{
ID: key.ID,
UserID: key.UserID,
Name: key.Name,
Prefix: key.Prefix,
Status: entry.Status,
CreatedAt: key.CreatedAt,
LastUsedAt: key.LastUsedAt,
ExpiresAt: key.ExpiresAt,
RevokedAt: key.RevokedAt,
}
if entry.ListsCreator {
item.CreatedByName = &entry.CreatorName
}
items = append(items, item)
}
body, err := json.Marshal(items)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id)); err != nil {
switch {
case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
+392
View File
@@ -0,0 +1,392 @@
package article
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateArticleUseCase
GetUseCase *useCases.GetArticleUseCase
ListUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
}
type PostArticle struct {
Title string `json:"title" validate:"required,min=3,max=200"`
Body string `json:"body"`
}
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, getUser *useCases.GetUserUseCase) *Handler {
return &Handler{
CreateUseCase: create,
GetUseCase: get,
ListUseCase: list,
DeleteUseCase: delete,
UpdateUseCase: update,
GetUserUseCase: getUser,
}
}
type articleResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat domain.BodyFormat `json:"body_format"`
IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
body := json.RawMessage(article.Body)
if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body)
if err != nil {
return articleResponse{}, err
}
body = encoded
}
return articleResponse{
ID: article.ID,
Title: article.Title,
Body: body,
BodyFormat: format,
IsOnline: article.IsOnline,
CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt,
}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
response, err := toArticleResponse(article, format)
if err != nil {
return nil, err
}
return json.Marshal(response)
}
func parseBodyFormat(w http.ResponseWriter, r *http.Request) (domain.BodyFormat, bool) {
format, err := domain.ParseBodyFormat(r.URL.Query().Get("format"))
if err != nil {
http.Error(w, apperr.ArticleBodyFormatErrorMessage, http.StatusBadRequest)
return "", false
}
return format, true
}
func parseArticleId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var article domain.Article
if h.callerIsVerified(r) {
article, err = h.GetUseCase.GetArticle(id, format)
} else {
article, err = h.GetUseCase.GetOnlineArticle(id, format)
}
if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
articleJson, err := encodeArticle(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(articleJson); err != nil {
fmt.Printf("Failed to write response: %v", err)
}
}
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle PostArticle
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(localArticle); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
Title: localArticle.Title,
Body: localArticle.Body,
Format: format,
})
if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) {
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Failed to create article", http.StatusInternalServerError)
return
}
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
}
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline bool) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, isOnline, format)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
}
type UpdateArticleBody struct {
Body string `json:"body"`
}
func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle UpdateArticleBody
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(localArticle); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
updatedArticle, err := h.UpdateUseCase.UpdateBody(r.Context(), id, localArticle.Body, format)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
}
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
h.setIsOnline(w, r, true)
}
func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) {
h.setIsOnline(w, r, false)
}
func (h *Handler) callerIsVerified(r *http.Request) bool {
userId, ok := claims.UserId(r)
if !ok {
return false
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
if err != nil {
return false
}
return currentUser.IsVerified
}
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Q string `validate:"max=200"`
Status string `validate:"omitempty,oneof=online draft"`
Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"`
}
const defaultListSort = "updated_at:desc"
func parseSort(value string) domain.Sort {
field, direction, _ := strings.Cut(value, ":")
return domain.Sort{Field: domain.SortField(field), Direction: domain.SortDirection(direction)}
}
type listResponse struct {
Items []articleResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts domain.Counts `json:"counts"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size, err := intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
status := values.Get("status")
if values.Has("status") && status == "" {
return listQuery{}, errors.New("empty status")
}
sortValue := defaultListSort
if values.Has("sort") {
sortValue = values.Get("sort")
}
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue}, nil
}
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Format: format,
Verified: h.callerIsVerified(r),
Page: query.Page,
Size: query.Size,
Q: query.Q,
Status: query.Status,
Sort: parseSort(query.Sort),
})
if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
return
}
responses := make([]articleResponse, 0, len(page.Items))
for _, article := range page.Items {
response, err := toArticleResponse(article, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
responses = append(responses, response)
}
articlesJson, err := json.Marshal(listResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(articlesJson); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.DeleteUseCase.DeleteArticle(r.Context(), id); err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
http.Error(w, "Failed to delete article", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
+102
View File
@@ -0,0 +1,102 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/jwtauth/v5"
)
type AcceptInviteHandler struct {
Token *jwtauth.JWTAuth
GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase
IsProduction bool
}
type AcceptInviteRequest struct {
Code string `json:"code" validate:"required"`
Username string `json:"username" validate:"required,min=3,max=20"`
Password string `json:"password" validate:"required,min=8"`
}
type inviteEmailResponse struct {
Email string `json:"email"`
}
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *jwtauth.JWTAuth, isProduction bool) *AcceptInviteHandler {
return &AcceptInviteHandler{
Token: token,
GetInviteUseCase: getInvite,
AcceptInviteUseCase: acceptInvite,
IsProduction: isProduction,
}
}
func (h *AcceptInviteHandler) GetInvite(w http.ResponseWriter, r *http.Request) {
found, err := h.GetInviteUseCase.GetInvite(r.URL.Query().Get("c"))
if err != nil {
writeAcceptInviteError(w, err)
return
}
body, err := json.Marshal(inviteEmailResponse{Email: found.Email})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *AcceptInviteHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
var request AcceptInviteRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
createdUser, err := h.AcceptInviteUseCase.AcceptInvite(r.Context(), useCases.AcceptInviteCommand{
Code: request.Code,
Username: request.Username,
Password: request.Password,
})
if err != nil {
writeAcceptInviteError(w, err)
return
}
if err := setJwtCookie(w, h.Token, h.IsProduction, createdUser); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
message, _ := json.Marshal(map[string]any{"message": "Invite accepted! HTTPonly jwt cookie created"})
_, _ = w.Write(message)
}
func writeAcceptInviteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invite.ErrInviteInvalid):
http.Error(w, apperr.InviteInvalidErrorMessage, http.StatusNotFound)
case errors.Is(err, invite.ErrInviteExpired):
http.Error(w, apperr.InviteExpiredErrorMessage, http.StatusGone)
case errors.Is(err, user.ErrUsernameTaken):
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+147
View File
@@ -0,0 +1,147 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/jwtauth/v5"
)
type LoginHandler struct {
Token *jwtauth.JWTAuth
CreateUseCase *useCases.CreateUserUseCase
AuthenticateUseCase *useCases.AuthenticateUserUseCase
IsProduction bool
}
type RegisterCredentials struct {
Username string `json:"username" validate:"required,min=3,max=20"`
Password string `json:"password" validate:"required,min=8"`
Email string `json:"email" validate:"required,email"`
}
type LoginCredentials struct {
Username string `json:"username" validate:"required,min=3,max=20"`
Password string `json:"password" validate:"required,min=8"`
}
func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler {
return &LoginHandler{
Token: token,
CreateUseCase: create,
AuthenticateUseCase: authenticate,
IsProduction: isProduction,
}
}
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: isProduction,
HttpOnly: true,
Path: "/",
})
}
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error {
_, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Value: tokenString,
Expires: time.Now().Add(24 * time.Hour),
Secure: isProduction,
HttpOnly: true,
Path: "/",
})
return nil
}
func (h *LoginHandler) SetJwtCookie(w *http.ResponseWriter, u user.User) error {
return setJwtCookie(*w, h.Token, h.IsProduction, u)
}
func (h *LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
var credentials LoginCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
dbUser, err := h.AuthenticateUseCase.Authenticate(r.Context(), credentials.Username, credentials.Password)
if err != nil {
http.Error(w, apperr.LogsErrorMessage, http.StatusForbidden)
return
}
_ = h.SetJwtCookie(&w, dbUser)
message, _ := json.Marshal(map[string]any{"message": "User logged in! HTTPonly jwt cookie created"})
_, _ = w.Write(message)
}
func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
var credentials RegisterCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
createdUser, err := h.CreateUseCase.CreateUser(r.Context(), useCases.CreateUserCommand{
Username: credentials.Username,
Password: credentials.Password,
Email: credentials.Email,
})
if err != nil {
if errors.Is(err, user.ErrRegistrationClosed) {
http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden)
return
}
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
return
}
_ = h.SetJwtCookie(&w, createdUser)
message, _ := json.Marshal(map[string]any{"message": "User registered! HTTPonly jwt cookie created"})
_, _ = w.Write(message)
}
func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request) {
body, _ := json.Marshal(map[string]bool{"open": h.CreateUseCase.IsRegistrationOpen()})
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.AuthenticateUseCase.Logout(r.Context())
removeJwtCookie(w, h.IsProduction)
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
_, _ = w.Write(message)
}
+48
View File
@@ -0,0 +1,48 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type MeHandler struct {
GetUseCase *useCases.GetUserUseCase
}
func NewMeHandler(getUser *useCases.GetUserUseCase) *MeHandler {
return &MeHandler{
GetUseCase: getUser,
}
}
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
loadedUser, err := h.GetUseCase.GetUser(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(publicuser.New(loadedUser))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
+81
View File
@@ -0,0 +1,81 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type PasswordResetHandler struct {
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase
}
type RequestPasswordResetCredentials struct {
Email string `json:"email" validate:"required,email"`
}
type ResetPasswordCredentials struct {
Email string `json:"email" validate:"required,email"`
Code string `json:"code" validate:"required"`
Password string `json:"password" validate:"required,min=8"`
}
func NewPasswordResetHandler(requestPasswordReset *useCases.RequestPasswordResetUseCase, resetPassword *useCases.ResetPasswordUseCase) *PasswordResetHandler {
return &PasswordResetHandler{
RequestPasswordResetUseCase: requestPasswordReset,
ResetPasswordUseCase: resetPassword,
}
}
func (h *PasswordResetHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
var credentials RequestPasswordResetCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = h.RequestPasswordResetUseCase.RequestPasswordReset(r.Context(), credentials.Email)
message, _ := json.Marshal(map[string]any{"message": apperr.PasswordResetRequestedMessage})
_, _ = w.Write(message)
}
func (h *PasswordResetHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
var credentials ResetPasswordCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = h.ResetPasswordUseCase.ResetPassword(r.Context(), credentials.Email, credentials.Code, credentials.Password)
if err != nil {
if errors.Is(err, user.ErrInvalidPasswordResetCode) || errors.Is(err, user.ErrPasswordResetCodeExpired) {
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
message, _ := json.Marshal(map[string]any{"message": "Your password has been reset. You can now log in."})
_, _ = w.Write(message)
}
+99
View File
@@ -0,0 +1,99 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type VerificationHandler struct {
VerifyUseCase *useCases.VerifyUserUseCase
CancelUseCase *useCases.CancelRegistrationUseCase
IsProduction bool
}
type VerifyCredentials struct {
Code string `json:"code" validate:"required"`
}
func NewVerificationHandler(verify *useCases.VerifyUserUseCase, cancel *useCases.CancelRegistrationUseCase, isProduction bool) *VerificationHandler {
return &VerificationHandler{
VerifyUseCase: verify,
CancelUseCase: cancel,
IsProduction: isProduction,
}
}
func (h *VerificationHandler) Verify(w http.ResponseWriter, r *http.Request) {
var credentials VerifyCredentials
err := json.NewDecoder(r.Body).Decode(&credentials)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
verifiedUser, err := h.VerifyUseCase.Verify(r.Context(), userId, credentials.Code)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if errors.Is(err, user.ErrInvalidVerificationCode) || errors.Is(err, user.ErrVerificationCodeExpired) {
http.Error(w, apperr.VerificationFailedErrorMessage, http.StatusForbidden)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(publicuser.New(verifiedUser))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
func (h *VerificationHandler) CancelRegistration(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
err := h.CancelUseCase.CancelRegistration(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if errors.Is(err, user.ErrAlreadyVerified) {
http.Error(w, apperr.AlreadyVerifiedErrorMessage, http.StatusForbidden)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
removeJwtCookie(w, h.IsProduction)
message, _ := json.Marshal(map[string]any{"message": "Registration cancelled! Account deleted and HTTPonly jwt cookie cleared"})
_, _ = w.Write(message)
}
+108
View File
@@ -0,0 +1,108 @@
package frontend
import (
"RenewCMS/internal/infrastructure/assets"
"bytes"
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const hashedAssetsPrefix = "assets/"
const (
hashedAssetCacheControl = "public, max-age=31536000, immutable"
staticFileCacheControl = "public, max-age=3600"
indexCacheControl = "no-store, no-cache, must-revalidate"
)
const devNotice = "RenewCMS was built with -tags nofrontend: the SPA is served by the Vite dev server (http://localhost:5173)."
type spaHandler struct {
staticFS fs.FS
modTime time.Time
}
func NewFrontendRouter() http.Handler {
r := chi.NewRouter()
if assets.Dist == nil {
r.Get("/*", serveDevNotice)
return r
}
handler := &spaHandler{
staticFS: assets.Dist,
modTime: time.Now(),
}
r.Get("/*", handler.ServeHTTP)
return r
}
func serveDevNotice(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, devNotice)
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := h.cleanPath(r.URL.Path)
if h.fileExists(path) {
h.serveAsset(w, r, path)
return
}
h.serveIndex(w, r)
}
func (h *spaHandler) cleanPath(p string) string {
return strings.TrimPrefix(path.Clean("/"+p), "/")
}
func (h *spaHandler) fileExists(path string) bool {
if path == "" || path == "." {
return false
}
f, err := h.staticFS.Open(path)
if err != nil {
return false
}
defer f.Close()
stat, err := f.Stat()
return err == nil && !stat.IsDir()
}
func (h *spaHandler) serveAsset(w http.ResponseWriter, r *http.Request, path string) {
if strings.HasPrefix(path, hashedAssetsPrefix) {
w.Header().Set("Cache-Control", hashedAssetCacheControl)
} else {
w.Header().Set("Cache-Control", staticFileCacheControl)
}
http.FileServer(http.FS(h.staticFS)).ServeHTTP(w, r)
}
func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
f, err := h.staticFS.Open("index.html")
if err != nil {
http.Error(w, "Index not found", http.StatusNotFound)
return
}
defer f.Close()
html, err := io.ReadAll(f)
if err != nil {
http.Error(w, "Index not readable", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", indexCacheControl)
if policy := w.Header().Get("Content-Security-Policy"); policy != "" {
w.Header().Set("Content-Security-Policy", AllowInlineScripts(policy, html))
}
http.ServeContent(w, r, "index.html", h.modTime, bytes.NewReader(html))
}
+33
View File
@@ -0,0 +1,33 @@
package frontend
import (
"crypto/sha256"
"encoding/base64"
"regexp"
"strings"
)
const scriptSrcSelf = "script-src 'self'"
var inlineScriptPattern = regexp.MustCompile(`(?is)<script(\s[^>]*)?>(.*?)</script>`)
var srcAttributePattern = regexp.MustCompile(`(?i)\ssrc\s*=`)
func inlineScriptSources(html []byte) []string {
var sources []string
for _, match := range inlineScriptPattern.FindAllSubmatch(html, -1) {
if srcAttributePattern.Match(match[1]) || strings.TrimSpace(string(match[2])) == "" {
continue
}
digest := sha256.Sum256(match[2])
sources = append(sources, "'sha256-"+base64.StdEncoding.EncodeToString(digest[:])+"'")
}
return sources
}
func AllowInlineScripts(policy string, html []byte) string {
sources := inlineScriptSources(html)
if len(sources) == 0 {
return policy
}
return strings.Replace(policy, scriptSrcSelf, scriptSrcSelf+" "+strings.Join(sources, " "), 1)
}
+141
View File
@@ -0,0 +1,141 @@
package imagevariant
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"math"
"net/http"
validatorlib "github.com/go-playground/validator/v10"
)
type cacheUnusedResponse struct {
Files int `json:"files"`
Bytes int64 `json:"bytes"`
Days int `json:"days"`
}
type cacheVariantResponse struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
type cacheImageResponse struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
URL string `json:"url"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
type cacheResponse struct {
GeneratedFiles int `json:"generated_files"`
DiskUsed int64 `json:"disk_used"`
VariantsDefined int `json:"variants_defined"`
VariantsEnabled int `json:"variants_enabled"`
VariantsDisabled int `json:"variants_disabled"`
RejectedRequests int `json:"rejected_requests"`
RejectedWindowDays int `json:"rejected_window_days"`
Unused cacheUnusedResponse `json:"unused"`
ByVariant []cacheVariantResponse `json:"by_variant"`
TopImages []cacheImageResponse `json:"top_images"`
}
type cachePurgeRequest struct {
Scope string `json:"scope" validate:"required,oneof=all unused variant image"`
Variant string `json:"variant" validate:"required_if=Scope variant"`
ImageID int64 `json:"image_id" validate:"required_if=Scope image,omitempty,gt=0,lte=4294967295"`
}
type cachePurgeResponse struct {
Scope string `json:"scope"`
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
FailedFiles int `json:"failed_files"`
}
func (r *cachePurgeRequest) ignoreUnusedTargets() {
if r.Scope != string(image.CacheScopeVariant) {
r.Variant = ""
}
if r.Scope != string(image.CacheScopeImage) {
r.ImageID = 0
}
}
func cacheResponseOf(stats useCases.VariantCacheStats) cacheResponse {
response := cacheResponse{
GeneratedFiles: stats.GeneratedFiles,
DiskUsed: stats.DiskUsed,
VariantsDefined: stats.VariantsDefined,
VariantsEnabled: stats.VariantsEnabled,
VariantsDisabled: stats.VariantsDisabled,
RejectedRequests: stats.RejectedRequests,
RejectedWindowDays: stats.RejectedWindowDays,
Unused: cacheUnusedResponse{Files: stats.Unused.Files, Bytes: stats.Unused.Bytes, Days: stats.UnusedDays},
ByVariant: make([]cacheVariantResponse, 0, len(stats.ByVariant)),
TopImages: make([]cacheImageResponse, 0, len(stats.TopImages)),
}
for _, entry := range stats.ByVariant {
response.ByVariant = append(response.ByVariant, cacheVariantResponse{Name: entry.Name, Enabled: entry.Enabled, Files: entry.Files, Bytes: entry.Bytes})
}
for _, top := range stats.TopImages {
response.TopImages = append(response.TopImages, cacheImageResponse{ID: top.ID, Filename: top.Filename, URL: top.URL, Files: top.Files, Bytes: top.Bytes})
}
return response
}
func (h *Handler) CacheStats(w http.ResponseWriter, _ *http.Request) {
stats, err := h.cacheStats.Get()
if err != nil {
h.writeError(w, "", err)
return
}
writeJSON(w, http.StatusOK, cacheResponseOf(stats))
}
func (h *Handler) PurgeCache(w http.ResponseWriter, r *http.Request) {
var request cachePurgeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
request.ignoreUnusedTargets()
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, cachePurgeValidationMessage(err), http.StatusUnprocessableEntity)
return
}
result, err := h.purgeCache.Purge(r.Context(), useCases.PurgeVariantCacheCommand{
Scope: image.CachePurgeScope(request.Scope),
Variant: request.Variant,
ImageID: uint32(min(request.ImageID, math.MaxUint32)),
})
if err != nil {
h.writePurgeCacheError(w, err)
return
}
writeJSON(w, http.StatusOK, cachePurgeResponse{Scope: string(result.Scope), PurgedFiles: result.Files, PurgedBytes: result.Bytes, FailedFiles: result.Failed})
}
func cachePurgeValidationMessage(err error) string {
var failures validatorlib.ValidationErrors
if errors.As(err, &failures) && len(failures) > 0 && failures[0].StructField() != "Scope" {
return apperr.CachePurgeTargetErrorMessage
}
return apperr.CachePurgeScopeErrorMessage
}
func (h *Handler) writePurgeCacheError(w http.ResponseWriter, err error) {
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
h.writeError(w, "", err)
}
@@ -0,0 +1,266 @@
package imagevariant
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
validatorlib "github.com/go-playground/validator/v10"
"github.com/go-chi/chi/v5"
)
type Handler struct {
list *useCases.ListVariantsUseCase
create *useCases.CreateVariantUseCase
update *useCases.UpdateVariantUseCase
setEnabled *useCases.SetVariantEnabledUseCase
remove *useCases.DeleteVariantUseCase
purge *useCases.PurgeVariantUseCase
cacheStats *useCases.GetVariantCacheStatsUseCase
purgeCache *useCases.PurgeVariantCacheUseCase
}
func NewHandler(list *useCases.ListVariantsUseCase, create *useCases.CreateVariantUseCase, update *useCases.UpdateVariantUseCase, setEnabled *useCases.SetVariantEnabledUseCase, remove *useCases.DeleteVariantUseCase, purge *useCases.PurgeVariantUseCase, cacheStats *useCases.GetVariantCacheStatsUseCase, purgeCache *useCases.PurgeVariantCacheUseCase) *Handler {
return &Handler{list, create, update, setEnabled, remove, purge, cacheStats, purgeCache}
}
type definitionRequest struct {
Width *int `json:"width" validate:"required_without=Height,omitempty,min=1,max=8000"`
Height *int `json:"height" validate:"required_without=Width,omitempty,min=1,max=8000"`
Fit string `json:"fit" validate:"omitempty,oneof=cover contain inside"`
Format string `json:"format" validate:"omitempty,oneof=original jpeg png webp"`
Quality *int `json:"quality" validate:"omitempty,min=1,max=100"`
}
type switchesRequest struct {
GenerateOnUpload *bool `json:"generate_on_upload"`
Enabled *bool `json:"enabled"`
}
type createRequest struct {
Name string `json:"name" validate:"required,variantname"`
definitionRequest
switchesRequest
}
type updateRequest struct {
definitionRequest
switchesRequest
}
type enabledRequest struct {
Enabled *bool `json:"enabled" validate:"required"`
}
type variantResponse struct {
Name string `json:"name"`
Width *int `json:"width"`
Height *int `json:"height"`
Fit image.VariantFit `json:"fit"`
Format image.VariantFormat `json:"format"`
Quality *int `json:"quality"`
GenerateOnUpload bool `json:"generate_on_upload"`
Enabled bool `json:"enabled"`
GeneratedFiles int `json:"generated_files"`
CacheSize int64 `json:"cache_size"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type updatedVariantResponse struct {
variantResponse
PurgedFiles int `json:"purged_files"`
}
type listResponse struct {
Items []variantResponse `json:"items"`
}
type purgeResponse struct {
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
}
func responseOf(summary useCases.VariantSummary) variantResponse {
variant := summary.Variant
return variantResponse{
Name: variant.Name,
Width: variant.Width,
Height: variant.Height,
Fit: variant.Fit,
Format: variant.Format,
Quality: variant.Quality,
GenerateOnUpload: variant.GenerateOnUpload,
Enabled: variant.Enabled,
GeneratedFiles: summary.GeneratedFiles,
CacheSize: summary.CacheSize,
CreatedAt: variant.CreatedAt,
UpdatedAt: variant.UpdatedAt,
}
}
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
summaries, err := h.list.List()
if err != nil {
h.writeError(w, "", err)
return
}
items := make([]variantResponse, 0, len(summaries))
for _, summary := range summaries {
items = append(items, responseOf(summary))
}
writeJSON(w, http.StatusOK, listResponse{items})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request createRequest
if !h.decode(w, r, &request) {
return
}
summary, err := h.create.Create(r.Context(), useCases.CreateVariantCommand{
Name: request.Name,
VariantDefinitionCommand: request.definitionRequest.command(),
GenerateOnUpload: request.GenerateOnUpload,
Enabled: request.Enabled,
})
if err != nil {
h.writeError(w, request.Name, err)
return
}
writeJSON(w, http.StatusCreated, responseOf(summary))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var request updateRequest
if !h.decode(w, r, &request) {
return
}
name := chi.URLParam(r, "name")
result, err := h.update.Update(r.Context(), useCases.UpdateVariantCommand{
Name: name,
VariantDefinitionCommand: request.definitionRequest.command(),
GenerateOnUpload: request.GenerateOnUpload,
Enabled: request.Enabled,
})
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, updatedVariantResponse{responseOf(result.VariantSummary), result.PurgedFiles})
}
func (h *Handler) SetEnabled(w http.ResponseWriter, r *http.Request) {
var request enabledRequest
if !h.decode(w, r, &request) {
return
}
name := chi.URLParam(r, "name")
summary, err := h.setEnabled.SetEnabled(r.Context(), name, *request.Enabled)
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, responseOf(summary))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := h.remove.Delete(r.Context(), name); err != nil {
h.writeError(w, name, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Purge(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
purged, err := h.purge.Purge(r.Context(), name)
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, purgeResponse{purged.Files, purged.Bytes})
}
func (d definitionRequest) command() useCases.VariantDefinitionCommand {
return useCases.VariantDefinitionCommand{Width: d.Width, Height: d.Height, Fit: d.Fit, Format: d.Format, Quality: d.Quality}
}
func (h *Handler) decode(w http.ResponseWriter, r *http.Request, request any) bool {
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return false
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, h.validationMessage(err), http.StatusUnprocessableEntity)
return false
}
return true
}
func (h *Handler) validationMessage(err error) string {
var failures validatorlib.ValidationErrors
if !errors.As(err, &failures) || len(failures) == 0 {
return apperr.VariantInvalidErrorMessage
}
switch failures[0].StructField() {
case "Name":
return apperr.VariantNameErrorMessage
case "Width", "Height":
return apperr.VariantDimensionsErrorMessage
case "Fit":
return apperr.VariantFitErrorMessage
case "Format":
return h.formatMessage()
case "Quality":
return apperr.VariantQualityErrorMessage
default:
return apperr.VariantInvalidErrorMessage
}
}
func (h *Handler) formatMessage() string {
encodable := h.create.EncodableFormats()
names := make([]string, 0, len(encodable))
for _, format := range encodable {
names = append(names, string(format))
}
last := len(names) - 1
return fmt.Sprintf(apperr.VariantFormatErrorMessageFormat, strings.Join(names[:last], ", ")+" or "+names[last])
}
func (h *Handler) writeError(w http.ResponseWriter, name string, err error) {
switch {
case errors.Is(err, image.ErrVariantNotFound):
http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, image.ErrVariantExists):
http.Error(w, fmt.Sprintf(apperr.VariantExistsErrorMessageFormat, name), http.StatusConflict)
case errors.Is(err, image.ErrVariantFormatUnavailable):
http.Error(w, h.formatMessage(), http.StatusUnprocessableEntity)
case errors.Is(err, image.ErrVariantInvalid):
http.Error(w, apperr.VariantInvalidErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeJSON(w http.ResponseWriter, status int, value any) {
body, err := json.Marshal(value)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(status)
_, _ = w.Write(body)
}
+367
View File
@@ -0,0 +1,367 @@
package media
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/upload"
validator "RenewCMS/api/pkg/validator"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateImageUseCase
ListUseCase *useCases.ListMediaUseCase
RenameUseCase *useCases.RenameMediaUseCase
DeleteUseCase *useCases.DeleteMediaUseCase
MergeUseCase *useCases.MergeMediaUseCase
UsageUseCase *useCases.GetMediaUsageUseCase
PurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
ListVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
}
type RenameRequest struct {
Filename string `json:"filename" validate:"required"`
}
type MergeRequest struct {
KeepID uint32 `json:"keep_id" validate:"required,gt=0"`
MergeIDs []uint32 `json:"merge_ids" validate:"required,min=1,dive,gt=0"`
}
type mediaItemResponse struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
URL string `json:"url"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
Width *int `json:"width"`
Height *int `json:"height"`
SHA256 string `json:"sha256"`
UploadedBy string `json:"uploaded_by"`
CreatedAt time.Time `json:"created_at"`
UsedIn int `json:"used_in"`
Duplicate bool `json:"duplicate"`
VariantCount int `json:"variant_count"`
VariantSize int64 `json:"variant_size"`
}
type countsResponse struct {
All int `json:"all"`
Unused int `json:"unused"`
Duplicates int `json:"duplicates"`
}
type listResponse struct {
Items []mediaItemResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts countsResponse `json:"counts"`
}
type pagingQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
type usageResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Published bool `json:"published"`
}
type mergeResponse struct {
ArticlesUpdated int `json:"articles_updated"`
Merged int `json:"merged"`
}
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase, listVariants *useCases.ListImageVariantsUseCase, generateVariant *useCases.GenerateImageVariantUseCase, generateAll *useCases.GenerateAllImageVariantsUseCase, purgeVariant *useCases.PurgeImageVariantUseCase, generateOnUpload *useCases.GenerateVariantsOnUploadUseCase) *Handler {
return &Handler{
CreateUseCase: create,
ListUseCase: list,
RenameUseCase: rename,
DeleteUseCase: deleteUseCase,
MergeUseCase: merge,
UsageUseCase: usage,
PurgeSettingsUseCase: purgeSettings,
UpdatePurgeSettingsUseCase: updatePurgeSettings,
PreviewPurgeUseCase: previewPurge,
RunPurgeUseCase: runPurge,
ListVariantsUseCase: listVariants,
GenerateVariantUseCase: generateVariant,
GenerateAllUseCase: generateAll,
PurgeVariantUseCase: purgeVariant,
GenerateOnUploadUseCase: generateOnUpload,
}
}
func writeInternalServerError(w http.ResponseWriter) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
writeInternalServerError(w)
return
}
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeImageError(w http.ResponseWriter, err error) {
if errors.Is(err, domainImage.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
writeInternalServerError(w)
}
func parseImageId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return 0, false
}
return uint32(id), true
}
func dimension(value int) *int {
if value == 0 {
return nil
}
return &value
}
func toResponse(item useCases.MediaItem) mediaItemResponse {
return mediaItemResponse{
ID: item.Image.ID,
Filename: item.Image.Filename,
URL: item.Image.Path,
Size: item.Image.Size,
ContentType: item.Image.ContentType,
Width: dimension(item.Image.Width),
Height: dimension(item.Image.Height),
SHA256: item.Image.SHA256,
UploadedBy: item.UploadedBy,
CreatedAt: item.Image.CreatedAt,
UsedIn: item.UsedIn,
Duplicate: item.Duplicate,
VariantCount: item.VariantCount,
VariantSize: item.VariantSize,
}
}
func parseListQuery(r *http.Request) (domainImage.ListQuery, error) {
values, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query := domainImage.NewListQuery()
query.Search = values.Get("q")
query.Hash = strings.ToLower(values.Get("hash"))
if values.Has("filter") {
query.Filter = domainImage.Filter(values.Get("filter"))
}
if values.Has("variants") {
if values.Get("variants") == "" {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Variants = domainImage.VariantsFilter(values.Get("variants"))
}
if values.Has("sort") {
query.Sort = domainImage.Sort(values.Get("sort"))
}
page, err := intQueryParam(values, "page", domainImage.DefaultListPage)
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
size, err := intQueryParam(values, "size", domainImage.DefaultListSize)
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
if validator.Validate.Struct(pagingQuery{Page: page, Size: size}) != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Page = page
query.Size = size
return query, nil
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := parseListQuery(r)
if err != nil {
http.Error(w, apperr.MediaQueryErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(query)
if err != nil {
if errors.Is(err, domainImage.ErrInvalidQuery) {
http.Error(w, apperr.MediaQueryErrorMessage, http.StatusBadRequest)
return
}
writeInternalServerError(w)
return
}
items := make([]mediaItemResponse, 0, len(page.Items))
for _, item := range page.Items {
items = append(items, toResponse(item))
}
response := listResponse{
Items: items,
Total: page.Total,
Page: page.Page,
Size: page.Size,
Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates},
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
userId, _ := claims.UserId(r)
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
if !ok {
return
}
defer closeFile()
created, err := h.CreateUseCase.CreateImage(r.Context(), input)
if err != nil {
upload.WriteCreateError(w, err)
return
}
h.GenerateOnUploadUseCase.Generate(r.Context(), created)
h.writeItem(w, http.StatusCreated, created)
}
func (h *Handler) writeItem(w http.ResponseWriter, status int, img domainImage.Image) {
item, err := h.ListUseCase.Describe(img)
if err != nil {
writeInternalServerError(w)
return
}
writeJSON(w, status, toResponse(item))
}
func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
var request RenameRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest)
return
}
renamed, err := h.RenameUseCase.Rename(r.Context(), id, request.Filename)
if err != nil {
if errors.Is(err, domainImage.ErrInvalidFilename) {
http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest)
return
}
writeImageError(w, err)
return
}
h.writeItem(w, http.StatusOK, renamed)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
if err := h.DeleteUseCase.Delete(r.Context(), id); err != nil {
writeImageError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
articles, err := h.UsageUseCase.Usage(id)
if err != nil {
writeImageError(w, err)
return
}
response := make([]usageResponse, 0, len(articles))
for _, a := range articles {
response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline})
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
var request MergeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
return
}
result, err := h.MergeUseCase.Merge(r.Context(), request.KeepID, request.MergeIDs)
if err != nil {
writeMergeError(w, err)
return
}
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, Merged: result.Merged})
}
func writeMergeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domainImage.ErrInvalidMergeRequest):
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
case errors.Is(err, domainImage.ErrMergeCopyMissing), errors.Is(err, domainImage.ErrMergeHashMismatch):
http.Error(w, apperr.MediaMergeConflictErrorMessage, http.StatusConflict)
default:
writeImageError(w, err)
}
}
+169
View File
@@ -0,0 +1,169 @@
package media
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
type PurgeSettingsRequest struct {
AutoPurgeEnabled *bool `json:"auto_purge_enabled"`
AutoPurgeIntervalDays *int `json:"auto_purge_interval_days" validate:"omitempty,gte=1,lte=365"`
}
type PurgeRunRequest struct {
Fingerprint *string `json:"fingerprint"`
}
type purgeSettingsResponse struct {
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
LastPurgeAt *time.Time `json:"last_purge_at"`
LastPurgeMerged int `json:"last_purge_merged"`
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
EnabledAt *time.Time `json:"enabled_at"`
NextPurgeAt *time.Time `json:"next_purge_at"`
}
type purgePreviewGroupResponse struct {
KeepID uint32 `json:"keep_id"`
Filename string `json:"filename"`
URL string `json:"url"`
UsedIn int `json:"used_in"`
Merged int `json:"merged"`
}
type purgePreviewResponse struct {
Groups int `json:"groups"`
Copies int `json:"copies"`
Articles int `json:"articles"`
List []purgePreviewGroupResponse `json:"list"`
More int `json:"more"`
Fingerprint string `json:"fingerprint"`
}
type purgeRunResponse struct {
GroupsTotal int `json:"groups_total"`
GroupsPurged int `json:"groups_purged"`
Merged int `json:"merged"`
ArticlesUpdated int `json:"articles_updated"`
FailedGroups int `json:"failed_groups"`
}
func toPurgeSettingsResponse(settings purge.Settings) purgeSettingsResponse {
return purgeSettingsResponse{
AutoPurgeEnabled: settings.AutoPurgeEnabled,
AutoPurgeIntervalDays: settings.AutoPurgeIntervalDays,
LastPurgeAt: settings.LastPurgeAt,
LastPurgeMerged: settings.LastPurgeMerged,
LastPurgeArticlesUpdated: settings.LastPurgeArticlesUpdated,
EnabledAt: settings.EnabledAt,
NextPurgeAt: settings.NextPurgeAt(),
}
}
func writePurgeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, purge.ErrInvalidPurgeInterval):
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
case errors.Is(err, purge.ErrPurgeConflict):
http.Error(w, apperr.PurgeConflictErrorMessage, http.StatusConflict)
case errors.Is(err, purge.ErrPurgeBusy):
http.Error(w, apperr.PurgeBusyErrorMessage, http.StatusConflict)
default:
writeInternalServerError(w)
}
}
func (h *Handler) GetPurgeSettings(w http.ResponseWriter, _ *http.Request) {
settings, err := h.PurgeSettingsUseCase.Get()
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
}
func (h *Handler) UpdatePurgeSettings(w http.ResponseWriter, r *http.Request) {
var request PurgeSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
return
}
settings, err := h.UpdatePurgeSettingsUseCase.Update(useCases.UpdatePurgeSettingsCommand{
AutoPurgeEnabled: request.AutoPurgeEnabled,
AutoPurgeIntervalDays: request.AutoPurgeIntervalDays,
})
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
}
func (h *Handler) PreviewPurge(w http.ResponseWriter, _ *http.Request) {
preview, err := h.PreviewPurgeUseCase.Preview()
if err != nil {
writePurgeError(w, err)
return
}
list := make([]purgePreviewGroupResponse, 0, len(preview.List))
for _, group := range preview.List {
list = append(list, purgePreviewGroupResponse{
KeepID: group.KeepID,
Filename: group.Filename,
URL: group.URL,
UsedIn: group.UsedIn,
Merged: group.Merged,
})
}
writeJSON(w, http.StatusOK, purgePreviewResponse{
Groups: preview.Groups,
Copies: preview.Copies,
Articles: preview.Articles,
List: list,
More: preview.More,
Fingerprint: preview.Fingerprint,
})
}
func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
var request PurgeRunRequest
if len(raw) > 0 {
if err := json.Unmarshal(raw, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
}
result, err := h.RunPurgeUseCase.Run(r.Context(), request.Fingerprint)
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, purgeRunResponse{
GroupsTotal: result.GroupsTotal,
GroupsPurged: result.GroupsPurged,
Merged: result.Merged,
ArticlesUpdated: result.ArticlesUpdated,
FailedGroups: result.FailedGroups,
})
}
+161
View File
@@ -0,0 +1,161 @@
package media
import (
"RenewCMS/api/pkg/apperr"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
type imageVariantResponse struct {
Name string `json:"name"`
Width *int `json:"width"`
Height *int `json:"height"`
Fit domainImage.VariantFit `json:"fit"`
Format domainImage.VariantFormat `json:"format"`
OutputWidth int `json:"output_width"`
OutputHeight int `json:"output_height"`
OutputFormat domainImage.VariantFormat `json:"output_format"`
Generated bool `json:"generated"`
Size *int64 `json:"size"`
URL string `json:"url"`
}
type imageVariantListResponse struct {
Unsupported *string `json:"unsupported"`
Items []imageVariantResponse `json:"items"`
}
type variantFailureResponse struct {
Name string `json:"name"`
Cause string `json:"cause"`
}
type generateAllResponse struct {
Generated int `json:"generated"`
Failed int `json:"failed"`
Failures []variantFailureResponse `json:"failures"`
Items []imageVariantResponse `json:"items"`
}
type variantPurgeResponse struct {
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
}
func toImageVariantResponse(view useCases.ImageVariantView) imageVariantResponse {
response := imageVariantResponse{
Name: view.Variant.Name,
Width: view.Variant.Width,
Height: view.Variant.Height,
Fit: view.Variant.Fit,
Format: view.Variant.Format,
OutputWidth: view.OutputWidth,
OutputHeight: view.OutputHeight,
OutputFormat: view.OutputFormat,
Generated: view.Generated,
URL: view.URL,
}
if view.Generated {
size := view.Size
response.Size = &size
}
return response
}
func toImageVariantResponses(views []useCases.ImageVariantView) []imageVariantResponse {
items := make([]imageVariantResponse, 0, len(views))
for _, view := range views {
items = append(items, toImageVariantResponse(view))
}
return items
}
func writeImageVariantError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domainImage.ErrVariantNotFound):
http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domainImage.ErrVariantSourceUnsupported):
http.Error(w, apperr.VariantSourceUnsupportedErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domainImage.ErrSourceTooLarge):
http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge)
case errors.Is(err, domainImage.ErrVariantSourceUnreadable):
http.Error(w, apperr.VariantSourceUnreadableErrorMessage, http.StatusUnsupportedMediaType)
default:
writeImageError(w, err)
}
}
func (h *Handler) ListVariants(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
list, err := h.ListVariantsUseCase.List(id)
if err != nil {
writeImageVariantError(w, err)
return
}
response := imageVariantListResponse{Items: toImageVariantResponses(list.Items)}
if list.Unsupported != "" {
response.Unsupported = &list.Unsupported
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) GenerateVariant(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
view, err := h.GenerateVariantUseCase.Generate(r.Context(), id, chi.URLParam(r, "name"))
if err != nil {
writeImageVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, toImageVariantResponse(view))
}
func (h *Handler) GenerateAllVariants(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
result, err := h.GenerateAllUseCase.Generate(r.Context(), id)
if err != nil {
writeImageVariantError(w, err)
return
}
failures := make([]variantFailureResponse, 0, len(result.Failures))
for _, failure := range result.Failures {
failures = append(failures, variantFailureResponse{Name: failure.Name, Cause: failure.Cause})
}
writeJSON(w, http.StatusOK, generateAllResponse{
Generated: result.Generated,
Failed: result.Failed,
Failures: failures,
Items: toImageVariantResponses(result.Items),
})
}
func (h *Handler) PurgeVariant(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
purged, err := h.PurgeVariantUseCase.Purge(r.Context(), id, chi.URLParam(r, "name"))
if err != nil {
writeImageVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, variantPurgeResponse{PurgedFiles: purged.Files, PurgedBytes: purged.Bytes})
}
+108
View File
@@ -0,0 +1,108 @@
package settings
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
type Handler struct {
GetSettingsUseCase *useCases.GetSettingsUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
}
type UpdateSettingsRequest struct {
ActivityRetentionDays *int `json:"activity_retention_days" validate:"omitempty,oneof=0 30 90 365"`
ActivityRetentionMaxGB *int `json:"activity_retention_max_gb" validate:"omitempty,oneof=0 1 5 10 25 50"`
}
type settingsResponse struct {
Version string `json:"version"`
ActivityRetentionDays int `json:"activity_retention_days"`
ActivityRetentionMaxGB int `json:"activity_retention_max_gb"`
ActivityLogSizeBytes int64 `json:"activity_log_size_bytes"`
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
ActivityNextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
}
func NewHandler(getSettings *useCases.GetSettingsUseCase, getRetention *useCases.GetActivityRetentionUseCase, updateRetention *useCases.UpdateActivityRetentionUseCase) *Handler {
return &Handler{GetSettingsUseCase: getSettings, GetActivityRetentionUseCase: getRetention, UpdateActivityRetentionUseCase: updateRetention}
}
func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
retention, err := h.GetActivityRetentionUseCase.GetActivityRetention()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.write(w, retention)
}
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil || hasNullValue(body) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
var request UpdateSettingsRequest
if err := json.Unmarshal(body, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), useCases.UpdateActivityRetentionCommand{
Days: request.ActivityRetentionDays,
MaxGB: request.ActivityRetentionMaxGB,
})
if err != nil {
if errors.Is(err, audit.ErrInvalidRetention) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.write(w, retention)
}
func hasNullValue(body []byte) bool {
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &fields); err != nil {
return false
}
for _, value := range fields {
if string(value) == "null" {
return true
}
}
return false
}
func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
body, err := json.Marshal(settingsResponse{
Version: h.GetSettingsUseCase.GetSettings().Version,
ActivityRetentionDays: retention.RetentionDays,
ActivityRetentionMaxGB: retention.RetentionMaxGB,
ActivityLogSizeBytes: retention.LogSizeBytes,
ActivityLastCleanupAt: retention.LastCleanupAt,
ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
ActivityNextCleanupAt: retention.NextCleanupAt,
})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
@@ -0,0 +1,182 @@
package staticimages
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const cacheControl = "public, no-cache"
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
const fallbackContentType = "application/octet-stream"
const unknownVariantCode = "unknown_variant"
const unsupportedParameterCode = "unsupported_parameter"
var contentTypeExtensions = map[string]string{
".png": "image/png",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
".gif": "image/gif",
}
type Handler struct {
openMedia *useCases.OpenMediaUseCase
serveVariant *useCases.ServeVariantUseCase
modTime time.Time
}
func NewHandler(openMedia *useCases.OpenMediaUseCase, serveVariant *useCases.ServeVariantUseCase) *Handler {
return &Handler{
openMedia: openMedia,
serveVariant: serveVariant,
modTime: time.Now(),
}
}
type requestErrorBody struct {
Status int `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Detail string `json:"detail"`
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
cleaned := cleanPath(requestedPath(r))
served, err := h.serveVariant.Serve(variantRequestFrom(r, cleaned))
if err != nil {
writeServeError(w, r, err)
return
}
switch {
case served.NotModified:
writeNotModified(w, served)
case served.Original:
h.serveOriginal(w, r, cleaned)
default:
serveVariantFile(w, r, cleaned, served)
}
}
func variantRequestFrom(r *http.Request, storedName string) useCases.ServeVariantRequest {
query := r.URL.Query()
request := useCases.ServeVariantRequest{Context: r.Context(), StoredName: storedName, IfNoneMatch: r.Header.Get("If-None-Match")}
for parameter := range query {
request.Parameters = append(request.Parameters, parameter)
}
if query.Has(image.VariantQueryParameter) {
name := query.Get(image.VariantQueryParameter)
request.Variant = &name
}
return request
}
func writeServeError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, image.ErrUnknownVariant):
writeRequestError(w, unknownVariantCode, apperr.VariantUnknownMessage, err)
case errors.Is(err, image.ErrUnsupportedParameter):
writeRequestError(w, unsupportedParameterCode, apperr.VariantUnsupportedParameterMessage, err)
case errors.Is(err, image.ErrBlobNotFound), errors.Is(err, image.ErrImageNotFound):
http.NotFound(w, r)
case errors.Is(err, image.ErrSourceTooLarge):
http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge)
default:
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
}
}
func writeRequestError(w http.ResponseWriter, code string, message string, err error) {
body, marshalErr := json.Marshal(requestErrorBody{
Status: http.StatusBadRequest,
Error: code,
Message: message,
Detail: image.VariantRequestDetail(err),
})
if marshalErr != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(body)
}
func writeNotModified(w http.ResponseWriter, served useCases.ServedVariant) {
w.Header().Set("ETag", served.ETag)
w.Header().Set("Cache-Control", cacheControl)
w.WriteHeader(http.StatusNotModified)
}
func serveVariantFile(w http.ResponseWriter, r *http.Request, name string, served useCases.ServedVariant) {
defer served.Content.Close()
w.Header().Set("ETag", served.ETag)
serveContent(w, r, name, served.ContentType, image.Blob{Content: served.Content, Size: served.Size, ModTime: served.ModTime})
}
func (h *Handler) serveOriginal(w http.ResponseWriter, r *http.Request, name string) {
blob, err := h.openMedia.Open(name)
if errors.Is(err, image.ErrBlobNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
return
}
defer blob.Content.Close()
serveContent(w, r, name, contentTypeFor(name), blob)
}
func serveContent(w http.ResponseWriter, r *http.Request, name string, contentType string, blob image.Blob) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", cacheControl)
if contentType == "image/svg+xml" {
w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy)
}
if content, ok := blob.Content.(io.ReadSeeker); ok {
http.ServeContent(w, r, name, blob.ModTime, content)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10))
if !blob.ModTime.IsZero() {
w.Header().Set("Last-Modified", blob.ModTime.UTC().Format(http.TimeFormat))
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = io.Copy(w, blob.Content)
}
}
func requestedPath(r *http.Request) string {
if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" {
return rctx.RoutePath
}
return r.URL.Path
}
func cleanPath(p string) string {
return strings.TrimPrefix(path.Clean("/"+p), "/")
}
func contentTypeFor(p string) string {
ext := path.Ext(p)
if contentType, ok := contentTypeExtensions[ext]; ok {
return contentType
}
return fallbackContentType
}
+162
View File
@@ -0,0 +1,162 @@
package update
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
)
const restartBodyLimitBytes = 1024
type Handler struct {
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
GetUserUseCase *useCases.GetUserUseCase
}
type restartRequest struct {
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"`
}
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase, getUser *useCases.GetUserUseCase) *Handler {
return &Handler{
GetUpdateStatusUseCase: getStatus,
CheckForUpdateUseCase: checkForUpdate,
StartUpdateDownloadUseCase: startDownload,
RequestRestartUseCase: requestRestart,
GetUserUseCase: getUser,
}
}
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
isOwner, ok := h.isOwner(w, r)
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
}
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
if _, err := h.CheckForUpdateUseCase.Check(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
isOwner, ok := h.isOwner(w, r)
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
status, err := h.StartUpdateDownloadUseCase.Start(r.Context())
if err != nil {
writeDownloadError(w, err)
return
}
writeStatus(w, http.StatusAccepted, status)
}
func (h *Handler) Restart(w http.ResponseWriter, r *http.Request) {
kind, ok := decodeRestartKind(w, r)
if !ok {
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
status, err := h.RequestRestartUseCase.Request(r.Context(), userId, kind)
if err != nil {
writeRestartError(w, err)
return
}
writeStatus(w, http.StatusAccepted, status)
}
func decodeRestartKind(w http.ResponseWriter, r *http.Request) (string, bool) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, restartBodyLimitBytes))
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return "", false
}
if strings.TrimSpace(string(body)) == "" {
return "", true
}
var request restartRequest
if err := json.Unmarshal(body, &request); err != nil || validator.Validate.Struct(request) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return "", false
}
if request.Kind == nil {
return "", true
}
return *request.Kind, true
}
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return false, false
}
return currentUser.Role == user.RoleOwner, true
}
func writeDownloadError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrNotPrepared):
http.Error(w, apperr.UpdateNotPreparedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrJobInProgress), errors.Is(err, domain.ErrRestartPending):
http.Error(w, apperr.UpdateAlreadyRunningErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrNothingToUpdate):
http.Error(w, apperr.NothingToUpdateErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrPreflightRefused):
http.Error(w, apperr.UpdateRefusedErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeRestartError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrNotPrepared):
http.Error(w, apperr.UpdateNotPreparedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrJobInProgress), errors.Is(err, domain.ErrRestartPending):
http.Error(w, apperr.UpdateAlreadyRunningErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrRestartKindMismatch):
http.Error(w, apperr.UpdateRestartMismatchErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeStatus(w http.ResponseWriter, code int, status domain.UpdateStatus) {
body, err := json.Marshal(status)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(code)
_, _ = w.Write(body)
}
+204
View File
@@ -0,0 +1,204 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
type InviteHandler struct {
CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
}
type CreateInviteRequest struct {
Email string `json:"email" validate:"required,inviteemail"`
}
type inviteResponse struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type inviteListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type userListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func NewInviteHandler(createInvite *useCases.CreateInviteUseCase, cancelInvite *useCases.CancelInviteUseCase, renewInvite *useCases.RenewInviteUseCase, listUsersAndInvites *useCases.ListUsersAndInvitesUseCase) *InviteHandler {
return &InviteHandler{
CreateInviteUseCase: createInvite,
CancelInviteUseCase: cancelInvite,
RenewInviteUseCase: renewInvite,
ListUsersAndInvitesUseCase: listUsersAndInvites,
}
}
func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
var request CreateInviteRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
Email: request.Email,
InvitedBy: userId,
})
if err != nil {
writeCreateInviteError(w, err)
return
}
writeInviteResponse(w, http.StatusCreated, created)
}
func writeInviteResponse(w http.ResponseWriter, status int, entry invite.Invite) {
body, err := json.Marshal(inviteResponse{
ID: entry.ID,
Kind: useCases.EntryKindInvite,
Email: entry.Email,
Status: string(entry.StatusAt(time.Now())),
CreatedAt: entry.CreatedAt,
ExpiresAt: entry.ExpiresAt,
})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func parseInviteId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func (h *InviteHandler) CancelInvite(w http.ResponseWriter, r *http.Request) {
id, err := parseInviteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.CancelInviteUseCase.CancelInvite(r.Context(), id); err != nil {
writeManageInviteError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
id, err := parseInviteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{ID: id, RenewedBy: userId})
if err != nil {
writeManageInviteError(w, err)
return
}
writeInviteResponse(w, http.StatusOK, renewed)
}
func writeManageInviteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invite.ErrInviteNotFound):
http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, invite.ErrInviteAlreadyAccepted):
http.Error(w, apperr.InviteAlreadyAcceptedErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrInviteEmailNotSent):
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeCreateInviteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrPendingInvite):
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrInviteEmailNotSent):
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt})
continue
}
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt})
}
body, err := json.Marshal(items)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
+70
View File
@@ -0,0 +1,70 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type Handler struct {
UpdateProfileUseCase *useCases.UpdateUserProfileUseCase
}
type UpdateProfileRequest struct {
DisplayName *string `json:"display_name" validate:"required"`
}
func NewHandler(updateProfile *useCases.UpdateUserProfileUseCase) *Handler {
return &Handler{
UpdateProfileUseCase: updateProfile,
}
}
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
var request UpdateProfileRequest
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
err = validator.Validate.Struct(request)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
updatedUser, err := h.UpdateProfileUseCase.UpdateDisplayName(r.Context(), userId, *request.DisplayName)
if err != nil {
if errors.Is(err, user.ErrInvalidDisplayName) {
http.Error(w, apperr.DisplayNameErrorMessage, http.StatusBadRequest)
return
}
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(publicuser.New(updatedUser))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
+118
View File
@@ -0,0 +1,118 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)
const bearerScheme = "bearer "
type apiKeyIdContextKey struct{}
type apiKeyNameContextKey struct{}
func bearerAPIKey(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization")
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
return "", false
}
credential := strings.TrimSpace(header[len(bearerScheme):])
return credential, strings.HasPrefix(credential, apikey.RawKeyPrefix)
}
func apiKeyIdFrom(r *http.Request) (uint32, bool) {
id, ok := r.Context().Value(apiKeyIdContextKey{}).(uint32)
return id, ok
}
func apiKeyNameFrom(r *http.Request) string {
name, _ := r.Context().Value(apiKeyNameContextKey{}).(string)
return name
}
func RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, isKey := bearerAPIKey(r); isKey {
http.Error(w, apperr.SessionRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func RejectAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, authenticatedByKey := apiKeyIdFrom(r); authenticatedByKey {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
return func(next http.Handler) http.Handler {
limited := limiter(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawKey, isKey := bearerAPIKey(r)
if !isKey {
next.ServeHTTP(w, r)
return
}
authenticated, err := authenticate.AuthenticateAPIKey(rawKey)
if err != nil {
writeAPIKeyError(w, err)
return
}
token, err := sessionTokenFor(authenticated)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
ctx := jwtauth.NewContext(r.Context(), token, nil)
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
limited.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func writeAPIKeyError(w http.ResponseWriter, err error) {
if errors.Is(err, apikey.ErrAPIKeyInvalid) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) {
token := jwt.New()
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil {
return nil, err
}
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
return nil, err
}
return token, nil
}
func apiKeyRateLimitKey(r *http.Request) string {
id, _ := apiKeyIdFrom(r)
return strconv.FormatUint(uint64(id), 10)
}
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
)
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := audit.Origin{
Actor: auditActor(getUser, r),
Source: auditSource(r),
IP: clientIP(r, trustedProxies),
UserAgent: truncatedUserAgent(r),
}
next.ServeHTTP(w, r.WithContext(audit.WithOrigin(r.Context(), origin)))
})
}
}
func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor {
if id, ok := apiKeyIdFrom(r); ok {
return audit.APIKeyActor(id, apiKeyNameFrom(r))
}
userId, ok := claims.UserId(r)
if !ok {
return audit.AnonymousActor("")
}
current, err := getUser.GetUser(userId)
if err != nil {
return audit.AnonymousActor("")
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok || tokenVersion != current.TokenVersion {
return audit.AnonymousActor("")
}
return audit.UserActor(current)
}
func auditSource(r *http.Request) audit.Source {
if _, ok := apiKeyIdFrom(r); ok {
return audit.SourceAPIKey
}
return audit.SourceWeb
}
func clientIP(r *http.Request, trustedProxies []string) string {
realIP := r.Header.Get("X-Real-IP")
if realIP != "" && isTrustedProxy(r.RemoteAddr, trustedProxies) {
return realIP
}
return hostOf(r.RemoteAddr)
}
func truncatedUserAgent(r *http.Request) string {
runes := []rune(r.Header.Get("User-Agent"))
if len(runes) > audit.MaxUserAgentLength {
runes = runes[:audit.MaxUserAgentLength]
}
return string(runes)
}
+115
View File
@@ -0,0 +1,115 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
)
func JsonContentTypeMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}
func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter, r *http.Request) (user.User, bool) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return user.User{}, false
}
currentUser, err := getUser.GetUser(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return user.User{}, false
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return user.User{}, false
}
return currentUser, true
}
func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if tokenVersion != currentUser.TokenVersion {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if !currentUser.IsVerified {
http.Error(w, apperr.VerificationRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if currentUser.Role != user.RoleOwner {
http.Error(w, apperr.OwnerRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
func RequireOwnerOrAdmin(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin {
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+74
View File
@@ -0,0 +1,74 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"net/http"
"strconv"
"sync"
"time"
)
type rateLimitWindow struct {
start time.Time
count int
}
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, func(r *http.Request) string { return r.RemoteAddr })
}
func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, userRateLimitKey)
}
func userRateLimitKey(r *http.Request) string {
userId, _ := claims.UserId(r)
return strconv.FormatUint(uint64(userId), 10)
}
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
var mu sync.Mutex
windows := make(map[string]*rateLimitWindow)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retryAfter, allowed := recordAttempt(&mu, windows, keyOf(r), maxAttempts, window)
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
func recordAttempt(mu *sync.Mutex, windows map[string]*rateLimitWindow, key string, maxAttempts int, window time.Duration) (int, bool) {
mu.Lock()
defer mu.Unlock()
now := time.Now()
current, exists := windows[key]
if !exists || now.Sub(current.start) >= window {
current = &rateLimitWindow{start: now}
windows[key] = current
}
current.count++
if current.count > maxAttempts {
return retryAfterSeconds(window - now.Sub(current.start)), false
}
return 0, true
}
func retryAfterSeconds(remaining time.Duration) int {
seconds := int(remaining / time.Second)
if remaining%time.Second > 0 {
seconds++
}
if seconds < 0 {
return 0
}
return seconds
}
+20
View File
@@ -0,0 +1,20 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)
func RequireSignedIn(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil || jwt.Validate(token) != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
+33
View File
@@ -0,0 +1,33 @@
package middleware
import "net/http"
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self' 'wasm-unsafe-eval'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'"
const strictTransportSecurity = "max-age=31536000"
func SecurityHeaders(hsts bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
if hsts {
h.Set("Strict-Transport-Security", strictTransportSecurity)
}
next.ServeHTTP(w, r)
})
}
}
+44
View File
@@ -0,0 +1,44 @@
package middleware
import (
"net"
"net/http"
)
func TrustedProxyRealIP(trustedProxies []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
realIP := r.Header.Get("X-Real-IP")
if realIP != "" && isTrustedProxy(r.RemoteAddr, trustedProxies) {
r.RemoteAddr = withRewrittenHost(r.RemoteAddr, realIP)
}
next.ServeHTTP(w, r)
})
}
}
func isTrustedProxy(remoteAddr string, trustedProxies []string) bool {
host := hostOf(remoteAddr)
for _, trusted := range trustedProxies {
if host == trusted {
return true
}
}
return false
}
func hostOf(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return remoteAddr
}
return host
}
func withRewrittenHost(remoteAddr string, newHost string) string {
_, port, err := net.SplitHostPort(remoteAddr)
if err != nil {
return newHost
}
return net.JoinHostPort(newHost, port)
}
+59
View File
@@ -0,0 +1,59 @@
package apperr
const LogsErrorMessage = "Access to the requested resource is forbidden due to incorrect password and/or username."
const BodyErrorMessage = "The request cannot be processed due to a mismatch in the format of the body."
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
const ArticleNotFoundErrorMessage = "Article not found"
const ImageNotFoundErrorMessage = "Image not found"
const DisplayNameErrorMessage = "Display name must be 60 characters or fewer."
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
const RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
const UnauthorizedErrorMessage = "You must be logged in to access this resource."
const VerificationFailedErrorMessage = "The verification code is invalid or has expired."
const AlreadyVerifiedErrorMessage = "This account is already verified and cannot be cancelled this way."
const VerificationRequiredErrorMessage = "You must verify your account to access this resource."
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address."
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
const InviteNotFoundErrorMessage = "Invite not found"
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired."
const OwnerRequiredErrorMessage = "Only the owner can do this."
const UsernameTakenErrorMessage = "That username is taken. Try another."
const MediaQueryErrorMessage = "The media query is invalid."
const MediaFilenameErrorMessage = "Enter a filename."
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
const MediaMergeRequestErrorMessage = "Choose an image to keep and at least one other image to merge into it."
const UpdateNotPreparedErrorMessage = "This server cannot update itself yet."
const UpdateAlreadyRunningErrorMessage = "An update is already running or waiting to be installed."
const NothingToUpdateErrorMessage = "There is nothing to update."
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
const APIKeyNotFoundErrorMessage = "API key not found"
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
const SessionRequiredErrorMessage = "This action needs a real session."
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
const VariantNotFoundErrorMessage = "Variant not found"
const VariantNameErrorMessage = "Use lowercase letters, numbers and dashes, 2 to 32 characters."
const VariantDimensionsErrorMessage = "Width and height must be between 1 and 8000, and one of them is required."
const VariantFitErrorMessage = "Fit must be cover, contain or inside."
const VariantQualityErrorMessage = "Quality must be between 1 and 100."
const VariantInvalidErrorMessage = "The variant is not valid."
const VariantExistsErrorMessageFormat = "A variant named “%s” already exists. Choose another name."
const VariantFormatErrorMessageFormat = "Format must be %s."
const VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant."
const VariantSourceUnsupportedErrorMessage = "Variants aren't generated for this format."
const VariantSourceUnreadableErrorMessage = "The source image can't be read, so this variant can't be generated."
const CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image."
const CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
const VariantUnknownMessage = "Unknown variant"
const VariantUnsupportedParameterMessage = "Unsupported parameter"
+50
View File
@@ -0,0 +1,50 @@
package claims
import (
"math"
"net/http"
"github.com/go-chi/jwtauth/v5"
)
func UserId(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
return 0, false
}
rawUserId, ok := claims["user_id"]
if !ok {
return 0, false
}
userIdFloat, ok := rawUserId.(float64)
if !ok {
return 0, false
}
if userIdFloat < 0 || userIdFloat > math.MaxUint32 || userIdFloat != math.Trunc(userIdFloat) {
return 0, false
}
return uint32(userIdFloat), true
}
func TokenVersion(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
return 0, false
}
rawTokenVersion, ok := claims["token_version"]
if !ok {
return 0, false
}
tokenVersionFloat, ok := rawTokenVersion.(float64)
if !ok {
return 0, false
}
return uint32(tokenVersionFloat), true
}
+30
View File
@@ -0,0 +1,30 @@
package publicuser
import (
"RenewCMS/internal/domain/user"
"time"
)
type PublicUser struct {
ID uint32 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsVerified bool `json:"is_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DisplayName string `json:"display_name"`
Role user.Role `json:"role"`
}
func New(u user.User) PublicUser {
return PublicUser{
ID: u.ID,
Username: u.Username,
Email: u.Email,
IsVerified: u.IsVerified,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
DisplayName: u.DisplayName,
Role: u.Role,
}
}
+49
View File
@@ -0,0 +1,49 @@
package upload
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/image"
"errors"
"net/http"
)
const multipartOverheadAllowance = 1 << 20
const fileField = "file"
func ReadImageInput(w http.ResponseWriter, r *http.Request, uploadedById uint32) (image.ImageInput, func(), bool) {
r.Body = http.MaxBytesReader(w, r.Body, image.MaxImageSize+multipartOverheadAllowance)
file, header, err := r.FormFile(fileField)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, image.ErrImageTooLarge.Error(), http.StatusRequestEntityTooLarge)
return image.ImageInput{}, nil, false
}
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return image.ImageInput{}, nil, false
}
input := image.ImageInput{
Content: file,
Filename: header.Filename,
Size: header.Size,
ContentType: header.Header.Get("Content-Type"),
UploadedByID: uploadedById,
}
return input, func() { _ = file.Close() }, true
}
func WriteCreateError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, image.ErrImageTypeNotAllowed):
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
case errors.Is(err, image.ErrImageTooLarge):
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
case errors.Is(err, image.ErrImageUnreadable):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+29
View File
@@ -0,0 +1,29 @@
package validator
import (
"RenewCMS/internal/domain/image"
"regexp"
"github.com/go-playground/validator/v10"
)
const inviteEmailTag = "inviteemail"
const variantNameTag = "variantname"
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
var Validate *validator.Validate
func InitValidator() {
Validate = validator.New()
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
}
func isVariantName(field validator.FieldLevel) bool {
return image.IsValidVariantName(field.Field().String())
}
func isInviteEmail(field validator.FieldLevel) bool {
return inviteEmailPattern.MatchString(field.Field().String())
}
+376
View File
@@ -0,0 +1,376 @@
package api
import (
"RenewCMS/api/handlers/activity"
"RenewCMS/api/handlers/apikey"
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/update"
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/internal/infrastructure/config"
"encoding/json"
"net/http"
"time"
"github.com/MadAppGang/httplog"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/jwtauth/v5"
)
func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) {
if environment == "development" {
r.Use(httplog.LoggerWithName(routerName))
}
}
func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
msg, _ := json.Marshal(map[string]string{"message": "Hello World"})
_, _ = w.Write(msg)
}
const (
apiKeyMaxRequests = 120
apiKeyWindow = time.Minute
)
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
c.apiKeyAuthOnce.Do(func() {
c.apiKeyAuthMiddleware = middleware.APIKeyAuth(c.AuthenticateAPIKeyUseCase, apiKeyMaxRequests, apiKeyWindow)
})
return c.apiKeyAuthMiddleware
}
func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
})
return r
}
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter()
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
r.Post("/register", loginHandler.Register)
r.Get("/registration", loginHandler.RegistrationStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/login", loginHandler.Login)
})
r.Post("/logout", loginHandler.Logout)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
})
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Get("/invite", acceptInviteHandler.GetInvite)
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Get("/me", meHandler.Me)
})
return r
}
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter()
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe)
r.Get("/", inviteHandler.ListUsers)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/invite", inviteHandler.CreateInvite)
r.Delete("/invite/{id}", inviteHandler.CancelInvite)
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
})
return r
}
func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", settingsHandler.GetSettings)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Patch("/", settingsHandler.UpdateSettings)
})
return r
}
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", activityHandler.List)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/summary", activityHandler.Summary)
r.Get("/export", activityHandler.Export)
})
return r
}
const (
updateCheckMaxAttempts = 5
updateCheckWindow = time.Minute
updateDownloadMaxAttempts = 5
updateDownloadWindow = time.Minute
)
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/update", updateHandler.GetStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
r.Post("/update/download", updateHandler.Download)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Post("/update/restart", updateHandler.Restart)
})
return r
}
func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", apiKeyHandler.Create)
r.Get("/", apiKeyHandler.List)
r.Delete("/{id}", apiKeyHandler.Revoke)
return r
}
func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.Get("/purge-preview", mediaHandler.PreviewPurge)
r.Post("/purge", mediaHandler.RunPurge)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RejectAPIKey)
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/{id}/variants", mediaHandler.ListVariants)
r.Post("/{id}/variants", mediaHandler.GenerateAllVariants)
r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
})
r.Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge)
r.Patch("/{id}", mediaHandler.Rename)
r.Delete("/{id}", mediaHandler.Delete)
r.Get("/{id}/usage", mediaHandler.Usage)
return r
}
func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase, container.GetVariantCacheStatsUseCase, container.PurgeVariantCacheUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/", variantHandler.List)
r.Post("/", variantHandler.Create)
r.Get("/cache", variantHandler.CacheStats)
r.Post("/cache/purge", variantHandler.PurgeCache)
r.Put("/{name}", variantHandler.Update)
r.Patch("/{name}", variantHandler.SetEnabled)
r.Delete("/{name}", variantHandler.Delete)
r.Post("/{name}/purge", variantHandler.Purge)
return r
}
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r := chi.NewRouter()
enableLoggingIfDev(r, "backend", settings.Environment)
r.Use(chimiddleware.GetHead)
r.Use(middleware.JsonContentTypeMiddleware)
r.Get("/", GetHelloWorld)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
})
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r
}
func InitFrontendRoutes(environment string) *chi.Mux {
r := chi.NewRouter()
enableLoggingIfDev(r, "frontend", environment)
r.Mount("/", frontend.NewFrontendRouter())
return r
}
func GetHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
}
func enableCorsIfConfigured(r *chi.Mux, origins []string) {
if len(origins) == 0 {
return
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300,
}))
}
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
backend := InitBackendRoutes(container, settings)
frontend := InitFrontendRoutes(settings.Environment)
apiRouter := chi.NewRouter()
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth)
apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
apiRouter.Mount("/", frontend)
return apiRouter
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol class="bi bi-0-circle" viewBox="0 0 16 16" id="0-circle"><path d="M7.988 12.158c-1.851 0-2.941-1.57-2.941-3.99V7.84c0-2.408 1.101-3.996 2.965-3.996 1.857 0 2.935 1.57 2.935 3.996v.328c0 2.408-1.101 3.99-2.959 3.99M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8"/></symbol><symbol class="bi bi-0-circle-fill" viewBox="0 0 16 16" id="0-circle-fill"><path d="M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-8.012 4.158c1.858 0 2.96-1.582 2.96-3.99V7.84c0-2.426-1.079-3.996-2.936-3.996-1.864 0-2.965 1.588-2.965 3.996v.328c0 2.42 1.09 3.99 2.941 3.99"/></symbol><symbol class="bi bi-0-square" viewBox="0 0 16 16" id="0-square"><path d="M7.988 12.158c-1.851 0-2.941-1.57-2.941-3.99V7.84c0-2.408 1.101-3.996 2.965-3.996 1.857 0 2.935 1.57 2.935 3.996v.328c0 2.408-1.101 3.99-2.959 3.99M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/></symbol><symbol class="bi bi-0-square-fill" viewBox="0 0 16 16" id="0-square-fill"><path d="M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm5.988 12.158c-1.851 0-2.941-1.57-2.941-3.99V7.84c0-2.408 1.101-3.996 2.965-3.996 1.857 0 2.935 1.57 2.935 3.996v.328c0 2.408-1.101 3.99-2.959 3.99"/></symbol><symbol class="bi bi-1-circle" viewBox="0 0 16 16" id="1-circle"><path d="M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8m15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0M9.283 4.002V12H7.971V5.338h-.065L6.072 6.656V5.385l1.899-1.383z"/></symbol><symbol class="bi bi-1-circle-fill" viewBox="0 0 16 16" id="1-circle-fill"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M9.283 4.002H7.971L6.072 5.385v1.271l1.834-1.318h.065V12h1.312z"/></symbol><symbol class="bi bi-1-square" viewBox="0 0 16 16" id="1-square"><path d="M9.283 4.002V12H7.971V5.338h-.065L6.072 6.656V5.385l1.899-1.383z"/><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/></symbol><symbol class="bi bi-1-square-fill" viewBox="0 0 16 16" id="1-square-fill"><path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm7.283 4.002V12H7.971V5.338h-.065L6.072 6.656V5.385l1.899-1.383z"/></symbol><symbol class="bi bi-123" viewBox="0 0 16 16" id="123"><path d="M2.873 11.297V4.142H1.699L0 5.379v1.137l1.64-1.18h.06v5.961zm3.213-5.09v-.063c0-.618.44-1.169 1.196-1.169.676 0 1.174.44 1.174 1.106 0 .624-.42 1.101-.807 1.526L4.99 10.553v.744h4.78v-.99H6.643v-.069L8.41 8.252c.65-.724 1.237-1.332 1.237-2.27C9.646 4.849 8.723 4 7.308 4c-1.573 0-2.36 1.064-2.36 2.15v.057zm6.559 1.883h.786c.823 0 1.374.481 1.379 1.179.01.707-.55 1.216-1.421 1.21-.77-.005-1.326-.419-1.379-.953h-1.095c.042 1.053.938 1.918 2.464 1.918 1.478 0 2.642-.839 2.62-2.144-.02-1.143-.922-1.651-1.551-1.714v-.063c.535-.09 1.347-.66 1.326-1.678-.026-1.053-.933-1.855-2.359-1.845-1.5.005-2.317.88-2.348 1.898h1.116c.032-.498.498-.944 1.206-.944.703 0 1.206.435 1.206 1.07.005.64-.504 1.106-1.2 1.106h-.75z"/></symbol><symbol class="bi bi-2-circle" viewBox="0 0 16 16" id="2-circle"><path d="M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8m15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0M6.646 6.24v.07H5.375v-.064c0-1.213.879-2.402 2.637-2.402 1.582 0 2.613.949 2.613 2.215 0 1.002-.6 1.667-1.287 2.43l-.096.107-1.974 2.22v.077h3.498V12H5.422v-.832l2.97-3.293c.434-.475.903-1.008.903-1.705 0-.744-.557-1.236-1.313-1.236-.843 0-1.336.615-1.336 1.306"/></symbol><symbol class="bi bi-2-circle-fill" viewBox="0 0 16 16" id="2-circle-fill"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M6.646 6.24c0-.691.493-1.306 1.336-1.306.756 0 1.313.492 1.313 1.236 0 .697-.469 1.23-.902 1.705l-2.971 3.293V12h5.344v-1.107H7.268v-.077l1.974-2.22.096-.107c.688-.763 1.287-1.428 1.287-2.43 0-1.266-1.031-2.215-2.613-2.215-1.758 0-2.637 1.19-2.637 2.402v.065h1.271v-.07Z"/></symbol><symbol class="bi bi-2-square" viewBox="0 0 16 16" id="2-square"><path d="M6.646 6.24v.07H5.375v-.064c0-1.213.879-2.402 2.637-2.402 1.582 0 2.613.949 2.613 2.215 0 1.002-.6 1.667-1.287 2.43l-.096.107-1.974 2.22v.077h3.498V12H5.422v-.832l2.97-3.293c.434-.475.903-1.008.903-1.705 0-.744-.557-1.236-1.313-1.236-.843 0-1.336.615-1.336 1.306"/><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/></symbol><symbol class="bi bi-2-square-fill" viewBox="0 0 Line truncated

Before

Width:  |  Height:  |  Size: 1.0 MiB

-6
View File
@@ -1,6 +0,0 @@
@charset "UTF-8";/*!
* Bootstrap v5.3.1 (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--Line truncated
/*# sourceMappingURL=bootstrap.min.css.map */
-1
View File
@@ -1 +0,0 @@
{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_root.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_color-mode.scss","../../scss/_reboot.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_color-bg.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_focus-ring.scss","../../scss/helpers/_icon-link.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"iBACE;;;;ACDF,MCOA,sBDEI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAIA,uBAAA,QAAA,yBAAA,QAAA,uBAAA,QAAA,oBAAA,QAAA,uBAAA,QAAA,sBAAA,QAAA,qBAAA,QAAA,oBAAA,QAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,KAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAOA,sBAAA,0BE2OI,oBAAA,KFzOJ,sBAAA,IACA,sBAAA,IAKA,gBAAA,QACA,oBAAA,EAAA,CAAA,EAAA,CAAA,GACA,aAAA,KACA,iBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,oBAAA,KACA,wBAAA,CAAA,CAAA,CAAA,CAAA,EAEA,qBAAA,uBACA,yBAAA,EAAA,CAAA,EAAA,CAAA,GACA,kBAAA,QACA,sBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,oBAAA,sBACA,wBAAA,EAAA,CAAA,EAAA,CAAA,GACA,iBAAA,QACA,qBAAA,GAAA,CAAA,GAAA,CAAA,IAGA,mBAAA,QAEA,gBAAA,QACA,oBAAA,EAAA,CAAA,GAAA,CAAA,IACA,qBAAA,UAEA,sBAAA,QACA,0BAAA,EAAA,CAAA,EAAA,CAAA,IAMA,gBAAA,QACA,kBAAA,QAGA,kBAAA,IACA,kBAAA,MACA,kBAAA,QACA,8BAAA,qBAEA,mBAAA,SACA,sBAAA,QACA,sBAAA,OACA,sBAAA,KACA,uBAAA,KACA,uBAAA,4BACA,wBAAA,MAGA,gBAAA,EAAA,OAAA,KAAA,oBACA,mBAAA,EAAA,SAAA,QAAA,qBACA,mBAAA,EAAA,KAAA,KAAA,qBACA,sBAAA,MAAA,EAAA,IAAA,IAAA,qBAIA,sBAAA,QACA,wBAAA,KACA,sBAAA,yBAIA,sBAAA,QACA,6BAAA,QACA,wBAAA,QACA,+BAAA,QG/GE,qBHqHA,aAAA,KAGA,gBAAA,QACA,oBAAA,GAAA,CAAA,GAAA,CAAA,IACA,aAAA,QACA,iBAAA,EAAA,CAAA,EAAA,CAAA,GAEA,oBAAA,KACA,wBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,qBAAA,0BACA,yBAAA,GAAA,CAAA,GAAA,CAAA,IACA,kBAAA,QACA,sBAAA,EAAA,CAAA,EAAA,CAAA,GAEA,oBAAA,yBACA,wBAAA,GAAA,CAAA,GAAA,CAAA,IACA,iBAAA,QACA,qBAAA,EAAA,CAAA,EAAA,CAAA,GAGE,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAIA,uBAAA,QAAA,yBAAA,QAAA,uBAAA,QAAA,oBAAA,QAAA,uBAAA,QAAA,sBAAA,QAAA,qBAAA,QAAA,oBAAA,QAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAGF,mBAAA,QAEA,gBAAA,QACA,sBAAA,QACA,oBAAA,GAAA,CAAA,GAAA,CAAA,IACA,0BAAA,GAAA,CAAA,GAAA,CAAA,IAEA,gBAAA,QAEALine truncated
-7
View File
@@ -1,7 +0,0 @@
/*!
* Bootstrap v5.3.1 (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("@popperjs/core")):"function"==typeof define&&define.amd?define(["@popperjs/core"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e(t.Popper)}(this,(function(t){"use strict";function e(t){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(t)for(const i in t)if("default"!==i){const s=Object.getOwnPropertyDescriptor(t,i);Object.defineProperty(e,i,s.get?s:{enumerable:!0,get:()=>t[i]})}return e.default=t,Object.freeze(e)}const i=e(t),s=new Map,n={set(t,e,i){s.has(t)||s.set(t,new Map);const n=s.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>s.has(t)&&s.get(t).get(e)||null,remove(t,e){if(!s.has(t))return;const i=s.get(t);i.delete(e),0===i.size&&s.delete(t)}},o="transitionend",r=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),a=t=>{t.dispatchEvent(new Event(o))},l=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),c=t=>l(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(r(t)):null,h=t=>{if(!l(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},d=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),u=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?u(t.parentNode):null},_=()=>{},g=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,m=[],p=()=>"rtl"===document.documentElement.dir,b=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,s=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=s,t.jQueryInterface)}},"loading"===document.readyState?(m.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of m)t()})),m.push(e)):e()},v=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,y=(t,e,i=!0)=>{if(!i)return void v(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const s=Number.parseFloat(e),n=Number.parseFloat(i);return s||n?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let n=!1;const r=({target:i})=>{i===e&&(n=!0,e.removeEventListener(o,r),v(t))};e.addEventListener(o,r),setTimeout((()=>{n||a(e)}),s)},w=(t,e,i,s)=>{const n=t.length;let o=t.indexOf(e);return-1===o?!i&&s?t[n-1]:t[0]:(o+=i?1:-1,s&&(o=(o+n)%n),t[Math.max(0,Math.min(o,n-1))])},A=/[^.]*(?=\..*)\.|.*/,E=/\..*/,C=/::\d+$/,T={};let k=1;const $={mouseenter:"mouseover",mouseleave:"mouseout"},S=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${k++}`||t.uidEvent||k++}function O(t){const e=L(t);return t.uidEvent=e,T[e]=T[e]||{},T[e]}function I(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function D(t,e,i){const s="string"==typeof e,n=s?i:e||i;let o=M(t);return S.has(o)||(o=t),[s,n,o]}function N(t,e,i,s,n){if("string"!=typeof e||!t)return;let[o,r,a]=D(e,i,s);if(e in $){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=O(t),c=l[a]||(l[a]={}),h=I(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&n);const d=L(r,e.replace(A,"")),u=o?function(t,e,i){return function s(n){const o=t.querySelectorAll(e);for(let{target:r}=n;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return F(n,{delegateTarget:r}),s.oneOff&&j.off(t,n.type,e,i),i.apply(r,[n])}}(t,i,r):function(t,e){return function i(s){return F(s,{delegateTarget:t}),i.oneOff&&j.off(t,s.type,e),e.apply(t,[s])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=n,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function P(t,e,i,s,n){const o=I(e[i],s,n);o&&(t.removeEventListener(i,o,Boolean(n)),delete e[i][o.uidEvent])}function x(t,e,i,s){const n=e[i]||{};for(const[o,r]of Object.entries(n))o.includes(s)&&PLine truncated
//# sourceMappingURL=bootstrap.min.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"names":["elementMap","Map","Data","set","element","key","instance","has","instanceMap","get","size","console","error","Array","from","keys","remove","delete","TRANSITION_END","parseSelector","selector","window","CSS","escape","replace","match","id","triggerTransitionEnd","dispatchEvent","Event","isElement","object","jquery","nodeType","getElement","length","document","querySelector","isVisible","getClientRects","elementIsVisible","getComputedStyle","getPropertyValue","closedDetails","closest","summary","parentNode","isDisabled","Node","ELEMENT_NODE","classList","contains","disabled","hasAttribute","getAttribute","findShadowRoot","documentElement","attachShadow","getRootNode","root","ShadowRoot","noop","reflow","offsetHeight","getjQuery","jQuery","body","DOMContentLoadedCallbacks","isRTL","dir","defineJQueryPlugin","plugin","callback","$","name","NAME","JQUERY_NO_CONFLICT","fn","jQueryInterface","Constructor","noConflict","readyState","addEventListener","push","execute","possibleCallback","args","defaultValue","executeAfterTransition","transitionElement","waitForTransition","emulatedDuration","transitionDuration","transitionDelay","floatTransitionDuration","Number","parseFloat","floatTransitionDelay","split","getTransitionDurationFromElement","called","handler","target","removeEventListener","setTimeout","getNextActiveElement","list","activeElement","shouldGetNext","isCycleAllowed","listLength","index","indexOf","Math","max","min","namespaceRegex","stripNameRegex","stripUidRegex","eventRegistry","uidEvent","customEvents","mouseenter","mouseleave","nativeEvents","Set","makeEventUid","uid","getElementEvents","findHandler","events","callable","delegationSelector","Object","values","find","event","normalizeParameters","originalTypeEvent","delegationFunction","isDelegated","typeEvent","getTypeEvent","addHandler","oneOff","wrapFunction","relatedTarget","delegateTarget","call","this","handlers","previousFunction","domElements","querySelectorAll","domElement","hydrateObj","EventHandler","off","type","apply","bootstrapDelegationHandler","bootstrapHandler","removeHandler","Boolean","removeNamespacedHandlers","namespace","storeElementEvent","handlerKey","entries","includes","on","one","inNamespace","isNamespace","startsWith","elementEvent","slice","keyHandlers","trigger","jQueryEvent","bubbles","nativeDispatch","defaultPrevented","isPropagationStopped","isImmediatePropagationStopped","isDefaultPrevented","evt","cancelable","preventDefault","obj","meta","value","_unused","defineProperty","configurable","normalizeData","toString","JSON","parse","decodeURIComponent","normalizeDataKey","chr","toLowerCase","Manipulator","setDataAttribute","setAttribute","removeDataAttribute","removeAttribute","getDataAttributes","attributes","bsKeys","dataset","filter","pureKey","charAt","getDataAttribute","Config","Default","DefaultType","Error","_getConfig","config","_mergeConfigObj","_configAfterMerge","_typeCheckConfig","jsonConfig","constructor","configTypes","property","expectedTypes","valueType","prototype","RegExp","test","TypeError","toUpperCase","BaseComponent","super","_element","_config","DATA_KEY","dispose","EVENT_KEY","propertyName","getOwnPropertyNames","_queueCallback","isAnimated","getInstance","getOrCreateInstance","VERSION","eventName","getSelector","hrefAttribute","trim","SelectorEngine","concat","Element","findOne","children","child","matches","parents","ancestor","prev","previous","previousElementSibling","next","nextElementSibling","focusableChildren","focusables","map","join","el","getSelectorFromElement","getElementFromSelector","getMultipleElementsFromSelector","enableDismissTrigger","component","method","clickEvent","tagName","EVENT_CLOSE","EVENT_CLOSED","Alert","close","_destroyElement","each","data","undefined","SELECTOR_DATA_TOGGLE","Button","toggle","button","EVENT_TOUCHSTART","EVENT_TOUCHMOVE","EVENT_TOUCHEND","EVENT_POINTERDOWN","EVENT_POINTERUP","endCallback","leftCallback","rightCallback","Swipe","isSupported","_deltaX","_supportPointerEvents","PointerEvent","_initEvents","_start","_eventIsPointerPenTouch","clientX","touches","_end","_handleSwipe","_move","absDeltaX","abs","direction","add","pointerType","navigator","maxTouchPoints","DATA_API_KEY","ORDER_NEXT","ORDER_PREV","DIRECTION_LEFT","DIRECTION_RIGHT","EVENT_SLIDE","EVENT_SLID","EVENT_KEYDOWN","EVENT_MOUSEENTER","EVENT_MOUSELEAVE","EVENT_DRAG_START","EVENT_LOAD_DATA_API","EVENT_CLICK_DATA_API","CLASS_NAME_CAROUSEL","CLASS_NAME_ACTIVE","SELECTOR_ACTIVE","SELECTOR_ITEM","SELECTOR_ACTIVE_ITEM","KEY_TO_DIRECTION","ArrowLeft","ArrowRight","interval","keyboard","pause","ride","touch","wrap","Carousel","_interval","_activeElement","_isSliding","touchTimeout","_swipeHelper","_indicatorsElement","_addEventListeners","cycle","_slide","nextWhenVisible","hidden","_clearInterval","_updateInterval","setInterval","_maybeEnableCycle","to","items","_getItems","activeIndex","_getItemIndex","_getActive","order","defaultInterval","_keydown","_addTouchEvenLine truncated
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
tinymce.IconManager.add("default",{icons:{"accessibility-check":'<svg width="24" height="24"><path d="M12 2a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2c0-1.1.9-2 2-2Zm8 7h-5v12c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-5c0-.6-.4-1-1-1a1 1 0 0 0-1 1v5c0 .6-.4 1-1 1a1 1 0 0 1-1-1V9H4a1 1 0 1 1 0-2h16c.6 0 1 .4 1 1s-.4 1-1 1Z" fill-rule="nonzero"/></svg>',"accordion-toggle":'<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.3 7.3a1 1 0 0 1 1.4 0L16 9.6l2.3-2.3a1 1 0 1 1 1.4 1.4L16 12.4l-3.7-3.7a1 1 0 0 1 0-1.4ZM4.3 11.7a1 1 0 0 1 0-1.4L6.6 8 4.3 5.7a1 1 0 0 1 1.4-1.4L9.4 8l-3.7 3.7a1 1 0 0 1-1.4 0Z"/></svg>',accordion:'<svg width="24" height="24"><rect x="12" y="7" width="10" height="2" rx="1"/><rect x="12" y="11" width="10" height="2" rx="1"/><rect x="12" y="15" width="6" height="2" rx="1"/><path fill-rule="evenodd" clip-rule="evenodd" d="M2.3 7.3a1 1 0 0 1 1.4 0L6 9.6l2.3-2.3a1 1 0 0 1 1.4 1.4L6 12.4 2.3 8.7a1 1 0 0 1 0-1.4Z"/></svg>',"action-next":'<svg width="24" height="24"><path fill-rule="nonzero" d="M5.7 7.3a1 1 0 0 0-1.4 1.4l7.7 7.7 7.7-7.7a1 1 0 1 0-1.4-1.4L12 13.6 5.7 7.3Z"/></svg>',"action-prev":'<svg width="24" height="24"><path fill-rule="nonzero" d="M18.3 15.7a1 1 0 0 0 1.4-1.4L12 6.6l-7.7 7.7a1 1 0 0 0 1.4 1.4L12 9.4l6.3 6.3Z"/></svg>',addtag:'<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 5a2 2 0 0 1 1.6.8L21 12l-4.4 6.2a2 2 0 0 1-1.6.8h-3v-2h3l3.5-5L15 7H5v3H3V7c0-1.1.9-2 2-2h10Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6 12a1 1 0 0 0-1 1v2H3a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0-2H7v-2c0-.6-.4-1-1-1Z"/></svg>',"ai-prompt":'<svg width="24" height="24"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 6.7a1 1 0 0 0-1.4 0l-9.9 10a1 1 0 0 0 0 1.3l2.1 2.1c.4.4 1 .4 1.4 0l10-9.9c.3-.3.3-1 0-1.4l-2.2-2Zm1.4 2.8-2-2-3 2.7 2.2 2.2 2.8-2.9Z"/><path d="m18.5 7.3-.7-1.5-1.5-.8 1.5-.7.7-1.5.7 1.5 1.5.7-1.5.8-.7 1.5ZM18.5 16.5l-.7-1.6-1.5-.7 1.5-.7.7-1.6.7 1.6 1.5.7-1.5.7-.7 1.6ZM9.7 7.3 9 5.8 7.5 5 9 4.3l.7-1.5.7 1.5L12 5l-1.5.8-.7 1.5Z"/></g><defs><clipPath id="a"><path d="M0 0h24v24H0z"/></clipPath></defs></svg>',ai:'<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M5 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3H5Zm6.8 11.5.5 1.2a68.3 68.3 0 0 0 .7 1.1l.4.1c.3 0 .5 0 .7-.3.2-.1.3-.3.3-.6l-.3-1-2.6-6.2a20.4 20.4 0 0 0-.5-1.3l-.5-.4-.7-.2c-.2 0-.5 0-.6.2-.2 0-.4.2-.5.4l-.3.6-.3.7L5.7 15l-.2.6-.1.4c0 .3 0 .5.3.7l.6.2c.3 0 .5 0 .7-.2l.4-1 .5-1.2h3.9ZM9.8 9l1.5 4h-3l1.5-4Zm5.6-.9v7.6c0 .4 0 .7.2 1l.7.2c.3 0 .6 0 .8-.3l.2-.9V8.1c0-.4 0-.7-.2-.9a1 1 0 0 0-.8-.3c-.2 0-.5.1-.7.3l-.2 1Z"/></svg>',"align-center":'<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm3 4h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm-3-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',"align-justify":'<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',"align-left":'<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',"align-none":'<svg width="24" height="24"><path d="M14.2 5 13 7H5a1 1 0 1 1 0-2h9.2Zm4 0h.8a1 1 0 0 1 0 2h-2l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h6.8Zm4 0H19a1 1 0 0 1 0 2h-4.4l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h4.4Zm4 0H19a1 1 0 0 1 0 2h-6.8l1.2-2ZM7 17l-1.2 2H5a1 1 0 0 1 0-2h2Zm4 0h8a1 1 0 0 1 0 2H9.8l1.2-2Zm5.2-13.5 1.3.7-9.7 16.3-1.3-.7 9.7-16.3Z" fill-rule="evenodd"/></svg>',"align-right":'<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm6 4h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm-6-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',"arrow-left":'<svg width="24" height="24"><path d="m5.6 13 12 6a1 1 0 0 0 1.4-1V6a1 1 0 0 0-1.4-.9l-12 6a1 1 0 0 0 0 1.8Z" fill-rule="evenodd"/></svg>',"arrow-right":'<svg width="24" height="24"><path d="m18.5 13-12 6A1 1 0 0 1 5 18V6a1 1 0 0 1 1.4-.9l12 6a1 1 0 0 1 0 1.8Z" fill-rule="evenodd"/></svg>',bold:'<svg width="24" height="24"><path d="M7.Line truncated
@@ -1,3 +0,0 @@
This is where language files should be placed.
Please DO NOT translate these directly, use this service instead: https://crowdin.com/project/tinymce
-6
View File
@@ -1,6 +0,0 @@
# Software License Agreement
**TinyMCE** – [<https://github.com/tinymce/tinymce>](https://github.com/tinymce/tinymce)
Copyright (c) 2024, Ephox Corporation DBA Tiny Technologies, Inc.
Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html).
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
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.
-4
View File
@@ -1,4 +0,0 @@
/**
* TinyMCE version 7.1.1 (2024-05-22)
*/
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.ModelManager");const t=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(o=n=e,(r=String).prototype.isPrototypeOf(o)||(null===(s=n.constructor)||void 0===s?void 0:s.name)===r.name)?"string":t;var o,n,r,s})(t)===e,o=e=>t=>typeof t===e,n=e=>t=>e===t,r=t("string"),s=t("object"),l=t("array"),a=n(null),c=o("boolean"),i=n(void 0),m=e=>!(e=>null==e)(e),d=o("function"),u=o("number"),f=()=>{},g=e=>()=>e,h=e=>e,p=(e,t)=>e===t;function b(e,...t){return(...o)=>{const n=t.concat(o);return e.apply(null,n)}}const w=e=>t=>!e(t),v=e=>e(),y=g(!1),x=g(!0);class C{constructor(e,t){this.tag=e,this.value=t}static some(e){return new C(!0,e)}static none(){return C.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?C.some(e(this.value)):C.none()}bind(e){return this.tag?e(this.value):C.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:C.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return m(e)?C.some(e):C.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}C.singletonNone=new C(!1);const S=Array.prototype.slice,T=Array.prototype.indexOf,R=Array.prototype.push,D=(e,t)=>{return o=e,n=t,T.call(o,n)>-1;var o,n},O=(e,t)=>{for(let o=0,n=e.length;o<n;o++)if(t(e[o],o))return!0;return!1},k=(e,t)=>{const o=[];for(let n=0;n<e;n++)o.push(t(n));return o},E=(e,t)=>{const o=e.length,n=new Array(o);for(let r=0;r<o;r++){const o=e[r];n[r]=t(o,r)}return n},N=(e,t)=>{for(let o=0,n=e.length;o<n;o++)t(e[o],o)},B=(e,t)=>{const o=[],n=[];for(let r=0,s=e.length;r<s;r++){const s=e[r];(t(s,r)?o:n).push(s)}return{pass:o,fail:n}},_=(e,t)=>{const o=[];for(let n=0,r=e.length;n<r;n++){const r=e[n];t(r,n)&&o.push(r)}return o},z=(e,t,o)=>(((e,t)=>{for(let o=e.length-1;o>=0;o--)t(e[o],o)})(e,((e,n)=>{o=t(o,e,n)})),o),A=(e,t,o)=>(N(e,((e,n)=>{o=t(o,e,n)})),o),L=(e,t)=>((e,t,o)=>{for(let n=0,r=e.length;n<r;n++){const r=e[n];if(t(r,n))return C.some(r);if(o(r,n))break}return C.none()})(e,t,y),W=(e,t)=>{for(let o=0,n=e.length;o<n;o++)if(t(e[o],o))return C.some(o);return C.none()},M=e=>{const t=[];for(let o=0,n=e.length;o<n;++o){if(!l(e[o]))throw new Error("Arr.flatten item "+o+" was not an array, input: "+e);R.apply(t,e[o])}return t},j=(e,t)=>M(E(e,t)),P=(e,t)=>{for(let o=0,n=e.length;o<n;++o)if(!0!==t(e[o],o))return!1;return!0},I=(e,t)=>{const o={};for(let n=0,r=e.length;n<r;n++){const r=e[n];o[String(r)]=t(r,n)}return o},F=(e,t)=>t>=0&&t<e.length?C.some(e[t]):C.none(),H=e=>F(e,0),$=e=>F(e,e.length-1),V=(e,t)=>{for(let o=0;o<e.length;o++){const n=t(e[o],o);if(n.isSome())return n}return C.none()},q=Object.keys,U=Object.hasOwnProperty,G=(e,t)=>{const o=q(e);for(let n=0,r=o.length;n<r;n++){const r=o[n];t(e[r],r)}},K=(e,t)=>Y(e,((e,o)=>({k:o,v:t(e,o)}))),Y=(e,t)=>{const o={};return G(e,((e,n)=>{const r=t(e,n);o[r.k]=r.v})),o},J=(e,t)=>{const o=[];return G(e,((e,n)=>{o.push(t(e,n))})),o},Q=e=>J(e,h),X=(e,t)=>U.call(e,t),Z="undefined"!=typeof window?window:Function("return this;")(),ee=(e,t)=>((e,t)=>{let o=null!=t?t:Z;for(let t=0;t<e.length&&null!=o;++t)o=o[e[t]];return o})(e.split("."),t),te=Object.getPrototypeOf,oe=e=>{const t=ee("ownerDocument.defaultView",e);return s(e)&&((e=>((e,t)=>{const o=((e,t)=>ee(e,t))(e,t);if(null==o)throw new Error(e+" not available on this browser");return o})("HTMLElement",e))(t).prototype.isPrototypeOf(e)||/^HTML\w*Element$/.test(te(e).constructor.name))},ne=e=>e.dom.nodeName.toLowerCase(),re=e=>e.dom.nodeType,se=e=>t=>re(t)===e,le=e=>8===re(e)||"#comment"===ne(e),ae=e=>ce(e)&&oe(e.dom),ce=se(1),ie=se(3),me=se(9),de=se(11),ue=e=>t=>ce(t)&&ne(t)===e,fe=(e,t,o)=>{if(!(r(o)||c(o)||u(o)))throw console.error("Invalid call to Attribute.set. Key ",t,":: Value ",o,":: Element ",e),new Error("Attribute value was not simple");e.setAttribute(t,o+"")},ge=(e,t,o)=>{fe(e.dom,t,o)},he=(e,t)=>{const o=e.dom;G(t,((e,t)=>{fe(o,t,e)}))},pe=(e,t)=>{const o=e.dom.getAttribute(t);return null===o?void 0:o},be=(e,t)=>C.from(pe(e,t)),we=(e,t)=>{e.dom.removeAttribute(t)},ve=e=>A(e.dom.attributes,((e,t)=>(e[t.name]=t.value,e)),{}),ye=e=>{if(null==e)throw new Error("Node cannot be null or undefined");return{dom:e}},xe={fromHtml:(e,t)=>{const o=(t||document).createElement("div");if(o.innerHTML=e,!o.hasChildNodes()||o.childNodes.length>1){const t="HTML does not have a single root node";throw console.error(t,e),new Error(t)}return ye(o.childNodes[0])},fromTag:(e,t)=>{const o=(t||document).createElement(e);return Line truncated
@@ -1,4 +0,0 @@
/**
* TinyMCE version 7.1.1 (2024-05-22)
*/
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");let t=0;const o=e=>t=>typeof t===e,n=e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(o=n=e,(r=String).prototype.isPrototypeOf(o)||(null===(s=n.constructor)||void 0===s?void 0:s.name)===r.name)?"string":t;var o,n,r,s})(e),r=o("boolean"),s=e=>null==e,a=e=>!s(e),i=o("function"),d=o("number"),l=e=>()=>e,c=(e,t)=>e===t,m=l(!1);class u{constructor(e,t){this.tag=e,this.value=t}static some(e){return new u(!0,e)}static none(){return u.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?u.some(e(this.value)):u.none()}bind(e){return this.tag?e(this.value):u.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:u.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return a(e)?u.some(e):u.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}u.singletonNone=new u(!1);const g=Array.prototype.indexOf,p=(e,t)=>{return o=e,n=t,g.call(o,n)>-1;var o,n},h=(e,t)=>{const o=e.length,n=new Array(o);for(let r=0;r<o;r++){const o=e[r];n[r]=t(o,r)}return n},f=(e,t)=>{for(let o=0,n=e.length;o<n;o++)t(e[o],o)},y=Object.keys;"undefined"!=typeof window?window:Function("return this;")();const v=e=>e.dom.nodeName.toLowerCase(),w=e=>e.dom.nodeType,b=e=>t=>w(t)===e,N=b(1),T=b(3),A=b(9),C=b(11),S=(e,t,o)=>{if(!(n(o)||r(o)||d(o)))throw console.error("Invalid call to Attribute.set. Key ",t,":: Value ",o,":: Element ",e),new Error("Attribute value was not simple");e.setAttribute(t,o+"")},x=(e,t)=>{const o=e.dom.getAttribute(t);return null===o?void 0:o},E=(e,t)=>u.from(x(e,t)),D=(e,t)=>{e.dom.removeAttribute(t)},M=e=>{if(null==e)throw new Error("Node cannot be null or undefined");return{dom:e}},P={fromHtml:(e,t)=>{const o=(t||document).createElement("div");if(o.innerHTML=e,!o.hasChildNodes()||o.childNodes.length>1){const t="HTML does not have a single root node";throw console.error(t,e),new Error(t)}return M(o.childNodes[0])},fromTag:(e,t)=>{const o=(t||document).createElement(e);return M(o)},fromText:(e,t)=>{const o=(t||document).createTextNode(e);return M(o)},fromDom:M,fromPoint:(e,t,o)=>u.from(e.dom.elementFromPoint(t,o)).map(M)},O=(e,t)=>{const o=e.dom;if(1!==o.nodeType)return!1;{const e=o;if(void 0!==e.matches)return e.matches(t);if(void 0!==e.msMatchesSelector)return e.msMatchesSelector(t);if(void 0!==e.webkitMatchesSelector)return e.webkitMatchesSelector(t);if(void 0!==e.mozMatchesSelector)return e.mozMatchesSelector(t);throw new Error("Browser lacks native selectors")}},k=e=>1!==e.nodeType&&9!==e.nodeType&&11!==e.nodeType||0===e.childElementCount,B=O,R=(L=/^\s+|\s+$/g,e=>e.replace(L,""));var L;const $=e=>void 0!==e.style&&i(e.style.getPropertyValue),I=e=>A(e)?e:P.fromDom(e.dom.ownerDocument),V=e=>u.from(e.dom.parentNode).map(P.fromDom),j=e=>u.from(e.dom.nextSibling).map(P.fromDom),q=e=>h(e.dom.childNodes,P.fromDom),F=i(Element.prototype.attachShadow)&&i(Node.prototype.getRootNode)?e=>P.fromDom(e.dom.getRootNode()):I,H=e=>P.fromDom(e.dom.host),z=e=>{const t=T(e)?e.dom.parentNode:e.dom;if(null==t||null===t.ownerDocument)return!1;const o=t.ownerDocument;return(e=>{const t=F(e);return C(o=t)&&a(o.dom.host)?u.some(t):u.none();var o})(P.fromDom(t)).fold((()=>o.body.contains(t)),(n=z,r=H,e=>n(r(e))));var n,r},K=(e,t)=>$(e)?e.style.getPropertyValue(t):"",U=(e,t)=>{V(e).each((o=>{o.dom.insertBefore(t.dom,e.dom)}))},Y=(e,t)=>{j(e).fold((()=>{V(e).each((e=>{_(e,t)}))}),(e=>{U(e,t)}))},_=(e,t)=>{e.dom.appendChild(t.dom)},G=(e,t)=>{f(t,((o,n)=>{const r=0===n?e:t[n-1];Y(r,o)}))},J=(e,t)=>{let o=[];return f(q(e),(e=>{t(e)&&(o=o.concat([e])),o=o.concat(J(e,t))})),o},Q=(e,t,o)=>{let n=e.dom;const r=i(o)?o:m;for(;n.parentNode;){n=n.parentNode;const e=P.fromDom(n);if(t(e))return u.some(e);if(r(e))break}return u.none()},W=e=>{const t=e.dom;null!==t.parentNode&&t.parentNode.removeChild(t)},X=(e,t,o)=>Q(e,(e=>O(e,t)),o),Z=(e,t)=>((e,t)=>{const o=void 0===t?document:t.dom;return k(o)?u.none():u.from(o.querySelector(e)).map(P.fromDom)})(t,e),ee=((e,t)=>{const o=t=>e(t)?u.from(t.dom.nodeValue):u.none();return{get:t=>{if(!e(t))throw new Error("Can only get text value of a text node");return o(t).getOr("")},getOption:o,set:(t,o)=>{if(!e(t))throw new Error("Can only set raw text value of a text node");t.dom.nodeValue=o}}})(T);var te=["body","p","div","article","aside","figcaption","figure","footer","header","nav","section","ol","ul","li","table","thead","tbody","tfoot","caption","tr","td","th"Line truncated
@@ -1,4 +0,0 @@
/**
* TinyMCE version 7.1.1 (2024-05-22)
*/
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;e<n;e++){const n=t[e];if(d(r=n)&&!/\btox\-/.test(r.className))return a.some(n);if(s(n,e))break}var r;return a.none()})(e,0,g).exists((e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the List Styles plugin.")}))}();
@@ -1,4 +0,0 @@
/**
* TinyMCE version 7.1.1 (2024-05-22)
*/
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=("allow_html_in_named_anchor",e=>e.options.get("allow_html_in_named_anchor"));const a="a:not([href])",r=e=>!e,i=e=>e.getAttribute("id")||e.getAttribute("name")||"",l=e=>(e=>"a"===e.nodeName.toLowerCase())(e)&&!e.getAttribute("href")&&""!==i(e),s=e=>e.dom.getParent(e.selection.getStart(),a),d=(e,a)=>{const r=s(e);r?((e,t,o)=>{o.removeAttribute("name"),o.id=t,e.addVisual(),e.undoManager.add()})(e,a,r):((e,a)=>{e.undoManager.transact((()=>{n(e)||e.selection.collapse(!0),e.selection.isCollapsed()?e.insertContent(e.dom.createHTML("a",{id:a})):((e=>{const n=e.dom;t(n).walk(e.selection.getRng(),(e=>{o.each(e,(e=>{var t;l(t=e)&&!t.firstChild&&n.remove(e,!1)}))}))})(e),e.formatter.remove("namedAnchor",void 0,void 0,!0),e.formatter.apply("namedAnchor",{value:a}),e.addVisual())}))})(e,a),e.focus()},c=e=>(e=>r(e.attr("href"))&&!r(e.attr("id")||e.attr("name")))(e)&&!e.firstChild,m=e=>t=>{for(let o=0;o<t.length;o++){const n=t[o];c(n)&&n.attr("contenteditable",e)}},u=e=>t=>{const o=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",o),o(),()=>{e.off("NodeChange",o)}};e.add("anchor",(e=>{(e=>{(0,e.options.register)("allow_html_in_named_anchor",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreInit",(()=>{e.parser.addNodeFilter("a",m("false")),e.serializer.addNodeFilter("a",m(null))}))})(e),(e=>{e.addCommand("mceAnchor",(()=>{(e=>{const t=(e=>{const t=s(e);return t?i(t):""})(e);e.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:t=>{((e,t)=>/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)?(d(e,t),!0):(e.windowManager.alert("ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),!1))(e,t.getData().id)&&t.close()}})})(e)}))})(e),(e=>{const t=()=>e.execCommand("mceAnchor");e.ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:t,onSetup:t=>{const o=e.selection.selectorChangedWithUnbind("a:not([href])",t.setActive).unbind,n=u(e)(t);return()=>{o(),n()}}}),e.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:t,onSetup:u(e)})})(e),e.on("PreInit",(()=>{(e=>{e.formatter.register("namedAnchor",{inline:"a",selector:a,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:(e,t,o)=>l(e)})})(e)}))}))}();
@@ -1,4 +0,0 @@
/**
* TinyMCE version 7.1.1 (2024-05-22)
*/
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}();
Loaded 100 of 1330 files, more files were not shown because too many files have changed in this diff. Show more