Compare commits
179
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73a75d5cb9 | ||
|
|
e6e088c963 | ||
|
|
58d58f5a04 | ||
|
|
ed67b7e70d | ||
|
|
23e88190d9 | ||
|
|
340a0ad461 | ||
|
|
500ad59458 | ||
|
|
18600ab49d | ||
|
|
daf3594316 | ||
|
|
9f4ce426a8 | ||
|
|
02fb8751fd | ||
|
|
cb737b7ae3 | ||
|
|
470584400f | ||
|
|
eb9876fde3 | ||
|
|
0eb46b0d74 | ||
|
|
cb1410f0b0 | ||
|
|
6b8990c55e | ||
|
|
3d81d2c717 | ||
|
|
b34a173d83 | ||
|
|
a11338094e | ||
|
|
45fcbd890d | ||
|
|
0d03a27f31 | ||
|
|
63c1e7076e | ||
|
|
04dc151ed7 | ||
|
|
a1cdfce529 | ||
|
|
c41e6dbca5 | ||
|
|
9d9453c1b3 | ||
|
|
54f27ac98f | ||
|
|
0632bf53cb | ||
|
|
fe254112a2 | ||
|
|
160c0a49ce | ||
|
|
e6746f2be7 | ||
|
|
6f9ae6207a | ||
|
|
03b39e64f2 | ||
|
|
73f4b539fe | ||
|
|
a2d224240c | ||
|
|
d6566743a7 | ||
|
|
10ec74c8cf | ||
|
|
5e05b606a7 | ||
|
|
67b3ff03d0 | ||
|
|
ce9c876db6 | ||
|
|
e8dff6bb5f | ||
|
|
c484d5bd12 | ||
|
|
d768668014 | ||
|
|
951fef18ba | ||
|
|
f567866161 | ||
|
|
f1d56ef50e | ||
|
|
f0845de643 | ||
|
|
f914eae3c5 | ||
|
|
70c8492f4f | ||
|
|
953be7342d | ||
|
|
28d2d5c826 | ||
|
|
59c72a84ce | ||
|
|
43469ef08d | ||
|
|
35db442cd3 | ||
|
|
e37a46a027 | ||
|
|
50b1649965 | ||
|
|
8907bb49fe | ||
|
|
dc5d12348c | ||
|
|
eb9bfabed5 | ||
|
|
d752a61e52 | ||
|
|
81e299f09a | ||
|
|
421cfafec3 | ||
|
|
2303b12377 | ||
|
|
1a55a39425 | ||
|
|
e82d0ce254 | ||
|
|
161ddc0f7d | ||
|
|
c1e73bda81 | ||
|
|
508a8ab77e | ||
|
|
1e5f324579 | ||
|
|
1d4f4bd2cc | ||
|
|
d8f171b8cc | ||
|
|
8ad3941f6d | ||
|
|
d275ba3c14 | ||
|
|
3e01c29657 | ||
|
|
acd392c29d | ||
|
|
514a082c96 | ||
|
|
844842fdae | ||
|
|
7e9bd9ba30 | ||
|
|
68401d66e0 | ||
|
|
daf497576f | ||
|
|
0490a4c494 | ||
|
|
2c8f39273c | ||
|
|
721311a643 | ||
|
|
7f1e8e572d | ||
|
|
02a0a36dc3 | ||
|
|
5a430c08f4 | ||
|
|
54e8fd5fed | ||
|
|
70460e4170 | ||
|
|
834493a4f5 | ||
|
|
a49eec909c | ||
|
|
e10a5e57e2 | ||
|
|
ff0854f331 | ||
|
|
b3793d5dbb | ||
|
|
008b0dfbb9 | ||
|
|
67d456baf6 | ||
|
|
0d99544579 | ||
|
|
95ae043d3e | ||
|
|
e0fa433ff9 | ||
|
|
142325730d | ||
|
|
1ece778caf | ||
|
|
153fd8719a | ||
|
|
461e93e3b5 | ||
|
|
162fea4afb | ||
|
|
b146dfead7 | ||
|
|
27dadbbfa6 | ||
|
|
6dc47789ef | ||
|
|
65ced64568 | ||
|
|
c40522a09f | ||
|
|
da36c78bc0 | ||
|
|
b0f7b7892b | ||
|
|
55525c06d6 | ||
|
|
310cfd528e | ||
|
|
6afffc1984 | ||
|
|
acdef18b37 | ||
|
|
7aae704d0a | ||
|
|
792aba433d | ||
|
|
b29d0ed045 | ||
|
|
0fc00ceeff | ||
|
|
e15f444603 | ||
|
|
cb05b38f36 | ||
|
|
938263cd6a | ||
|
|
d4fcfb2bba | ||
|
|
ad55137883 | ||
|
|
a9a208b80a | ||
|
|
f6d0d01092 | ||
|
|
ca3fe20519 | ||
|
|
c47d33eaa9 | ||
|
|
a4734b2c91 | ||
|
|
d493a181e1 | ||
|
|
930b836241 | ||
|
|
30a4df47b1 | ||
|
|
4e41d60142 | ||
|
|
3c3f7c6194 | ||
|
|
a5873f8886 | ||
|
|
f1e22f7073 | ||
|
|
001f9f4774 | ||
|
|
12faab7f92 | ||
|
|
eed661bb24 | ||
|
|
24f95a8fd7 | ||
|
|
64f50074bd | ||
|
|
2a64991916 | ||
|
|
61828783a1 | ||
|
|
8d17a12e92 | ||
|
|
0e050c36ff | ||
|
|
9b47685002 | ||
|
|
3282b0580c | ||
|
|
cbb403011a | ||
|
|
f3ec3d2fa2 | ||
|
|
81c4b62175 | ||
|
|
2ee28203ab | ||
|
|
aa865af2e9 | ||
|
|
6d510d2805 | ||
|
|
6e9e108033 | ||
|
|
0b4e614ec9 | ||
|
|
e474f03d91 | ||
|
|
5992c8598b | ||
|
|
8a8a24434a | ||
|
|
c16fdbd26d | ||
|
|
8822bdddd0 | ||
|
|
790d4e6da6 | ||
|
|
6a2ca07b66 | ||
|
|
4278d96882 | ||
|
|
ff8cd5f0ec | ||
|
|
8460a078e3 | ||
|
|
7c57173497 | ||
|
|
09bd5afa4a | ||
|
|
4a6209a8db | ||
|
|
aee595dee9 | ||
|
|
9ecaa80fcf | ||
|
|
fbbce403e2 | ||
|
|
9c80cb9885 | ||
|
|
ce787d26ff | ||
|
|
76245c47c9 | ||
|
|
5282770caa | ||
|
|
b50efae8a8 | ||
|
|
e0a6706d4e | ||
|
|
0eba18e367 | ||
|
|
61a84201d8 |
No files matched your search
@@ -1,51 +1,20 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
tmp_dir = "tmp/air"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "tmp/main.exe"
|
||||
cmd = "go build -o ./tmp/main.exe ./main"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "api\\static\\tinymce", "api\\static\\bootstrap-icons", "api\\static\\uploadedImages"]
|
||||
exclude_file = []
|
||||
cmd = "go build -tags nofrontend -o ./tmp/air/renewcms ./cmd/server"
|
||||
bin = "tmp/air/renewcms"
|
||||
delay = 500
|
||||
exclude_dir = ["tmp", "tmp/air", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"]
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
include_ext = ["go", "html"]
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
send_interrupt = true
|
||||
kill_delay = "2s"
|
||||
stop_on_error = true
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
clean_on_exit = true
|
||||
@@ -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
@@ -1,11 +1,42 @@
|
||||
ENVIRONMENT=development
|
||||
HOST=example.com
|
||||
PORT=8080
|
||||
JWT_SECRET=abc123
|
||||
CORS_ALLOWED_ORIGINS=*
|
||||
DB_FILE=./renewcms.db
|
||||
DOCKER_DB_FOLDER=./data
|
||||
SMTP_EMAIL=a@b.c
|
||||
SMTP_PASSWORD=12341234
|
||||
PUBLIC_URL=http://localhost:5173
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
SMTP_HOST=mail.service.abc
|
||||
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
@@ -1 +1,4 @@
|
||||
api/static/** linguist-vendored
|
||||
* text=auto eol=lf
|
||||
*.ico binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
@@ -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
@@ -4,14 +4,27 @@
|
||||
|
||||
# DB
|
||||
*.db
|
||||
/data/
|
||||
|
||||
# Build
|
||||
bin
|
||||
dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
docker.env
|
||||
|
||||
# Generated files
|
||||
tmp/
|
||||
.task/
|
||||
build-errors.log
|
||||
uploadedImages/
|
||||
*.exe
|
||||
|
||||
# AI fest
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.codex/
|
||||
.agents/
|
||||
AGENTS.md
|
||||
codex-workflow.md
|
||||
@@ -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
@@ -1,16 +1,30 @@
|
||||
FROM golang:1.20.5-alpine
|
||||
|
||||
WORKDIR /app
|
||||
FROM oven/bun:1-alpine AS web
|
||||
WORKDIR /src/web
|
||||
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 adapters ./adapters
|
||||
COPY api ./api
|
||||
COPY domain ./domain
|
||||
COPY main ./main
|
||||
COPY useCases ./useCases
|
||||
COPY .env ./.env
|
||||
|
||||
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"]
|
||||
@@ -1,65 +1,59 @@
|
||||
# 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
|
||||
|
||||
#### Compose
|
||||
|
||||
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
|
||||
```sh
|
||||
cp .env.example .env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
|
||||
./renewcms # → http://localhost:8080
|
||||
```
|
||||
|
||||
### 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`
|
||||
- `super_build.bat`
|
||||
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)**.
|
||||
|
||||
They will build the project for windows, linux and macOS. The binaries will be in the `bin` folder.
|
||||
You can also really just use `go build` to build the project for your current OS.
|
||||
### Updates from the browser
|
||||
|
||||
#### 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
|
||||
of course required, but not necessarily via the `.env` file.
|
||||
### Develop
|
||||
|
||||
### Environment variables
|
||||
Requires Go 1.27+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev).
|
||||
|
||||
| Name | Type | Description | Comment |
|
||||
|----------------------|--------|:----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
|
||||
| ENVIRONMENT | string | environment the API is running in | required, `development` or `production` |
|
||||
| HOST | string | the host domain name for emails and callbacks | required, if `localhost`: add the port (e.g. `localhost:8080`) |
|
||||
| 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. `./RenewCMS.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 |
|
||||
```sh
|
||||
cp .env.example .env
|
||||
task setup
|
||||
task dev # API with hot reload + Vite HMR → http://localhost:5173
|
||||
```
|
||||
|
||||
## API Usage
|
||||
## Documentation
|
||||
|
||||
TODO
|
||||
|
||||
## Demo
|
||||
|
||||
TODO
|
||||
| Doc | Contents |
|
||||
|---|---|
|
||||
| [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 |
|
||||
| [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/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter |
|
||||
| [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/custom-content-types.md](docs/custom-content-types.md) | Custom content types: layering, phase 1 limitations and their fix path |
|
||||
| [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
|
||||
|
||||
|
||||
+423
@@ -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,140 +0,0 @@
|
||||
package gateways
|
||||
|
||||
import (
|
||||
entity "RenewCMS/adapters/secondary/gateways/models"
|
||||
domain "RenewCMS/domain/article"
|
||||
"RenewCMS/domain/gateways"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ArticleRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewArticleRepository(db *gorm.DB) *ArticleRepository {
|
||||
return &ArticleRepository{db}
|
||||
}
|
||||
|
||||
func mapArticleToDomain(article entity.Article) domain.Article {
|
||||
return domain.FromDb(article.ID, article.Title, article.Body, article.Images, article.IsOnline, article.CreatedAt, article.UpdatedAt)
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Get(id uint32) (domain.Article, error) {
|
||||
var article entity.Article
|
||||
err := a.db.Model(&entity.Article{}).Preload("Images").First(&article, id).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
return mapArticleToDomain(article), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
||||
var article entity.Article
|
||||
err := a.db.Model(&entity.Article{}).Where("title = ?", name).First(&article).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
return mapArticleToDomain(article), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
||||
creationResult := a.db.Create(&entity.Article{
|
||||
Title: article.Title,
|
||||
Body: article.Body,
|
||||
})
|
||||
if creationResult.Error != nil {
|
||||
return domain.Article{}, creationResult.Error
|
||||
}
|
||||
|
||||
var createdArticle entity.Article
|
||||
creationResult.Scan(&createdArticle)
|
||||
|
||||
return mapArticleToDomain(createdArticle), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) GetAll() []domain.Article {
|
||||
var articles []entity.Article
|
||||
err := a.db.Model(&entity.Article{}).Find(&articles).Error
|
||||
if err != nil {
|
||||
return []domain.Article{}
|
||||
}
|
||||
|
||||
var domainArticles = make([]domain.Article, 0)
|
||||
for _, article := range articles {
|
||||
domainArticles = append(domainArticles, mapArticleToDomain(article))
|
||||
}
|
||||
|
||||
return domainArticles
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
|
||||
var localArticle entity.Article
|
||||
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
localArticle.Body = body
|
||||
err = a.db.Save(&localArticle).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
newArticle := domain.FromDb(
|
||||
localArticle.ID,
|
||||
localArticle.Title,
|
||||
localArticle.Body,
|
||||
localArticle.Images,
|
||||
localArticle.IsOnline,
|
||||
localArticle.CreatedAt,
|
||||
localArticle.UpdatedAt,
|
||||
)
|
||||
|
||||
return newArticle, nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
|
||||
var localArticle entity.Article
|
||||
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
localArticle.IsOnline = isOnline
|
||||
err = a.db.Save(&localArticle).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
return mapArticleToDomain(localArticle), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Delete(id uint32) error {
|
||||
return a.db.Delete(&entity.Article{}, id).Error
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) AddImage(articleId uint32, imageId uint32) error {
|
||||
var localArticle entity.Article
|
||||
err := a.db.Model(&entity.Article{}).First(&localArticle, articleId).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(&localArticle).Association("Images").Append(&localImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ gateways.IArticleRepository = &ArticleRepository{}
|
||||
@@ -1,85 +0,0 @@
|
||||
package gateways
|
||||
|
||||
import (
|
||||
entity "RenewCMS/adapters/secondary/gateways/models"
|
||||
"RenewCMS/domain/gateways"
|
||||
domain "RenewCMS/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.ArticleID,
|
||||
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/renewcms-favicon-64.png" alt="G" style="width: 2.5rem"/>
|
||||
RenewCMS
|
||||
</h1>
|
||||
<main>
|
||||
<p>You or someone tried to create a RenewCMS 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/renewcms-favicon-64.png" alt="G" style="width: 2.5rem"/>
|
||||
RenewCMS
|
||||
</h1>
|
||||
<main>
|
||||
<p>You or someone tried to create reset your RenewCMS 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,64 +0,0 @@
|
||||
package gateways
|
||||
|
||||
import (
|
||||
"RenewCMS/domain/gateways"
|
||||
"bytes"
|
||||
"embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
type MailRepository struct{}
|
||||
|
||||
//go:embed mail/templates/*
|
||||
var mailTemplateFiles embed.FS
|
||||
|
||||
func NewMailRepository() *MailRepository {
|
||||
return &MailRepository{}
|
||||
}
|
||||
|
||||
func (m MailRepository) Send(receiverAddress string, templateName string, data any) 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": {"RenewCMS <" + from + ">"},
|
||||
"To": {receiverAddress},
|
||||
"MIME-version": {"1.0"},
|
||||
"Content-Type": {"text/html"},
|
||||
"charset": {"UTF-8"},
|
||||
"Subject": {"RenewCMS | 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,18 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Article struct {
|
||||
gorm.Model
|
||||
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
|
||||
Title string
|
||||
Body string
|
||||
Images []*Image `gorm:"many2many:article_images;"`
|
||||
IsOnline bool `gorm:"not_null;default:false"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
gorm.Model
|
||||
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
|
||||
Path string
|
||||
ArticleID 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 (
|
||||
"RenewCMS/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 any) ([]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,152 +0,0 @@
|
||||
package gateways
|
||||
|
||||
import (
|
||||
entity "RenewCMS/adapters/secondary/gateways/models"
|
||||
"RenewCMS/domain/gateways"
|
||||
"RenewCMS/domain/user"
|
||||
domain "RenewCMS/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,66 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>RenewCMS | Create article</title>
|
||||
{{.Head}}
|
||||
<style>
|
||||
form {
|
||||
max-width: 24rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-dark">
|
||||
{{.Navbar}}
|
||||
<div class="container mt-3 text-black-50">
|
||||
<h1>Article - creation</h1>
|
||||
<p>Create a new article</p>
|
||||
<form action="create" method="post" class="d-flex align-items-center flex-column gap-3" id="createArticleForm">
|
||||
<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 article name" value="{{ .Name }}">
|
||||
<label for="name">Name</label>
|
||||
<div class="invalid-feedback">{{ .PageError.Message }}</div>
|
||||
</div>
|
||||
<button id="createArticleButton" class="btn btn-primary w-100" disabled type="submit">
|
||||
<span class="createArticleFormButtonLoading visually-hidden spinner-border spinner-border-sm"
|
||||
role="status"></span>
|
||||
<span class="createArticleFormButtonDefault">Create</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const button = document.querySelector("#createArticleButton")
|
||||
const inputs = document.querySelectorAll('input')
|
||||
const form = document.querySelector("#createArticleForm")
|
||||
|
||||
function formFieldsEmpty() {
|
||||
return Array.from(inputs).some((input) => input.value === "")
|
||||
}
|
||||
|
||||
function setButtonDisabled() {
|
||||
button.disabled = formFieldsEmpty() ? "disabled" : ""
|
||||
}
|
||||
|
||||
function setButtonLoading() {
|
||||
button.classList.add("disabled")
|
||||
button.querySelector(".createArticleFormButtonDefault").classList.add("visually-hidden")
|
||||
button.querySelector(".createArticleFormButtonLoading").classList.remove("visually-hidden")
|
||||
}
|
||||
|
||||
function onCreateArticleFormSubmit(event) {
|
||||
setButtonLoading()
|
||||
event.target.submit()
|
||||
}
|
||||
|
||||
function onInput(event) {
|
||||
if (event.target.tagName === "INPUT") setButtonDisabled()
|
||||
}
|
||||
|
||||
form.addEventListener('submit', onCreateArticleFormSubmit)
|
||||
window.addEventListener('input', onInput)
|
||||
setButtonDisabled()
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,94 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>RenewCMS | Article 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>Article - edition</h1>
|
||||
<label for="articleBody">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="articleBody" name="articleBody">{{.Article.Body}}</textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script src="/static/tinymce/js/tinymce/tinymce.min.js"></script>
|
||||
<script>
|
||||
tinymce.init({
|
||||
selector: '#articleBody',
|
||||
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', "/article/{{.Article.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>RenewCMS | Articles</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>Articles</h1>
|
||||
<p>List of your articles</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="article/create" method="get">
|
||||
<button class="btn btn-sm btn-link w-100 h-100" type="submit">Create a new article...</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{ range $article := .Articles }}
|
||||
<tr>
|
||||
<td>{{ $article.Title }}</td>
|
||||
<td class="date">{{ $article.CreatedAt }}</td>
|
||||
<td class="date">{{ $article.UpdatedAt }}</td>
|
||||
<td>{{ if $article.IsOnline }}🟢 Online{{ else }}🟠 Offline{{ end }}</td>
|
||||
<td>
|
||||
<a href="/article/{{ $article.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 $article.IsOnline }}
|
||||
<a href="/article/{{ $article.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="/article/{{ $article.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="/article/{{ $article.Title }}/delete" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal"
|
||||
data-bs-target="#{{ $article.ID }}">
|
||||
<svg width=" 12px" height="12px" fill="currentColor">
|
||||
<use xlink:href="/static/bootstrap-icons.svg#trash"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="modal fade" id="{{ $article.ID }}" tabindex="-1" aria-labelledby="{{ $article.ID }}Label"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="{{ $article.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>« {{ $article.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="/article/{{$article.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,32 +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/renewcms-favicon-64.png" alt="Logo" style="width: 2.5rem"/>
|
||||
<a class="navbar-brand h1 my-auto" href="https://github.com/Floriansylvain/RenewCMS" target="_blank">
|
||||
RenewCMS
|
||||
</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="/article">Articles</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 disconnect-link" href="/logout">Log out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,17 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>RenewCMS | Home</title>
|
||||
{{.Head}}
|
||||
</head>
|
||||
<body class="text-dark">
|
||||
{{.Navbar}}
|
||||
<div class="container mt-3 text-black-50">
|
||||
<h1>Home</h1>
|
||||
<p>Welcome on RenewCMS !</p>
|
||||
<a href="/article">
|
||||
<button class="btn btn-outline-primary">Articles »</button>
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,64 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>RenewCMS | 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 article data via the <code>/article</code>
|
||||
route.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>/v1/article</h2>
|
||||
<p>The <code>{{ .Host }}/v1/article</code> route returns a list of articles in JSON format. Each article has the
|
||||
following
|
||||
structure:</p>
|
||||
<pre>
|
||||
<code>[
|
||||
{
|
||||
"id": 12,
|
||||
"title": "This is a article!",
|
||||
"body": "<p>?</p>\r\n<p><img src=\"../../static/uploadedImages/649691a8-4f2e-48ca-9abe-27a60621d2e4.png\"
|
||||
alt=\"\" width=\"391\" height=\"465\"></p>",
|
||||
"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/article/{id}</h2>
|
||||
<p>The <code>{{ .Host }}/v1/article/{id}</code> route returns a single article in JSON format. The article has the
|
||||
following
|
||||
structure:</p>
|
||||
<pre>
|
||||
<code>{
|
||||
"id": 12,
|
||||
"title": "ravioli",
|
||||
"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",
|
||||
"article_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>RenewCMS | 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>RenewCMS</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>RenewCMS | 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>RenewCMS</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>RenewCMS | 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>RenewCMS</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,147 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>RenewCMS | 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>RenewCMS</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>RenewCMS | 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>RenewCMS</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>RenewCMS | 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>RenewCMS</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>
|
||||
@@ -1,99 +0,0 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/api/controllers/auth"
|
||||
"RenewCMS/domain/article"
|
||||
"RenewCMS/useCases"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type PostArticle 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 getArticle(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
|
||||
}
|
||||
|
||||
localArticle, err := api.Container.GetArticleUseCase.GetArticle(uint32(id))
|
||||
if err != nil || !localArticle.IsOnline {
|
||||
http.Error(w, "The requested resource, identified by its unique ID, could not be found on the server.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
articleJson, _ := json.Marshal(localArticle)
|
||||
_, _ = w.Write(articleJson)
|
||||
}
|
||||
|
||||
func postArticle(w http.ResponseWriter, r *http.Request) {
|
||||
var localArticle PostArticle
|
||||
err := json.NewDecoder(r.Body).Decode(&localArticle)
|
||||
if err != nil {
|
||||
http.Error(w, auth.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Validate.Struct(localArticle)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
createdArticle, err := api.Container.CreateArticleUseCase.CreateArticle(useCases.CreateArticleCommand{
|
||||
Title: localArticle.Title,
|
||||
Body: localArticle.Body,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
articleJson, _ := json.Marshal(createdArticle)
|
||||
|
||||
_, _ = w.Write(articleJson)
|
||||
}
|
||||
|
||||
func listArticles(w http.ResponseWriter, _ *http.Request) {
|
||||
articles := api.Container.ListArticlesUseCase.ListArticles()
|
||||
onlineArticles := make([]article.Article, 0)
|
||||
for _, localArticle := range articles {
|
||||
if localArticle.IsOnline {
|
||||
onlineArticles = append(onlineArticles, localArticle)
|
||||
}
|
||||
}
|
||||
articlesJson, _ := json.Marshal(onlineArticles)
|
||||
_, _ = w.Write(articlesJson)
|
||||
}
|
||||
|
||||
func deleteArticle(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.DeleteArticleUseCase.DeleteArticle(uint32(id))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte("article deleted"))
|
||||
}
|
||||
|
||||
func NewArticleRouter() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/{id}", getArticle)
|
||||
r.Post("/", postArticle)
|
||||
r.Get("/", listArticles)
|
||||
r.Delete("/{id}", deleteArticle)
|
||||
return r
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/domain/user"
|
||||
"RenewCMS/useCases"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"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
|
||||
|
||||
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]any{"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 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)
|
||||
return
|
||||
}
|
||||
|
||||
_ = SetJwtCookie(&w, dbUser.ID)
|
||||
|
||||
message, _ := json.Marshal(map[string]any{"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
|
||||
}
|
||||
|
||||
createdUser, err := api.Container.CreateUserUseCase.CreateUser(useCases.CreateUserCommand{
|
||||
Username: credentials.Username,
|
||||
Password: credentials.Password,
|
||||
Email: credentials.Email,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_ = SetJwtCookie(&w, createdUser.ID)
|
||||
|
||||
message, _ := json.Marshal(map[string]any{"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]any{"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
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"RenewCMS/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.UpdateArticleUseCase.AddImage(uint32(idInt), newImage.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
newJson := map[string]any{"location": newImage.Path}
|
||||
newJsonBytes, _ := json.Marshal(newJson)
|
||||
|
||||
_, _ = w.Write(newJsonBytes)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/useCases"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type ArticleCreatePageError struct {
|
||||
IsError bool
|
||||
Message string
|
||||
}
|
||||
|
||||
func GetArticleCreatePageTemplate(postName string, errorMessage string) ([]byte, error) {
|
||||
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
|
||||
return api.Container.GetPageUseCase.GetPage("articleCreate", map[string]any{
|
||||
"Navbar": template.HTML(navbarTmpl),
|
||||
"Head": headTmpl,
|
||||
"PageError": ArticleCreatePageError{
|
||||
IsError: errorMessage != "",
|
||||
Message: errorMessage,
|
||||
},
|
||||
"Name": postName,
|
||||
})
|
||||
}
|
||||
|
||||
func PostArticleCreatePage(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, _ := GetArticleCreatePageTemplate(postName, "Name should be alphanumeric, and between 3 and 50 characters.")
|
||||
_, _ = w.Write(postsTmpl)
|
||||
return
|
||||
}
|
||||
|
||||
post, err := api.Container.CreateArticleUseCase.CreateArticle(useCases.CreateArticleCommand{
|
||||
Title: postName,
|
||||
Body: "",
|
||||
})
|
||||
if err != nil {
|
||||
postsTmpl, _ := GetArticleCreatePageTemplate(postName, "Something went wrong when creating the article, please contact admin.")
|
||||
_, _ = w.Write(postsTmpl)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/article/"+strconv.Itoa(int(post.ID))+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func GetArticleCreatePage(w http.ResponseWriter, _ *http.Request) {
|
||||
postsTmpl, _ := GetArticleCreatePageTemplate("", "")
|
||||
_, _ = w.Write(postsTmpl)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/api/controllers/article"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func GetArticleDeletePage(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, article.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
localArticle, err := api.Container.GetArticleUseCase.GetArticle(uint32(id))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(localArticle.Images)
|
||||
for _, image := range localArticle.Images {
|
||||
err = api.Container.DeleteImageUseCase.DeleteImage(image.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = api.Container.DeleteArticleUseCase.DeleteArticle(uint32(id))
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(400), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/article", http.StatusSeeOther)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/domain/article"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type ArticleEditPageAlert struct {
|
||||
IsError bool
|
||||
Message string
|
||||
}
|
||||
|
||||
func getArticleEditPageTemplate(post article.Article, alert ArticleEditPageAlert) []byte {
|
||||
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
|
||||
articleTmpl, _ := api.Container.GetPageUseCase.GetPage("articleEdit", map[string]any{
|
||||
"Navbar": template.HTML(navbarTmpl),
|
||||
"Head": headTmpl,
|
||||
"Article": post,
|
||||
"Alert": alert,
|
||||
"Secured": os.Getenv("ENVIRONMENT") == "production",
|
||||
})
|
||||
return articleTmpl
|
||||
}
|
||||
|
||||
func PostArticleEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
articleID := chi.URLParam(r, "id")
|
||||
articleIDint, err := strconv.Atoi(articleID)
|
||||
if err != nil {
|
||||
_, _ = w.Write(getArticleEditPageTemplate(article.Article{}, ArticleEditPageAlert{
|
||||
IsError: true,
|
||||
Message: "Could not find the requested article.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.ParseForm()
|
||||
articleBody := r.FormValue("articleBody")
|
||||
|
||||
getArticle, _ := api.Container.GetArticleUseCase.GetArticle(uint32(articleIDint))
|
||||
updatedArticle, err := api.Container.UpdateArticleUseCase.UpdateBody(getArticle.ID, articleBody)
|
||||
if err != nil {
|
||||
_, _ = w.Write(getArticleEditPageTemplate(article.Article{Body: articleBody}, ArticleEditPageAlert{
|
||||
IsError: true,
|
||||
Message: "Could not save the article: " + err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write(getArticleEditPageTemplate(updatedArticle, ArticleEditPageAlert{
|
||||
IsError: false,
|
||||
Message: "Article successfully edited!",
|
||||
}))
|
||||
}
|
||||
|
||||
func GetArticleEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
articleID := chi.URLParam(r, "id")
|
||||
articleIDint, err := strconv.Atoi(articleID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
getArticle, _ := api.Container.GetArticleUseCase.GetArticle(uint32(articleIDint))
|
||||
|
||||
_, _ = w.Write(getArticleEditPageTemplate(getArticle, ArticleEditPageAlert{
|
||||
IsError: false,
|
||||
Message: "",
|
||||
}))
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func updateIsOnline(articleId string, isOnline bool) (error, int) {
|
||||
articleIdInt, err := strconv.Atoi(articleId)
|
||||
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.UpdateArticleUseCase.UpdateIsOnline(uint32(articleIdInt), 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 GetArticleUnpublishPage(w http.ResponseWriter, r *http.Request) {
|
||||
articleId := chi.URLParam(r, "id")
|
||||
err, statusCode := updateIsOnline(articleId, false)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), statusCode)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/article", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func GetArticlePublishPage(w http.ResponseWriter, r *http.Request) {
|
||||
articleId := chi.URLParam(r, "id")
|
||||
err, statusCode := updateIsOnline(articleId, true)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), statusCode)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/article", http.StatusSeeOther)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetArticlesPage(w http.ResponseWriter, _ *http.Request) {
|
||||
articles := api.Container.ListArticlesUseCase.ListArticles()
|
||||
|
||||
var formattedArticles []map[string]any
|
||||
for _, article := range articles {
|
||||
formattedArticles = append(formattedArticles, map[string]any{
|
||||
"ID": article.ID,
|
||||
"Title": article.Title,
|
||||
"IsOnline": article.IsOnline,
|
||||
"CreatedAt": article.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"UpdatedAt": article.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
|
||||
articlesTmpl, _ := api.Container.GetPageUseCase.GetPage("articles", map[string]any{
|
||||
"Navbar": template.HTML(navbarTmpl),
|
||||
"Head": headTmpl,
|
||||
"Articles": formattedArticles,
|
||||
})
|
||||
|
||||
_, _ = w.Write(articlesTmpl)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/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]any{
|
||||
"Navbar": template.HTML(navbarTmpl),
|
||||
"Head": headTmpl,
|
||||
})
|
||||
_, _ = w.Write(homeTmpl)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func GetPageIntegration(w http.ResponseWriter, _ *http.Request) {
|
||||
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
|
||||
templ, _ := api.Container.GetPageUseCase.GetPage("integration", map[string]any{
|
||||
"Navbar": template.HTML(navbarTmpl),
|
||||
"Head": headTmpl,
|
||||
"Host": os.Getenv("HOST"),
|
||||
})
|
||||
_, _ = w.Write(templ)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/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]any{
|
||||
"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)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/api/controllers/auth"
|
||||
"RenewCMS/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("/article", GetArticlesPage)
|
||||
|
||||
r.Get("/article/{id}/edit", GetArticleEditPage)
|
||||
r.Post("/article/{id}/edit", PostArticleEditPage)
|
||||
|
||||
r.Get("/article/{id}/delete", GetArticleDeletePage)
|
||||
|
||||
r.Get("/article/create", GetArticleCreatePage)
|
||||
r.Post("/article/create", PostArticleCreatePage)
|
||||
|
||||
r.Post("/article/{id}/image/create", image.PostImage)
|
||||
|
||||
r.Get("/article/{id}/publish", GetArticlePublishPage)
|
||||
r.Get("/article/{id}/unpublish", GetArticleUnpublishPage)
|
||||
|
||||
r.Get("/integration", GetPageIntegration)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/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]any{
|
||||
"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 (
|
||||
"RenewCMS/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]any{
|
||||
"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)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/api/controllers/auth"
|
||||
"RenewCMS/useCases"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
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]any{
|
||||
"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
|
||||
}
|
||||
|
||||
createdUser, err := api.Container.CreateUserUseCase.CreateUser(useCases.CreateUserCommand{
|
||||
Username: credentials.Username,
|
||||
Password: credentials.Password,
|
||||
Email: credentials.Email,
|
||||
})
|
||||
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": createdUser.VerificationCode,
|
||||
})
|
||||
|
||||
_ = auth.SetJwtCookie(&w, createdUser.ID)
|
||||
|
||||
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/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]any{
|
||||
"Head": headTmpl,
|
||||
})
|
||||
_, _ = w.Write(registerPendingTmpl)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/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]any{
|
||||
"Head": headTmpl,
|
||||
"PageError": NewPageError(errorMessage),
|
||||
})
|
||||
_, _ = w.Write(registerValidateTmpl)
|
||||
}
|
||||
+501
-60
@@ -1,92 +1,533 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"RenewCMS/adapters/secondary/gateways"
|
||||
"RenewCMS/adapters/secondary/gateways/models"
|
||||
domainGateways "RenewCMS/domain/gateways"
|
||||
"RenewCMS/useCases"
|
||||
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
domainAudit "RenewCMS/internal/domain/audit"
|
||||
domainAuthor "RenewCMS/internal/domain/author"
|
||||
domainContentType "RenewCMS/internal/domain/contenttype"
|
||||
domainEntry "RenewCMS/internal/domain/entry"
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainSite "RenewCMS/internal/domain/site"
|
||||
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"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type UseCases struct {
|
||||
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||
GetArticleUseCase *useCases.GetArticleUseCase
|
||||
ListArticlesUseCase *useCases.ListArticlesUseCase
|
||||
UpdateArticleUseCase *useCases.UpdateArticleUseCase
|
||||
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
CreateUserUseCase *useCases.CreateUserUseCase
|
||||
UpdateUserUseCase *useCases.UpdateUserUseCase
|
||||
DeleteUserUseCase *useCases.DeleteUserUseCase
|
||||
ListUsersUseCase *useCases.ListUsersUseCase
|
||||
GetPageUseCase *useCases.GetPageUseCase
|
||||
SendMailUseCase *useCases.SendMailUseCase
|
||||
CreateImageUseCase *useCases.CreateImageUseCase
|
||||
DeleteImageUseCase *useCases.DeleteImageUseCase
|
||||
apiKeyAuthOnce sync.Once
|
||||
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||
Token *jwtauth.JWTAuth
|
||||
PurgeScheduler *scheduler.Runner
|
||||
ActivityCleanupScheduler *scheduler.Runner
|
||||
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||
GetArticleUseCase *useCases.GetArticleUseCase
|
||||
ListArticlesUseCase *useCases.ListArticlesUseCase
|
||||
UpdateArticleUseCase *useCases.UpdateArticleUseCase
|
||||
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
|
||||
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
|
||||
ListSitemapUseCase *useCases.ListSitemapUseCase
|
||||
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
||||
ListAuthorsUseCase *useCases.ListAuthorsUseCase
|
||||
CreateAuthorUseCase *useCases.CreateAuthorUseCase
|
||||
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
|
||||
UpdateAuthorUseCase *useCases.UpdateAuthorUseCase
|
||||
DeleteAuthorUseCase *useCases.DeleteAuthorUseCase
|
||||
GetMyAuthorUseCase *useCases.GetMyAuthorUseCase
|
||||
ListSitesUseCase *useCases.ListSitesUseCase
|
||||
CreateSiteUseCase *useCases.CreateSiteUseCase
|
||||
UpdateSiteUseCase *useCases.UpdateSiteUseCase
|
||||
SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase
|
||||
DeleteSiteUseCase *useCases.DeleteSiteUseCase
|
||||
CreateContentTypeUseCase *useCases.CreateContentTypeUseCase
|
||||
ListContentTypesUseCase *useCases.ListContentTypesUseCase
|
||||
GetContentTypeUseCase *useCases.GetContentTypeUseCase
|
||||
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
|
||||
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
|
||||
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
|
||||
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
|
||||
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
|
||||
CreateEntryUseCase *useCases.CreateEntryUseCase
|
||||
GetEntryUseCase *useCases.GetEntryUseCase
|
||||
ListEntriesUseCase *useCases.ListEntriesUseCase
|
||||
UpdateEntryUseCase *useCases.UpdateEntryUseCase
|
||||
DeleteEntryUseCase *useCases.DeleteEntryUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
CreateUserUseCase *useCases.CreateUserUseCase
|
||||
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
|
||||
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
|
||||
}
|
||||
|
||||
func (c *UseCases) RunSchedulers(ctx context.Context) {
|
||||
var running sync.WaitGroup
|
||||
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler} {
|
||||
if runner == nil {
|
||||
continue
|
||||
}
|
||||
running.Add(1)
|
||||
go func() {
|
||||
defer running.Done()
|
||||
runner.Run(ctx)
|
||||
}()
|
||||
}
|
||||
running.Wait()
|
||||
}
|
||||
|
||||
type Repositories struct {
|
||||
ArticleRepo domainGateways.IArticleRepository
|
||||
UserRepo domainGateways.IUserRepository
|
||||
ImageRepo domainGateways.IImageRepository
|
||||
MailRepo domainGateways.IMailRepository
|
||||
PageRepo domainGateways.IPageRepository
|
||||
ArticleRepo domainArticle.Repository
|
||||
SiteRepo domainSite.Repository
|
||||
ContentTypeRepo domainContentType.Repository
|
||||
EntryRepo domainEntry.Repository
|
||||
AuthorRepo domainAuthor.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
|
||||
}
|
||||
|
||||
var Container *UseCases
|
||||
const (
|
||||
updateStatusFileName = "update-status.json"
|
||||
updateJobFileName = "update-job.json"
|
||||
updateHelperFileName = "update-helper.json"
|
||||
updatesDirName = "updates"
|
||||
backupsDirName = "backups"
|
||||
)
|
||||
|
||||
func getDb() *gorm.DB {
|
||||
dbName := os.Getenv("DB_FILE")
|
||||
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
|
||||
panic("Unable to create necessary subdirectories: " + err.Error())
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
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 {
|
||||
panic("Unable to open the database: " + err.Error())
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.User{}); err != nil {
|
||||
if err := database.Migrate(connection.DB, settings.PublicURL); err != nil {
|
||||
panic("Failed to migrate database: " + err.Error())
|
||||
}
|
||||
|
||||
return db
|
||||
return connection
|
||||
}
|
||||
|
||||
func getRepositories(db *gorm.DB) *Repositories {
|
||||
func releasePublicKey() ed25519.PublicKey {
|
||||
key, err := update.EmbeddedPublicKey()
|
||||
if err != nil {
|
||||
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: gateways.NewArticleRepository(db),
|
||||
UserRepo: gateways.NewUserRepository(db),
|
||||
ImageRepo: gateways.NewImageRepository(db),
|
||||
MailRepo: gateways.NewMailRepository(),
|
||||
PageRepo: gateways.NewPageRepository(),
|
||||
ArticleRepo: persistence.NewArticleRepository(db),
|
||||
SiteRepo: persistence.NewSiteRepository(db),
|
||||
ContentTypeRepo: persistence.NewContentTypeRepository(db),
|
||||
EntryRepo: persistence.NewEntryRepository(db),
|
||||
AuthorRepo: persistence.NewAuthorRepository(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) *UseCases {
|
||||
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, repos.SiteRepo, repos.AuthorRepo)
|
||||
|
||||
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo)
|
||||
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{
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo),
|
||||
UpdateUserUseCase: useCases.NewUpdateUserUseCase(repos.UserRepo),
|
||||
DeleteUserUseCase: useCases.NewDeleteUserUseCase(repos.UserRepo),
|
||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||
GetPageUseCase: useCases.NewGetPageUseCase(repos.PageRepo),
|
||||
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
|
||||
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
|
||||
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, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
|
||||
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
||||
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
||||
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
|
||||
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
|
||||
CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
|
||||
CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder),
|
||||
UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
|
||||
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo),
|
||||
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
|
||||
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
|
||||
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
|
||||
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, time.Now),
|
||||
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
|
||||
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
|
||||
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
|
||||
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
|
||||
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
|
||||
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
|
||||
CreateEntryUseCase: useCases.NewCreateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
|
||||
GetEntryUseCase: useCases.NewGetEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
ListEntriesUseCase: useCases.NewListEntriesUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
|
||||
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
|
||||
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, repos.SiteRepo, repos.AuthorRepo),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo),
|
||||
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, repos.SiteRepo, repos.AuthorRepo),
|
||||
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 InitContainer() {
|
||||
db := getDb()
|
||||
repos := getRepositories(db)
|
||||
Container = getUseCases(repos)
|
||||
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},
|
||||
})
|
||||
}
|
||||
@@ -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 sites authors"`
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
playground "github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
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 baseArticleResponse 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"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
Authors []author.Byline `json:"authors"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type articleResponse struct {
|
||||
baseArticleResponse
|
||||
Slug string `json:"slug"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
SEOTitle string `json:"seo_title"`
|
||||
SEODescription string `json:"seo_description"`
|
||||
CoverImageID *uint32 `json:"cover_image_id"`
|
||||
CoverImageAlt string `json:"cover_image_alt"`
|
||||
SocialTitle string `json:"social_title"`
|
||||
SocialDescription string `json:"social_description"`
|
||||
SocialImageID *uint32 `json:"social_image_id"`
|
||||
CanonicalURL string `json:"canonical_url"`
|
||||
Noindex bool `json:"noindex"`
|
||||
SEODismissed []string `json:"seo_dismissed"`
|
||||
}
|
||||
|
||||
type listItemResponse struct {
|
||||
baseArticleResponse
|
||||
Excerpt string `json:"excerpt"`
|
||||
Slug string `json:"slug"`
|
||||
SEOStatus string `json:"seo_status"`
|
||||
SEOSuggestions int `json:"seo_suggestions"`
|
||||
}
|
||||
|
||||
func toBaseResponse(article domain.Article, format domain.BodyFormat) (baseArticleResponse, error) {
|
||||
body := json.RawMessage(article.Body)
|
||||
if format != domain.BodyFormatJSON {
|
||||
encoded, err := json.Marshal(article.Body)
|
||||
if err != nil {
|
||||
return baseArticleResponse{}, err
|
||||
}
|
||||
body = encoded
|
||||
}
|
||||
authors := article.Authors
|
||||
if authors == nil {
|
||||
authors = []author.Byline{}
|
||||
}
|
||||
return baseArticleResponse{
|
||||
ID: article.ID,
|
||||
Title: article.Title,
|
||||
Body: body,
|
||||
BodyFormat: format,
|
||||
IsOnline: article.IsOnline,
|
||||
SiteID: article.SiteID,
|
||||
Authors: authors,
|
||||
CreatedAt: article.CreatedAt,
|
||||
UpdatedAt: article.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
|
||||
base, err := toBaseResponse(article, format)
|
||||
if err != nil {
|
||||
return articleResponse{}, err
|
||||
}
|
||||
dismissed := article.SEODismissed
|
||||
if dismissed == nil {
|
||||
dismissed = []string{}
|
||||
}
|
||||
return articleResponse{
|
||||
baseArticleResponse: base,
|
||||
Slug: article.Slug,
|
||||
Excerpt: article.Excerpt,
|
||||
SEOTitle: article.SEOTitle,
|
||||
SEODescription: article.SEODescription,
|
||||
CoverImageID: article.CoverImageID,
|
||||
CoverImageAlt: article.CoverImageAlt,
|
||||
SocialTitle: article.SocialTitle,
|
||||
SocialDescription: article.SocialDescription,
|
||||
SocialImageID: article.SocialImageID,
|
||||
CanonicalURL: article.CanonicalURL,
|
||||
Noindex: article.Noindex,
|
||||
SEODismissed: dismissed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat) (listItemResponse, error) {
|
||||
base, err := toBaseResponse(article, format)
|
||||
if err != nil {
|
||||
return listItemResponse{}, err
|
||||
}
|
||||
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, 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
|
||||
}
|
||||
|
||||
creatorId, _ := claims.UserId(r)
|
||||
createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
|
||||
Title: localArticle.Title,
|
||||
Body: localArticle.Body,
|
||||
Format: format,
|
||||
CreatedBy: creatorId,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidTitle) {
|
||||
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !writeKnownArticleError(w, err) {
|
||||
http.Error(w, "Failed to create article", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
|
||||
}
|
||||
|
||||
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||
if !writeKnownArticleError(w, err) {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func writeKnownArticleError(w http.ResponseWriter, err error) bool {
|
||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, site.ErrSiteNotFound) {
|
||||
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, author.ErrAuthorNotFound) {
|
||||
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrSlugTaken) {
|
||||
writeSlugTaken(w, err)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrInvalidSlug) {
|
||||
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, domain.ErrInvalidCanonicalURL) {
|
||||
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, image.ErrImageNotFound) {
|
||||
http.Error(w, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeSlugTaken(w http.ResponseWriter, err error) {
|
||||
var conflict *domain.SlugTakenError
|
||||
if errors.As(err, &conflict) && conflict.Suggestion != "" {
|
||||
w.Header().Set(apperr.SlugSuggestionHeader, conflict.Suggestion)
|
||||
}
|
||||
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
|
||||
}
|
||||
|
||||
func writeValidationError(w http.ResponseWriter, err error) {
|
||||
var failures playground.ValidationErrors
|
||||
if errors.As(err, &failures) {
|
||||
for _, failure := range failures {
|
||||
if failure.Tag() == validator.ArticleSlugTag {
|
||||
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
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 optionalImageID struct {
|
||||
Set bool
|
||||
Value *uint32 `validate:"omitempty,gte=1"`
|
||||
}
|
||||
|
||||
func (o *optionalImageID) UnmarshalJSON(data []byte) error {
|
||||
o.Set = true
|
||||
if string(data) == "null" {
|
||||
o.Value = nil
|
||||
return nil
|
||||
}
|
||||
var value uint32
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
o.Value = &value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o optionalImageID) toNullable() domain.Nullable[uint32] {
|
||||
return domain.Nullable[uint32]{Set: o.Set, Value: o.Value}
|
||||
}
|
||||
|
||||
type UpdateArticleBody struct {
|
||||
Body *string `json:"body"`
|
||||
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
|
||||
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,dive,gte=1"`
|
||||
Slug *string `json:"slug" validate:"omitempty,articleslug"`
|
||||
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
|
||||
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
|
||||
SEODescription *string `json:"seo_description" validate:"omitempty,max=320"`
|
||||
CoverImageID optionalImageID `json:"cover_image_id"`
|
||||
CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"`
|
||||
SocialTitle *string `json:"social_title" validate:"omitempty,max=120"`
|
||||
SocialDescription *string `json:"social_description" validate:"omitempty,max=320"`
|
||||
SocialImageID optionalImageID `json:"social_image_id"`
|
||||
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
|
||||
Noindex *bool `json:"noindex"`
|
||||
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
|
||||
}
|
||||
|
||||
var nonNullableArticleFields = []string{"body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
|
||||
|
||||
var nullableArticleFields = []string{"cover_image_id", "social_image_id"}
|
||||
|
||||
func hasDuplicates(ids []uint32) bool {
|
||||
seen := make(map[uint32]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
return true
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) {
|
||||
known := 0
|
||||
for name, value := range fields {
|
||||
switch {
|
||||
case slices.Contains(nonNullable, name):
|
||||
if string(value) == "null" {
|
||||
return 0, false
|
||||
}
|
||||
known++
|
||||
case slices.Contains(nullableArticleFields, name):
|
||||
known++
|
||||
}
|
||||
}
|
||||
return known, true
|
||||
}
|
||||
|
||||
func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
known, ok := countKnownFields(fields, nonNullableArticleFields)
|
||||
if !ok || known == 0 {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
var request UpdateArticleBody
|
||||
if err := json.Unmarshal(raw, &request); err != nil {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
if request.AuthorIDs != nil && hasDuplicates(*request.AuthorIDs) {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
request, ok := readUpdateArticleBody(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, useCases.UpdateArticleCommand{
|
||||
Body: request.Body,
|
||||
SiteID: request.SiteID,
|
||||
AuthorIDs: request.AuthorIDs,
|
||||
Slug: request.Slug,
|
||||
Excerpt: request.Excerpt,
|
||||
SEOTitle: request.SEOTitle,
|
||||
SEODescription: request.SEODescription,
|
||||
CoverImageID: request.CoverImageID.toNullable(),
|
||||
CoverImageAlt: request.CoverImageAlt,
|
||||
SocialTitle: request.SocialTitle,
|
||||
SocialDescription: request.SocialDescription,
|
||||
SocialImageID: request.SocialImageID.toNullable(),
|
||||
CanonicalURL: request.CanonicalURL,
|
||||
Noindex: request.Noindex,
|
||||
SEODismissed: request.SEODismissed,
|
||||
Format: 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 {
|
||||
return isVerifiedCaller(r, h.GetUserUseCase)
|
||||
}
|
||||
|
||||
func isVerifiedCaller(r *http.Request, getUser *useCases.GetUserUseCase) bool {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
currentUser, err := getUser.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"`
|
||||
Site *string `validate:"omitempty,min=1"`
|
||||
SEO string `validate:"omitempty,oneof=ready suggestions hidden"`
|
||||
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 []listItemResponse `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")
|
||||
}
|
||||
var siteKey *string
|
||||
if values.Has("site") {
|
||||
key := values.Get("site")
|
||||
if key == "" {
|
||||
return listQuery{}, errors.New("empty site")
|
||||
}
|
||||
siteKey = &key
|
||||
}
|
||||
seoStatus := values.Get("seo")
|
||||
if values.Has("seo") && seoStatus == "" {
|
||||
return listQuery{}, errors.New("empty seo")
|
||||
}
|
||||
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey, SEO: seoStatus}, 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),
|
||||
SiteKey: query.Site,
|
||||
SEO: query.SEO,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]listItemResponse, 0, len(page.Items))
|
||||
for _, article := range page.Items {
|
||||
response, err := toListItemResponse(article, page.SEO[article.ID], 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)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SEOHandler struct {
|
||||
ResolveUseCase *useCases.ResolveArticleSEOUseCase
|
||||
CheckUseCase *useCases.CheckArticleSEOUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
}
|
||||
|
||||
func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase, getUser *useCases.GetUserUseCase) *SEOHandler {
|
||||
return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check, GetUserUseCase: getUser}
|
||||
}
|
||||
|
||||
type CheckArticleSEOBody struct {
|
||||
Body *string `json:"body"`
|
||||
Slug *string `json:"slug" validate:"omitempty,articleslug"`
|
||||
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
|
||||
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
|
||||
SEODescription *string `json:"seo_description" validate:"omitempty,max=320"`
|
||||
CoverImageID optionalImageID `json:"cover_image_id"`
|
||||
CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"`
|
||||
SocialTitle *string `json:"social_title" validate:"omitempty,max=120"`
|
||||
SocialDescription *string `json:"social_description" validate:"omitempty,max=320"`
|
||||
SocialImageID optionalImageID `json:"social_image_id"`
|
||||
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
|
||||
Noindex *bool `json:"noindex"`
|
||||
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
|
||||
}
|
||||
|
||||
var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
|
||||
|
||||
func readCheckBody(r *http.Request) (CheckArticleSEOBody, bool) {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return CheckArticleSEOBody{}, false
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
|
||||
return CheckArticleSEOBody{}, false
|
||||
}
|
||||
known, ok := countKnownFields(fields, checkNonNullableFields)
|
||||
if !ok || known != len(fields) {
|
||||
return CheckArticleSEOBody{}, false
|
||||
}
|
||||
var request CheckArticleSEOBody
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return CheckArticleSEOBody{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseArticleId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resolved, err := h.ResolveUseCase.Resolve(id, isVerifiedCaller(r, h.GetUserUseCase))
|
||||
if err != nil {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
writeSEOJson(w, resolved)
|
||||
}
|
||||
|
||||
func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseArticleId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
request, ok := readCheckBody(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
checked, err := h.CheckUseCase.Check(id, useCases.CheckArticleSEOCommand{
|
||||
Body: request.Body,
|
||||
Slug: request.Slug,
|
||||
Excerpt: request.Excerpt,
|
||||
SEOTitle: request.SEOTitle,
|
||||
SEODescription: request.SEODescription,
|
||||
CoverImageID: request.CoverImageID.toNullable(),
|
||||
CoverImageAlt: request.CoverImageAlt,
|
||||
SocialTitle: request.SocialTitle,
|
||||
SocialDescription: request.SocialDescription,
|
||||
SocialImageID: request.SocialImageID.toNullable(),
|
||||
CanonicalURL: request.CanonicalURL,
|
||||
Noindex: request.Noindex,
|
||||
SEODismissed: request.SEODismissed,
|
||||
})
|
||||
if err != nil {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
writeSEOJson(w, checked)
|
||||
}
|
||||
|
||||
func writeSEOJson(w http.ResponseWriter, payload any) {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(encoded); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/publicuser"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type MeHandler struct {
|
||||
GetUseCase *useCases.GetUserUseCase
|
||||
GetAuthorUseCase *useCases.GetMyAuthorUseCase
|
||||
}
|
||||
|
||||
func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase) *MeHandler {
|
||||
return &MeHandler{
|
||||
GetUseCase: getUser,
|
||||
GetAuthorUseCase: getAuthor,
|
||||
}
|
||||
}
|
||||
|
||||
type meAuthor struct {
|
||||
author.Byline
|
||||
ArticleCount int `json:"article_count"`
|
||||
}
|
||||
|
||||
type meResponse struct {
|
||||
publicuser.PublicUser
|
||||
Author *meAuthor `json:"author"`
|
||||
}
|
||||
|
||||
func toMeAuthor(found *author.Author) *meAuthor {
|
||||
if found == nil {
|
||||
return nil
|
||||
}
|
||||
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
linked, err := h.GetAuthorUseCase.Get(loadedUser.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked)})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package author
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
playground "github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListPage = 1
|
||||
defaultListSize = 25
|
||||
reassignParam = "reassign_to"
|
||||
reassignNone = "none"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
ListUseCase *useCases.ListAuthorsUseCase
|
||||
CreateUseCase *useCases.CreateAuthorUseCase
|
||||
CreateMyUseCase *useCases.CreateMyAuthorUseCase
|
||||
UpdateUseCase *useCases.UpdateAuthorUseCase
|
||||
DeleteUseCase *useCases.DeleteAuthorUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
}
|
||||
|
||||
func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase, getUser *useCases.GetUserUseCase) *Handler {
|
||||
return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase, GetUserUseCase: getUser}
|
||||
}
|
||||
|
||||
type socialLinkRequest struct {
|
||||
Type string `json:"type" validate:"socialtype"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func toLinks(requests []socialLinkRequest) []domain.SocialLink {
|
||||
links := make([]domain.SocialLink, 0, len(requests))
|
||||
for _, request := range requests {
|
||||
links = append(links, domain.SocialLink{Type: request.Type, URL: request.URL})
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
type PostAuthor struct {
|
||||
Name string `json:"name" validate:"authorname"`
|
||||
Slug string `json:"slug" validate:"omitempty,authorslug"`
|
||||
Bio string `json:"bio"`
|
||||
AvatarID *uint32 `json:"avatar_id" validate:"omitempty,gte=1"`
|
||||
Website *string `json:"website"`
|
||||
SocialLinks []socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
|
||||
UserID *uint32 `json:"user_id" validate:"omitempty,gte=1"`
|
||||
}
|
||||
|
||||
type optional[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
}
|
||||
|
||||
func (o *optional[T]) UnmarshalJSON(data []byte) error {
|
||||
o.Set = true
|
||||
if string(data) == "null" {
|
||||
o.Value = nil
|
||||
return nil
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
o.Value = &value
|
||||
return nil
|
||||
}
|
||||
|
||||
type PatchAuthor struct {
|
||||
Name *string `json:"name" validate:"authorname"`
|
||||
Slug *string `json:"slug" validate:"authorslug"`
|
||||
Bio *string `json:"bio"`
|
||||
AvatarID optional[uint32] `json:"avatar_id"`
|
||||
Website optional[string] `json:"website"`
|
||||
SocialLinks *[]socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
|
||||
UserID optional[uint32] `json:"user_id"`
|
||||
}
|
||||
|
||||
func (p PatchAuthor) toPatch() domain.Patch {
|
||||
patch := domain.Patch{
|
||||
Name: p.Name,
|
||||
Slug: p.Slug,
|
||||
Bio: p.Bio,
|
||||
AvatarID: domain.Nullable[uint32]{Set: p.AvatarID.Set, Value: p.AvatarID.Value},
|
||||
Website: domain.Nullable[string]{Set: p.Website.Set, Value: p.Website.Value},
|
||||
UserID: domain.Nullable[uint32]{Set: p.UserID.Set, Value: p.UserID.Value},
|
||||
}
|
||||
if p.SocialLinks != nil {
|
||||
links := toLinks(*p.SocialLinks)
|
||||
patch.SocialLinks = &links
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
var requiredNonNullFields = []string{"name", "slug", "bio", "social_links"}
|
||||
|
||||
var nullableFields = []string{"avatar_id", "website", "user_id"}
|
||||
|
||||
func readPatchAuthor(r *http.Request) (PatchAuthor, bool) {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return PatchAuthor{}, false
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return PatchAuthor{}, false
|
||||
}
|
||||
known := false
|
||||
for _, name := range requiredNonNullFields {
|
||||
value, present := fields[name]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if string(value) == "null" {
|
||||
return PatchAuthor{}, false
|
||||
}
|
||||
known = true
|
||||
}
|
||||
for _, name := range nullableFields {
|
||||
if _, present := fields[name]; present {
|
||||
known = true
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return PatchAuthor{}, false
|
||||
}
|
||||
var request PatchAuthor
|
||||
if err := json.Unmarshal(raw, &request); err != nil {
|
||||
return PatchAuthor{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
func parseAuthorId(r *http.Request) (uint32, error) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
return uint32(id), err
|
||||
}
|
||||
|
||||
type ownAuthorResponse struct {
|
||||
domain.Author
|
||||
SlugAdjusted bool `json:"slug_adjusted"`
|
||||
}
|
||||
|
||||
func presentAuthor(a domain.Author) domain.Author {
|
||||
if a.SocialLinks == nil {
|
||||
a.SocialLinks = []domain.SocialLink{}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthorError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrAuthorNotFound):
|
||||
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrSlugTaken):
|
||||
http.Error(w, apperr.AuthorSlugTakenErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrAuthorHasArticles):
|
||||
http.Error(w, apperr.AuthorHasArticlesErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrUserAlreadyLinked):
|
||||
http.Error(w, apperr.UserAlreadyLinkedErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrAuthorAlreadyLinked):
|
||||
http.Error(w, apperr.AuthorAlreadyLinkedErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrAuthorRights):
|
||||
http.Error(w, apperr.AuthorRightsErrorMessage, http.StatusForbidden)
|
||||
case errors.Is(err, domain.ErrBioTooLong):
|
||||
http.Error(w, apperr.BioTooLongErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, domain.ErrInvalidURL):
|
||||
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, domain.ErrTooManyLinks):
|
||||
http.Error(w, apperr.TooManyLinksErrorMessage, http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, domain.ErrInvalidSocialType):
|
||||
http.Error(w, apperr.InvalidSocialTypeErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidSlug):
|
||||
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidReassign):
|
||||
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, image.ErrImageNotFound):
|
||||
http.Error(w, apperr.AuthorImageNotFoundErrorMessage, http.StatusBadRequest)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func writeValidationError(w http.ResponseWriter, err error) {
|
||||
var failures playground.ValidationErrors
|
||||
if errors.As(err, &failures) {
|
||||
for _, failure := range failures {
|
||||
if failure.Tag() == validator.AuthorSlugTag {
|
||||
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func (h *Handler) currentUser(r *http.Request) (user.User, bool) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
return user.User{}, false
|
||||
}
|
||||
current, err := h.GetUserUseCase.GetUser(userId)
|
||||
if err != nil {
|
||||
return user.User{}, false
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
type listQuery struct {
|
||||
Page int `validate:"gte=1"`
|
||||
Size int `validate:"oneof=5 25 50 100"`
|
||||
Q string `validate:"max=200"`
|
||||
}
|
||||
|
||||
type listResponse struct {
|
||||
Items []domain.Author `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
current, _ := h.currentUser(r)
|
||||
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, CallerRole: current.Role})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]domain.Author, 0, len(page.Items))
|
||||
for _, item := range page.Items {
|
||||
items = append(items, presentAuthor(item))
|
||||
}
|
||||
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
|
||||
writeJSON(w, http.StatusOK, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var request PostAuthor
|
||||
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 {
|
||||
writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateAuthorCommand{
|
||||
Name: request.Name,
|
||||
Slug: request.Slug,
|
||||
Bio: request.Bio,
|
||||
AvatarID: request.AvatarID,
|
||||
Website: request.Website,
|
||||
SocialLinks: toLinks(request.SocialLinks),
|
||||
UserID: request.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
writeAuthorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, presentAuthor(created))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateMine(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.CreateMyUseCase.Create(r.Context(), userId)
|
||||
if err != nil {
|
||||
writeAuthorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, ownAuthorResponse{Author: presentAuthor(created.Author), SlugAdjusted: created.SlugAdjusted})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseAuthorId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
request, ok := readPatchAuthor(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
current, ok := h.currentUser(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: current.ID, Role: current.Role}, request.toPatch())
|
||||
if err != nil {
|
||||
writeAuthorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentAuthor(updated))
|
||||
}
|
||||
|
||||
func readReassignment(r *http.Request) useCases.Reassignment {
|
||||
query := r.URL.Query()
|
||||
if !query.Has(reassignParam) {
|
||||
return useCases.Reassignment{Mode: useCases.ReassignAbsent}
|
||||
}
|
||||
value := query.Get(reassignParam)
|
||||
if value == reassignNone {
|
||||
return useCases.Reassignment{Mode: useCases.ReassignNone}
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 32)
|
||||
if err != nil {
|
||||
return useCases.Reassignment{Mode: useCases.ReassignMalformed}
|
||||
}
|
||||
return useCases.Reassignment{Mode: useCases.ReassignToAuthor, Destination: uint32(parsed)}
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseAuthorId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DeleteUseCase.Delete(r.Context(), id, readReassignment(r)); err != nil {
|
||||
writeAuthorError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package builtintype
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/response"
|
||||
domain "RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
ListUseCase *useCases.ListBuiltinTypesUseCase
|
||||
GetUseCase *useCases.GetBuiltinTypeUseCase
|
||||
}
|
||||
|
||||
func NewHandler(list *useCases.ListBuiltinTypesUseCase, get *useCases.GetBuiltinTypeUseCase) *Handler {
|
||||
return &Handler{ListUseCase: list, GetUseCase: get}
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, http.StatusOK, h.ListUseCase.List())
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
http.Error(w, apperr.BuiltinTypeNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, found)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package contenttype
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/response"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateContentTypeUseCase
|
||||
ListUseCase *useCases.ListContentTypesUseCase
|
||||
GetUseCase *useCases.GetContentTypeUseCase
|
||||
UpdateUseCase *useCases.UpdateContentTypeUseCase
|
||||
DeleteUseCase *useCases.DeleteContentTypeUseCase
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateContentTypeUseCase, list *useCases.ListContentTypesUseCase, get *useCases.GetContentTypeUseCase, update *useCases.UpdateContentTypeUseCase, deleteUseCase *useCases.DeleteContentTypeUseCase) *Handler {
|
||||
return &Handler{CreateUseCase: create, ListUseCase: list, GetUseCase: get, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
|
||||
}
|
||||
|
||||
type iconInput struct {
|
||||
value *string
|
||||
invalid bool
|
||||
}
|
||||
|
||||
func (i *iconInput) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
i.invalid = true
|
||||
return nil
|
||||
}
|
||||
i.value = &text
|
||||
return nil
|
||||
}
|
||||
|
||||
type PostContentType struct {
|
||||
Key string `json:"key" validate:"required"`
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Icon iconInput `json:"icon"`
|
||||
Fields []domain.Field `json:"fields"`
|
||||
}
|
||||
|
||||
type PutContentType struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Icon iconInput `json:"icon"`
|
||||
Fields []domain.Field `json:"fields"`
|
||||
}
|
||||
|
||||
type contentTypeResponse struct {
|
||||
ID uint32 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
Fields []domain.Field `json:"fields"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toResponse(contentType domain.ContentType) contentTypeResponse {
|
||||
fields := contentType.Fields
|
||||
if fields == nil {
|
||||
fields = []domain.Field{}
|
||||
}
|
||||
return contentTypeResponse{
|
||||
ID: contentType.ID,
|
||||
Key: contentType.Key,
|
||||
Name: contentType.Name,
|
||||
Icon: contentType.Icon,
|
||||
Fields: fields,
|
||||
CreatedAt: contentType.CreatedAt,
|
||||
UpdatedAt: contentType.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func writeContentTypeError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrContentTypeNotFound):
|
||||
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrKeyTaken):
|
||||
http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrHasEntries):
|
||||
http.Error(w, apperr.ContentTypeHasEntriesErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrSchemaIncompatible):
|
||||
http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrInvalidName):
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidIcon):
|
||||
http.Error(w, apperr.InvalidIconErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidKey):
|
||||
http.Error(w, apperr.InvalidContentTypeKeyErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidField):
|
||||
http.Error(w, apperr.InvalidFieldErrorMessage, http.StatusUnprocessableEntity)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
found, err := h.ListUseCase.List()
|
||||
if err != nil {
|
||||
writeContentTypeError(w, err)
|
||||
return
|
||||
}
|
||||
responses := make([]contentTypeResponse, 0, len(found))
|
||||
for _, contentType := range found {
|
||||
responses = append(responses, toResponse(contentType))
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
|
||||
if err != nil {
|
||||
writeContentTypeError(w, err)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, toResponse(found))
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var request PostContentType
|
||||
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
|
||||
}
|
||||
if request.Icon.invalid {
|
||||
writeContentTypeError(w, domain.ErrInvalidIcon)
|
||||
return
|
||||
}
|
||||
created, err := h.CreateUseCase.Create(useCases.CreateContentTypeCommand{Key: request.Key, Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
|
||||
if err != nil {
|
||||
writeContentTypeError(w, err)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, http.StatusCreated, toResponse(created))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var request PutContentType
|
||||
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
|
||||
}
|
||||
if request.Icon.invalid {
|
||||
writeContentTypeError(w, domain.ErrInvalidIcon)
|
||||
return
|
||||
}
|
||||
updated, err := h.UpdateUseCase.Update(useCases.UpdateContentTypeCommand{Key: chi.URLParam(r, "key"), Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
|
||||
if err != nil {
|
||||
writeContentTypeError(w, err)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, toResponse(updated))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key")); err != nil {
|
||||
writeContentTypeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package entry
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/response"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
domain "RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateEntryUseCase
|
||||
GetUseCase *useCases.GetEntryUseCase
|
||||
ListUseCase *useCases.ListEntriesUseCase
|
||||
UpdateUseCase *useCases.UpdateEntryUseCase
|
||||
DeleteUseCase *useCases.DeleteEntryUseCase
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateEntryUseCase, get *useCases.GetEntryUseCase, list *useCases.ListEntriesUseCase, update *useCases.UpdateEntryUseCase, deleteUseCase *useCases.DeleteEntryUseCase) *Handler {
|
||||
return &Handler{CreateUseCase: create, GetUseCase: get, ListUseCase: list, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
|
||||
}
|
||||
|
||||
type EntryBody struct {
|
||||
Data map[string]any `json:"data" validate:"required"`
|
||||
}
|
||||
|
||||
type entryResponse struct {
|
||||
ID uint32 `json:"id"`
|
||||
ContentType string `json:"content_type"`
|
||||
Data map[string]any `json:"data"`
|
||||
CreatedBy uint32 `json:"created_by"`
|
||||
UpdatedBy uint32 `json:"updated_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toResponse(typed useCases.TypedEntry) entryResponse {
|
||||
return entryResponse{
|
||||
ID: typed.ID,
|
||||
ContentType: typed.ContentTypeKey,
|
||||
Data: typed.Data,
|
||||
CreatedBy: typed.CreatedBy,
|
||||
UpdatedBy: typed.UpdatedBy,
|
||||
CreatedAt: typed.CreatedAt,
|
||||
UpdatedAt: typed.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type pageResponse struct {
|
||||
Items []entryResponse `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultListPage = 1
|
||||
defaultListSize = 25
|
||||
)
|
||||
|
||||
type listQuery struct {
|
||||
Page int `validate:"gte=1"`
|
||||
Size int `validate:"oneof=5 25 50 100"`
|
||||
Sort domain.Sort
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
order := domain.DefaultSort()
|
||||
if values.Has("sort") {
|
||||
order, err = domain.ParseSort(values.Get("sort"))
|
||||
if err != nil {
|
||||
return listQuery{}, err
|
||||
}
|
||||
}
|
||||
return listQuery{Page: page, Size: size, Sort: order}, nil
|
||||
}
|
||||
|
||||
func parseEntryId(r *http.Request) (uint32, error) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
return uint32(id), err
|
||||
}
|
||||
|
||||
func writeEntryError(w http.ResponseWriter, err error) {
|
||||
var fieldError domain.FieldError
|
||||
switch {
|
||||
case errors.Is(err, contenttype.ErrContentTypeNotFound):
|
||||
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrEntryNotFound):
|
||||
http.Error(w, apperr.EntryNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.As(err, &fieldError):
|
||||
http.Error(w, apperr.InvalidEntryErrorMessage+": "+fieldError.Error(), fieldErrorStatus(err))
|
||||
case errors.Is(err, domain.ErrInvalidPage), errors.Is(err, domain.ErrInvalidSort):
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidEntry):
|
||||
http.Error(w, apperr.InvalidEntryErrorMessage, http.StatusUnprocessableEntity)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func fieldErrorStatus(err error) int {
|
||||
if errors.Is(err, domain.ErrUniqueViolation) {
|
||||
return http.StatusConflict
|
||||
}
|
||||
return http.StatusUnprocessableEntity
|
||||
}
|
||||
|
||||
func readBody(w http.ResponseWriter, r *http.Request) (EntryBody, bool) {
|
||||
var body EntryBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || validator.Validate.Struct(body) != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return EntryBody{}, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func currentUserId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
}
|
||||
return userId, ok
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort})
|
||||
if err != nil {
|
||||
writeEntryError(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]entryResponse, 0, len(page.Items))
|
||||
for _, typed := range page.Items {
|
||||
items = append(items, toResponse(typed))
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, pageResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseEntryId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id)
|
||||
if err != nil {
|
||||
writeEntryError(w, err)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, toResponse(found))
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := currentUserId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := readBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, UserID: userId})
|
||||
if err != nil {
|
||||
writeEntryError(w, err)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, http.StatusCreated, toResponse(created))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseEntryId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId, ok := currentUserId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := readBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, UserID: userId})
|
||||
if err != nil {
|
||||
writeEntryError(w, err)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, toResponse(updated))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseEntryId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id); err != nil {
|
||||
writeEntryError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
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 {
|
||||
Type string `json:"type"`
|
||||
ID uint32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Published *bool `json:"published,omitempty"`
|
||||
Fields []string `json:"fields"`
|
||||
}
|
||||
|
||||
type mergeResponse struct {
|
||||
ArticlesUpdated int `json:"articles_updated"`
|
||||
SitesUpdated int `json:"sites_updated"`
|
||||
AuthorsUpdated int `json:"authors_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) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseImageId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.ListUseCase.Get(id)
|
||||
if err != nil {
|
||||
writeImageError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResponse(item))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
usages, err := h.UsageUseCase.Usage(id)
|
||||
if err != nil {
|
||||
writeImageError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]usageResponse, 0, len(usages))
|
||||
for _, usage := range usages {
|
||||
response = append(response, usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields})
|
||||
}
|
||||
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, SitesUpdated: result.SitesUpdated, AuthorsUpdated: result.AuthorsUpdated, 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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"`
|
||||
SitesUpdated int `json:"sites_updated"`
|
||||
AuthorsUpdated int `json:"authors_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,
|
||||
SitesUpdated: result.SitesUpdated,
|
||||
AuthorsUpdated: result.AuthorsUpdated,
|
||||
FailedGroups: result.FailedGroups,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,296 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/image"
|
||||
domain "RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
ListUseCase *useCases.ListSitesUseCase
|
||||
CreateUseCase *useCases.CreateSiteUseCase
|
||||
UpdateUseCase *useCases.UpdateSiteUseCase
|
||||
SetDefaultUseCase *useCases.SetDefaultSiteUseCase
|
||||
DeleteUseCase *useCases.DeleteSiteUseCase
|
||||
}
|
||||
|
||||
func NewHandler(list *useCases.ListSitesUseCase, create *useCases.CreateSiteUseCase, update *useCases.UpdateSiteUseCase, setDefault *useCases.SetDefaultSiteUseCase, deleteUseCase *useCases.DeleteSiteUseCase) *Handler {
|
||||
return &Handler{ListUseCase: list, CreateUseCase: create, UpdateUseCase: update, SetDefaultUseCase: setDefault, DeleteUseCase: deleteUseCase}
|
||||
}
|
||||
|
||||
type PostSite struct {
|
||||
Name string `json:"name" validate:"required,sitename"`
|
||||
Key string `json:"key" validate:"required,sitekey"`
|
||||
BaseURL string `json:"base_url" validate:"required"`
|
||||
DefaultRobots string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
|
||||
Locale *string `json:"locale"`
|
||||
DefaultSocialImageID *uint32 `json:"default_social_image_id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
SiteName string `json:"site_name" validate:"max=100"`
|
||||
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
|
||||
DefaultDescription string `json:"default_description" validate:"max=320"`
|
||||
}
|
||||
|
||||
type optional[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
}
|
||||
|
||||
func (o *optional[T]) UnmarshalJSON(data []byte) error {
|
||||
o.Set = true
|
||||
if string(data) == "null" {
|
||||
o.Value = nil
|
||||
return nil
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
o.Value = &value
|
||||
return nil
|
||||
}
|
||||
|
||||
type PatchSite struct {
|
||||
Name *string `json:"name" validate:"omitempty,sitename"`
|
||||
Key *string `json:"key" validate:"omitempty,sitekey"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
DefaultRobots *string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
|
||||
Locale optional[string] `json:"locale"`
|
||||
DefaultSocialImageID optional[uint32] `json:"default_social_image_id"`
|
||||
IsDefault *bool `json:"is_default"`
|
||||
SiteName *string `json:"site_name" validate:"omitempty,max=100"`
|
||||
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
|
||||
DefaultDescription *string `json:"default_description" validate:"omitempty,max=320"`
|
||||
}
|
||||
|
||||
func (p PatchSite) toPatch() domain.Patch {
|
||||
return domain.Patch{
|
||||
Name: p.Name,
|
||||
Key: p.Key,
|
||||
BaseURL: p.BaseURL,
|
||||
DefaultRobots: p.DefaultRobots,
|
||||
Locale: domain.Nullable[string]{Set: p.Locale.Set, Value: p.Locale.Value},
|
||||
DefaultSocialImageID: domain.Nullable[uint32]{Set: p.DefaultSocialImageID.Set, Value: p.DefaultSocialImageID.Value},
|
||||
IsDefault: p.IsDefault,
|
||||
SiteName: p.SiteName,
|
||||
TitleTemplate: p.TitleTemplate,
|
||||
DefaultDescription: p.DefaultDescription,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSiteId(r *http.Request) (uint32, error) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
return uint32(id), err
|
||||
}
|
||||
|
||||
func writeSite(w http.ResponseWriter, status int, s domain.Site) {
|
||||
body, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSiteError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrSiteNotFound):
|
||||
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrSiteKeyTaken):
|
||||
http.Error(w, apperr.SiteKeyTakenErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrSiteHasArticles):
|
||||
http.Error(w, apperr.SiteHasArticlesErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrDefaultSite):
|
||||
http.Error(w, apperr.DefaultSiteErrorMessage, http.StatusForbidden)
|
||||
case errors.Is(err, domain.ErrKeyImmutable):
|
||||
http.Error(w, apperr.SiteKeyImmutableErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidSiteURL):
|
||||
http.Error(w, apperr.InvalidSiteURLErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidTitleTemplate):
|
||||
http.Error(w, apperr.InvalidTitleTemplateErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidLocale):
|
||||
http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, image.ErrImageNotFound):
|
||||
http.Error(w, apperr.SiteImageNotFoundErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrUseSetDefault):
|
||||
http.Error(w, apperr.UseSetDefaultErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidReassign):
|
||||
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultListPage = 1
|
||||
defaultListSize = 25
|
||||
)
|
||||
|
||||
type listQuery struct {
|
||||
Page int `validate:"gte=1"`
|
||||
Size int `validate:"omitempty,oneof=5 25 50 100"`
|
||||
Q string `validate:"max=200"`
|
||||
}
|
||||
|
||||
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) {
|
||||
paged := values.Has("page") || values.Has("size")
|
||||
page, err := intQueryParam(values, "page", defaultListPage)
|
||||
if err != nil {
|
||||
return listQuery{}, err
|
||||
}
|
||||
size := 0
|
||||
if paged {
|
||||
size, err = intQueryParam(values, "size", defaultListSize)
|
||||
if err != nil {
|
||||
return listQuery{}, err
|
||||
}
|
||||
if size == 0 {
|
||||
return listQuery{}, errors.New("invalid size")
|
||||
}
|
||||
}
|
||||
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
page, err := h.ListUseCase.ListPage(useCases.ListSitesCommand{Q: query.Q, Page: query.Page, Size: query.Size})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(page.Items)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var request PostSite
|
||||
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
|
||||
}
|
||||
|
||||
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateSiteCommand{
|
||||
Name: request.Name,
|
||||
Key: request.Key,
|
||||
BaseURL: request.BaseURL,
|
||||
DefaultRobots: request.DefaultRobots,
|
||||
Locale: request.Locale,
|
||||
DefaultSocialImageID: request.DefaultSocialImageID,
|
||||
IsDefault: request.IsDefault,
|
||||
SiteName: request.SiteName,
|
||||
TitleTemplate: request.TitleTemplate,
|
||||
DefaultDescription: request.DefaultDescription,
|
||||
})
|
||||
if err != nil {
|
||||
writeSiteError(w, err)
|
||||
return
|
||||
}
|
||||
writeSite(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseSiteId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var request PatchSite
|
||||
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
|
||||
}
|
||||
|
||||
updated, err := h.UpdateUseCase.Update(r.Context(), id, request.toPatch())
|
||||
if err != nil {
|
||||
writeSiteError(w, err)
|
||||
return
|
||||
}
|
||||
writeSite(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *Handler) SetDefault(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseSiteId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.SetDefaultUseCase.SetDefault(r.Context(), id)
|
||||
if err != nil {
|
||||
writeSiteError(w, err)
|
||||
return
|
||||
}
|
||||
writeSite(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
const reassignQueryParam = "reassign_to"
|
||||
|
||||
func readReassign(r *http.Request) (*uint32, bool) {
|
||||
query := r.URL.Query()
|
||||
if !query.Has(reassignQueryParam) {
|
||||
return nil, false
|
||||
}
|
||||
parsed, err := strconv.ParseUint(query.Get(reassignQueryParam), 10, 32)
|
||||
if err != nil {
|
||||
return nil, true
|
||||
}
|
||||
destination := uint32(parsed)
|
||||
return &destination, false
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseSiteId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reassignTo, invalid := readReassign(r)
|
||||
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteSiteCommand{ID: id, ReassignTo: reassignTo, ReassignInvalid: invalid}); err != nil {
|
||||
writeSiteError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSitemapPage = 1
|
||||
defaultSitemapSize = 100
|
||||
totalCountHeader = "X-Total-Count"
|
||||
)
|
||||
|
||||
type SitemapHandler struct {
|
||||
ListUseCase *useCases.ListSitemapUseCase
|
||||
}
|
||||
|
||||
func NewSitemapHandler(list *useCases.ListSitemapUseCase) *SitemapHandler {
|
||||
return &SitemapHandler{ListUseCase: list}
|
||||
}
|
||||
|
||||
type sitemapQuery struct {
|
||||
Page int `validate:"gte=1"`
|
||||
Size int `validate:"oneof=25 50 100 500"`
|
||||
}
|
||||
|
||||
type sitemapItem struct {
|
||||
URL string `json:"url"`
|
||||
ModifiedAt string `json:"modified_at"`
|
||||
Noindex bool `json:"noindex"`
|
||||
}
|
||||
|
||||
func sitemapIntParam(values url.Values, name string, fallback int) (int, error) {
|
||||
if !values.Has(name) {
|
||||
return fallback, nil
|
||||
}
|
||||
return strconv.Atoi(values.Get(name))
|
||||
}
|
||||
|
||||
func readSitemapQuery(values url.Values) (sitemapQuery, error) {
|
||||
page, err := sitemapIntParam(values, "page", defaultSitemapPage)
|
||||
if err != nil {
|
||||
return sitemapQuery{}, err
|
||||
}
|
||||
size, err := sitemapIntParam(values, "size", defaultSitemapSize)
|
||||
if err != nil {
|
||||
return sitemapQuery{}, err
|
||||
}
|
||||
return sitemapQuery{Page: page, Size: size}, nil
|
||||
}
|
||||
|
||||
func (h *SitemapHandler) Sitemap(w http.ResponseWriter, r *http.Request) {
|
||||
query, err := readSitemapQuery(r.URL.Query())
|
||||
if err != nil || validator.Validate.Struct(query) != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page, err := h.ListUseCase.List(chi.URLParam(r, "key"), query.Page, query.Size)
|
||||
if err != nil {
|
||||
writeSiteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]sitemapItem, 0, len(page.Entries))
|
||||
for _, entry := range page.Entries {
|
||||
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex})
|
||||
}
|
||||
body, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set(totalCountHeader, strconv.Itoa(page.Total))
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/response"
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LookupHandler struct {
|
||||
LookupUseCase *useCases.LookupUserNamesUseCase
|
||||
}
|
||||
|
||||
type userNameResponse struct {
|
||||
ID uint32 `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
func NewLookupHandler(lookup *useCases.LookupUserNamesUseCase) *LookupHandler {
|
||||
return &LookupHandler{LookupUseCase: lookup}
|
||||
}
|
||||
|
||||
func parseLookupIDs(raw string) ([]uint32, bool) {
|
||||
if raw == "" {
|
||||
return nil, false
|
||||
}
|
||||
if strings.Count(raw, ",")+1 > domainUser.MaxLookupIDs {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]uint32, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
id, err := strconv.ParseUint(part, 10, 32)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
ids = append(ids, uint32(id))
|
||||
}
|
||||
return ids, true
|
||||
}
|
||||
|
||||
func (h *LookupHandler) Lookup(w http.ResponseWriter, r *http.Request) {
|
||||
ids, ok := parseLookupIDs(r.URL.Query().Get("ids"))
|
||||
if !ok {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
names, err := h.LookupUseCase.Lookup(ids)
|
||||
if errors.Is(err, domainUser.ErrInvalidLookup) {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
items := make([]userNameResponse, 0, len(names))
|
||||
for _, name := range names {
|
||||
items = append(items, userNameResponse{ID: name.ID, DisplayName: name.DisplayName})
|
||||
}
|
||||
response.WriteJSON(w, http.StatusOK, items)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/v3/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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v3/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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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"
|
||||
const SiteNotFoundErrorMessage = "site_not_found"
|
||||
const SiteKeyTakenErrorMessage = "site_key_taken"
|
||||
const SiteHasArticlesErrorMessage = "site_has_articles"
|
||||
const DefaultSiteErrorMessage = "default_site"
|
||||
const SiteKeyImmutableErrorMessage = "key_immutable"
|
||||
const InvalidSiteURLErrorMessage = "invalid_site_url"
|
||||
const InvalidLocaleErrorMessage = "invalid_locale"
|
||||
const SiteImageNotFoundErrorMessage = "image_not_found"
|
||||
const UseSetDefaultErrorMessage = "use_set_default"
|
||||
const InvalidReassignErrorMessage = "invalid_reassign"
|
||||
const InvalidSocialTypeErrorMessage = "invalid_social_type"
|
||||
|
||||
const AuthorNotFoundErrorMessage = "author_not_found"
|
||||
const AuthorSlugTakenErrorMessage = "author_slug_taken"
|
||||
const AuthorHasArticlesErrorMessage = "author_has_articles"
|
||||
const UserAlreadyLinkedErrorMessage = "user_already_linked"
|
||||
const AuthorAlreadyLinkedErrorMessage = "author_already_linked"
|
||||
const UserNotFoundErrorMessage = "user_not_found"
|
||||
const AuthorRightsErrorMessage = "author_rights"
|
||||
const BioTooLongErrorMessage = "bio_too_long"
|
||||
const InvalidURLErrorMessage = "invalid_url"
|
||||
const TooManyLinksErrorMessage = "too_many_links"
|
||||
const InvalidSlugErrorMessage = "invalid_slug"
|
||||
const AuthorImageNotFoundErrorMessage = "image_not_found"
|
||||
|
||||
const ArticleSlugTakenErrorMessage = "slug_taken"
|
||||
const ArticleImageNotFoundErrorMessage = "image_not_found"
|
||||
const InvalidTitleTemplateErrorMessage = "invalid_title_template"
|
||||
const SlugSuggestionHeader = "X-Slug-Suggestion"
|
||||
|
||||
const ContentTypeNotFoundErrorMessage = "content_type_not_found"
|
||||
const ContentTypeKeyTakenErrorMessage = "content_type_key_taken"
|
||||
const ContentTypeHasEntriesErrorMessage = "content_type_has_entries"
|
||||
const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key"
|
||||
const InvalidIconErrorMessage = "invalid_icon"
|
||||
const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found"
|
||||
const InvalidFieldErrorMessage = "invalid_field"
|
||||
const SchemaIncompatibleErrorMessage = "schema_incompatible"
|
||||
const EntryNotFoundErrorMessage = "entry_not_found"
|
||||
const InvalidEntryErrorMessage = "invalid_entry"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, payload any) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"reflect"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
const inviteEmailTag = "inviteemail"
|
||||
const variantNameTag = "variantname"
|
||||
const siteKeyTag = "sitekey"
|
||||
const siteNameTag = "sitename"
|
||||
const AuthorNameTag = "authorname"
|
||||
const AuthorSlugTag = "authorslug"
|
||||
const SocialTypeTag = "socialtype"
|
||||
const ArticleSlugTag = "articleslug"
|
||||
|
||||
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
|
||||
|
||||
var Validate *validator.Validate
|
||||
|
||||
func InitValidator() {
|
||||
Validate = validator.New()
|
||||
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
|
||||
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
|
||||
_ = Validate.RegisterValidation(siteKeyTag, isSiteKey)
|
||||
_ = Validate.RegisterValidation(siteNameTag, isSiteName)
|
||||
_ = Validate.RegisterValidation(AuthorNameTag, isAuthorName, true)
|
||||
_ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true)
|
||||
_ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true)
|
||||
_ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true)
|
||||
}
|
||||
|
||||
func isArticleSlug(field validator.FieldLevel) bool {
|
||||
if isAbsent(field) {
|
||||
return true
|
||||
}
|
||||
value := field.Field().String()
|
||||
return value == "" || article.IsValidSlug(value)
|
||||
}
|
||||
|
||||
func isAbsent(field validator.FieldLevel) bool {
|
||||
return field.Field().Kind() == reflect.Ptr && field.Field().IsNil()
|
||||
}
|
||||
|
||||
func isAuthorName(field validator.FieldLevel) bool {
|
||||
return isAbsent(field) || author.IsValidName(field.Field().String())
|
||||
}
|
||||
|
||||
func isSocialType(field validator.FieldLevel) bool {
|
||||
return author.IsValidSocialType(field.Field().String())
|
||||
}
|
||||
|
||||
func isAuthorSlug(field validator.FieldLevel) bool {
|
||||
return isAbsent(field) || author.IsValidSlug(field.Field().String())
|
||||
}
|
||||
|
||||
func isSiteKey(field validator.FieldLevel) bool {
|
||||
return site.IsValidKey(field.Field().String())
|
||||
}
|
||||
|
||||
func isSiteName(field validator.FieldLevel) bool {
|
||||
return site.IsValidName(field.Field().String())
|
||||
}
|
||||
|
||||
func isVariantName(field validator.FieldLevel) bool {
|
||||
return image.IsValidVariantName(field.Field().String())
|
||||
}
|
||||
|
||||
func isInviteEmail(field validator.FieldLevel) bool {
|
||||
return inviteEmailPattern.MatchString(field.Field().String())
|
||||
}
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/activity"
|
||||
"RenewCMS/api/handlers/apikey"
|
||||
"RenewCMS/api/handlers/article"
|
||||
"RenewCMS/api/handlers/auth"
|
||||
"RenewCMS/api/handlers/author"
|
||||
"RenewCMS/api/handlers/builtintype"
|
||||
"RenewCMS/api/handlers/contenttype"
|
||||
"RenewCMS/api/handlers/entry"
|
||||
"RenewCMS/api/handlers/frontend"
|
||||
"RenewCMS/api/handlers/imagevariant"
|
||||
"RenewCMS/api/handlers/media"
|
||||
"RenewCMS/api/handlers/settings"
|
||||
"RenewCMS/api/handlers/site"
|
||||
"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)
|
||||
|
||||
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase, container.GetUserUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
r.Get("/{id}/seo", seoHandler.Resolve)
|
||||
})
|
||||
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)
|
||||
r.Post("/{id}/seo/check", seoHandler.Check)
|
||||
})
|
||||
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, container.GetMyAuthorUseCase)
|
||||
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)
|
||||
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
|
||||
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.Get("/lookup", lookupHandler.Lookup)
|
||||
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 NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
siteHandler := site.NewHandler(container.ListSitesUseCase, container.CreateSiteUseCase, container.UpdateSiteUseCase, container.SetDefaultSiteUseCase, container.DeleteSiteUseCase)
|
||||
|
||||
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
|
||||
|
||||
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
|
||||
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("/", siteHandler.List)
|
||||
})
|
||||
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.Post("/", siteHandler.Create)
|
||||
r.Patch("/{id}", siteHandler.Update)
|
||||
r.Patch("/{id}/default", siteHandler.SetDefault)
|
||||
r.Delete("/{id}", siteHandler.Delete)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase, container.GetUserUseCase)
|
||||
|
||||
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("/", authorHandler.List)
|
||||
})
|
||||
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.Post("/me", authorHandler.CreateMine)
|
||||
r.Patch("/{id}", authorHandler.Update)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Post("/", authorHandler.Create)
|
||||
r.Delete("/{id}", authorHandler.Delete)
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
contentTypeHandler := contenttype.NewHandler(container.CreateContentTypeUseCase, container.ListContentTypesUseCase, container.GetContentTypeUseCase, container.UpdateContentTypeUseCase, container.DeleteContentTypeUseCase)
|
||||
|
||||
entryHandler := entry.NewHandler(container.CreateEntryUseCase, container.GetEntryUseCase, container.ListEntriesUseCase, container.UpdateEntryUseCase, container.DeleteEntryUseCase)
|
||||
|
||||
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.Get("/", contentTypeHandler.List)
|
||||
r.Get("/{key}", contentTypeHandler.Get)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Post("/", contentTypeHandler.Create)
|
||||
r.Put("/{key}", contentTypeHandler.Update)
|
||||
r.Delete("/{key}", contentTypeHandler.Delete)
|
||||
})
|
||||
r.Get("/{key}/entry", entryHandler.List)
|
||||
r.Post("/{key}/entry", entryHandler.Create)
|
||||
r.Get("/{key}/entry/{id}", entryHandler.Get)
|
||||
r.Put("/{key}/entry/{id}", entryHandler.Update)
|
||||
r.Delete("/{key}/entry/{id}", entryHandler.Delete)
|
||||
return r
|
||||
}
|
||||
|
||||
func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
builtinTypeHandler := builtintype.NewHandler(container.ListBuiltinTypesUseCase, container.GetBuiltinTypeUseCase)
|
||||
|
||||
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.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/", builtinTypeHandler.List)
|
||||
r.Get("/{key}", builtinTypeHandler.Get)
|
||||
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}", mediaHandler.Get)
|
||||
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("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/builtin-type", NewBuiltinTypeRouter(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", "X-Total-Count"},
|
||||
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 +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 |
Vendored
-6
@@ -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 +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
|
||||
Vendored
-7
@@ -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 +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: 472 B |
File diff suppressed because it is too large.
Load diff
Loaded 100 of 1647 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user