Compare commits
149
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6796ce63cb | ||
|
|
99812ddba4 | ||
|
|
0238c7511d | ||
|
|
d7ee0a488c | ||
|
|
c57394d4fd | ||
|
|
f47f145b6c | ||
|
|
83a1dec8ae | ||
|
|
b470bfcc67 | ||
|
|
cb9a431488 | ||
|
|
2292a8ef34 | ||
|
|
a77a5ad314 | ||
|
|
d895477b8c |
No files matched your search
@@ -1,51 +1,20 @@
|
|||||||
root = "."
|
root = "."
|
||||||
testdata_dir = "testdata"
|
|
||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
args_bin = []
|
cmd = "go build -tags nofrontend -o ./tmp/renewcms.exe ./cmd/server"
|
||||||
bin = "tmp\\main.exe"
|
bin = "tmp/renewcms.exe"
|
||||||
cmd = "go build -o ./tmp/main.exe ./main"
|
delay = 500
|
||||||
delay = 1000
|
exclude_dir = ["tmp", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"]
|
||||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "api\\static\\tinymce", "api\\static\\bootstrap-icons", "api\\static\\uploadedImages"]
|
|
||||||
exclude_file = []
|
|
||||||
exclude_regex = ["_test.go"]
|
exclude_regex = ["_test.go"]
|
||||||
exclude_unchanged = false
|
include_ext = ["go", "html"]
|
||||||
follow_symlink = false
|
|
||||||
full_bin = ""
|
|
||||||
include_dir = []
|
|
||||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
|
||||||
include_file = []
|
|
||||||
kill_delay = "0s"
|
|
||||||
log = "build-errors.log"
|
log = "build-errors.log"
|
||||||
poll = false
|
send_interrupt = true
|
||||||
poll_interval = 0
|
kill_delay = "2s"
|
||||||
post_cmd = []
|
stop_on_error = true
|
||||||
pre_cmd = []
|
|
||||||
rerun = false
|
|
||||||
rerun_delay = 500
|
|
||||||
send_interrupt = false
|
|
||||||
stop_on_error = false
|
|
||||||
|
|
||||||
[color]
|
|
||||||
app = ""
|
|
||||||
build = "yellow"
|
|
||||||
main = "magenta"
|
|
||||||
runner = "green"
|
|
||||||
watcher = "cyan"
|
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
main_only = false
|
|
||||||
time = false
|
time = false
|
||||||
|
|
||||||
[misc]
|
[misc]
|
||||||
clean_on_exit = false
|
clean_on_exit = true
|
||||||
|
|
||||||
[proxy]
|
|
||||||
app_port = 0
|
|
||||||
enabled = false
|
|
||||||
proxy_port = 0
|
|
||||||
|
|
||||||
[screen]
|
|
||||||
clear_on_rebuild = false
|
|
||||||
keep_scroll = true
|
|
||||||
@@ -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
|
PUBLIC_URL=http://localhost:5173
|
||||||
HOST=example.com
|
JWT_SECRET=change-me-to-a-long-random-string
|
||||||
PORT=8080
|
|
||||||
JWT_SECRET=abc123
|
|
||||||
CORS_ALLOWED_ORIGINS=*
|
|
||||||
DB_FILE=./gocms.db
|
|
||||||
DOCKER_DB_FOLDER=./data
|
|
||||||
SMTP_EMAIL=a@b.c
|
|
||||||
SMTP_PASSWORD=12341234
|
|
||||||
SMTP_HOST=mail.service.abc
|
SMTP_HOST=mail.service.abc
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
|
SMTP_LOGIN=a@b.c
|
||||||
|
SMTP_PASSWORD=12341234
|
||||||
|
SMTP_SENDER=a@b.c
|
||||||
|
|
||||||
|
ENVIRONMENT=development
|
||||||
|
PORT=8080
|
||||||
|
DB_FILE=./data/renewcms.db
|
||||||
|
UPLOAD_DIR=./data/uploads
|
||||||
|
CORS_ALLOWED_ORIGINS=
|
||||||
|
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
|
||||||
|
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
|
TRUSTED_PROXIES=
|
||||||
|
UPDATE_FEED_URL=https://git.floriansylvain.fr
|
||||||
|
RENEWCMS_CONTAINER=false
|
||||||
|
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
|
||||||
|
IMAGE_VARIANT_MAX_SOURCE_PIXELS=50000000
|
||||||
|
IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=4
|
||||||
|
DB_DRIVER=sqlite
|
||||||
|
DATA_DIR=
|
||||||
|
DB_HOST=
|
||||||
|
DB_PORT=
|
||||||
|
DB_USER=
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_NAME=
|
||||||
|
DB_TLS=false
|
||||||
|
DB_SSLMODE=disable
|
||||||
|
DB_MAX_OPEN_CONNS=10
|
||||||
|
DB_MAX_IDLE_CONNS=5
|
||||||
|
DB_CONN_MAX_LIFETIME_SECONDS=300
|
||||||
|
STORAGE_DRIVER=local
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_KEY_PREFIX=
|
||||||
|
S3_PATH_STYLE=false
|
||||||
|
S3_TLS=true
|
||||||
+4
-1
@@ -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
|
||||||
*.db
|
*.db
|
||||||
|
/data/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
bin
|
bin
|
||||||
|
dist/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
docker.env
|
||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
tmp/
|
tmp/
|
||||||
|
.task/
|
||||||
|
build-errors.log
|
||||||
uploadedImages/
|
uploadedImages/
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
# AI fest
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
.codex/
|
||||||
|
.agents/
|
||||||
|
AGENTS.md
|
||||||
|
codex-workflow.md
|
||||||
@@ -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
|
FROM oven/bun:1-alpine AS web
|
||||||
|
WORKDIR /src/web
|
||||||
WORKDIR /app
|
COPY web/package.json web/bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY web/ ./
|
||||||
|
RUN bun run build-only
|
||||||
|
|
||||||
|
FROM golang:1.27.1-alpine AS server
|
||||||
|
ARG VERSION=dev
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
COPY adapters ./adapters
|
|
||||||
COPY api ./api
|
|
||||||
COPY domain ./domain
|
|
||||||
COPY main ./main
|
|
||||||
COPY useCases ./useCases
|
|
||||||
COPY .env ./.env
|
|
||||||
|
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
RUN go build -o startServer ./main
|
COPY . .
|
||||||
|
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
|
||||||
|
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
|
||||||
|
|
||||||
CMD ["./startServer"]
|
FROM alpine:3.22
|
||||||
|
RUN addgroup -S -g 10001 renewcms \
|
||||||
|
&& adduser -S -u 10001 -G renewcms -H -s /sbin/nologin renewcms \
|
||||||
|
&& mkdir /data \
|
||||||
|
&& chown renewcms:renewcms /data
|
||||||
|
COPY --from=server /out/renewcms /usr/local/bin/renewcms
|
||||||
|
ENV RENEWCMS_CONTAINER=true
|
||||||
|
USER renewcms
|
||||||
|
WORKDIR /data
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["wget", "-qO-", "http://127.0.0.1:8080/healthz"]
|
||||||
|
ENTRYPOINT ["/usr/local/bin/renewcms"]
|
||||||
@@ -1,66 +1,58 @@
|
|||||||
# GoCMS
|
# RenewCMS
|
||||||
|
|
||||||
## 🚧 This project is under development !
|
A small self-hosted CMS: a Go API and a Vue 3 admin/front SPA, shipped as **a single executable** backed by a SQLite file.
|
||||||
|
|
||||||
The features are not complete, not fully tested and subject to many changes.
|
> 🚧 **Under development.** Features are incomplete, not fully tested and subject to change.
|
||||||
|
|
||||||
## Setup
|
## Quick start
|
||||||
|
|
||||||
Don't forget to setup the [Environment variables](#environment-variables)!
|
### Run a release binary
|
||||||
|
|
||||||
### Use with Docker
|
```sh
|
||||||
|
cp .env.example .env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
|
||||||
#### Compose
|
./renewcms # → http://localhost:8080
|
||||||
|
|
||||||
The easiest way to run the project is to use docker-compose.
|
|
||||||
You still need to set up the environment variables, but you can use the `.env` file for that.
|
|
||||||
Note that some supplementary variables are required for docker-compose to work, like `DOCKER_DB_FOLDER`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up --build -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build and run it yourself
|
Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**.
|
||||||
|
|
||||||
#### Build
|
### Run with Docker
|
||||||
|
|
||||||
There are 2 scripts to build the project:
|
```sh
|
||||||
|
cp docker.env.example docker.env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d # http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
- `super_build.sh`
|
Upgrade with `docker compose pull && docker compose up -d`. The image `git.floriansylvain.fr/fsylvain/renewcms` is published by the maintainer with `task docker:publish` (no CI). Tags: `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release; `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `beta` or `rc` for a pre-release (`latest` never moves for those, so the default is `beta` for now). To build from source use the override described in **[docs/docker.md](docs/docker.md)**.
|
||||||
- `super_build.bat`
|
|
||||||
|
|
||||||
They will build the project for windows, linux and macOS. The binaries will be in the `bin` folder.
|
### Updates from the browser
|
||||||
You can also really just use `go build` to build the project for your current OS.
|
|
||||||
|
|
||||||
#### Run
|
On Linux with systemd, install v2.6.0-beta or newer manually, then follow [the browser-update setup guide](docs/deployment.md#browser-updates). It covers finding the actual database directory, installing the helper, testing a restart and troubleshooting. Other platforms use manual upgrades.
|
||||||
|
|
||||||
Once the project is built, you can simply run the binary for your OS. Setting the environment variables is
|
### Develop
|
||||||
of course required, but not necessarily via the `.env` file.
|
|
||||||
|
|
||||||
### Environment variables
|
Requires Go 1.27+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev).
|
||||||
|
|
||||||
| Name | Type | Description | Comment |
|
```sh
|
||||||
|----------------------|--------|:----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
|
cp .env.example .env
|
||||||
| ENVIRONMENT | string | environment the API is running in | required, `development` or `production` |
|
task setup
|
||||||
| HOST | string | the host domain name for emails and callbacks | required |
|
task dev # API with hot reload + Vite HMR → http://localhost:5173
|
||||||
| PORT | int | port the API will use | required |
|
```
|
||||||
| JWT_SECRET | string | secret for the jwt auth | required |
|
|
||||||
| CORS_ALLOWED_ORIGINS | string | allowed origins for CORS | required, semicolon separated list |
|
## Documentation
|
||||||
| DB_FILE | string | path to the sqlite db file | required, can be at the root but name still required (e.g. `./gocms.db`) ; have to end up with `.db` |
|
|
||||||
| DOCKER_DB_FOLDER | string | path to the sqlite db file's folder | required with docker, this will basically be the host machine folder (e.g. `./data`) that contains the sqlite db file |
|
| Doc | Contents |
|
||||||
| SMTP_EMAIL | string | sender email | required |
|
|---|---|
|
||||||
| SMTP_PASSWORD | string | smtp account password | required |
|
| [docs/development.md](docs/development.md) | Dev setup, how Vite and the API fit together, tests, lint, `task` commands |
|
||||||
| SMTP_HOST | string | smtp server address | required |
|
| [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
|
||||||
| SMTP_PORT | int | smtp server port | required |
|
| [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 |
|
||||||
|
|
||||||
## API Usage
|
## API Usage
|
||||||
|
|
||||||
TODO
|
TODO
|
||||||
|
|
||||||
## Demo
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
||||||
## TODOs
|
## TODOs
|
||||||
|
|
||||||
- Cancel button when email validation pending or expired
|
- Cancel button when email validation pending or expired
|
||||||
+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,85 +0,0 @@
|
|||||||
package gateways
|
|
||||||
|
|
||||||
import (
|
|
||||||
entity "GoCMS/adapters/secondary/gateways/models"
|
|
||||||
"GoCMS/domain/gateways"
|
|
||||||
domain "GoCMS/domain/image"
|
|
||||||
"errors"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"mime/multipart"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ImageRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewImageRepository(db *gorm.DB) *ImageRepository {
|
|
||||||
return &ImageRepository{db}
|
|
||||||
}
|
|
||||||
|
|
||||||
var contentTypeExtensions = map[string]string{
|
|
||||||
"image/png": ".png",
|
|
||||||
"image/jpeg": ".jpeg",
|
|
||||||
"image/webp": ".webp",
|
|
||||||
"image/svg+xml": ".svg",
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapImageToDomain(image entity.Image) domain.Image {
|
|
||||||
return domain.FromDB(
|
|
||||||
image.ID,
|
|
||||||
image.Path,
|
|
||||||
image.PostID,
|
|
||||||
image.CreatedAt,
|
|
||||||
image.UpdatedAt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i ImageRepository) Create(file multipart.File, fileHeader multipart.FileHeader) (domain.Image, error) {
|
|
||||||
fileBytes := make([]byte, fileHeader.Size)
|
|
||||||
_, err := file.Read(fileBytes)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Image{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.MkdirAll("api/static/uploadedImages/", 0666)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Image{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := fileHeader.Header.Get("Content-Type")
|
|
||||||
extension := contentTypeExtensions[contentType]
|
|
||||||
if extension == "" {
|
|
||||||
return domain.Image{}, errors.New("the file must be a PNG, JPEG, WEBP, or SVG image")
|
|
||||||
}
|
|
||||||
|
|
||||||
newName := uuid.NewString() + extension
|
|
||||||
err = os.WriteFile("api/static/uploadedImages/"+newName, fileBytes, 0666)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Image{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
newImage := i.db.Create(&domain.Image{Path: "/static/uploadedImages/" + newName})
|
|
||||||
var createdImage entity.Image
|
|
||||||
newImage.Scan(&createdImage)
|
|
||||||
|
|
||||||
return mapImageToDomain(createdImage), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i ImageRepository) Delete(id uint32) error {
|
|
||||||
var image entity.Image
|
|
||||||
err := i.db.Model(&entity.Image{}).First(&image, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Remove("api" + image.Path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return i.db.Delete(&entity.Image{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ gateways.IImageRepository = &ImageRepository{}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<body>
|
|
||||||
<h1>
|
|
||||||
<img src="{{.Host}}/static/gocms-favicon-128.png" alt="G" style="width: 2.5rem"/>
|
|
||||||
GoCMS
|
|
||||||
</h1>
|
|
||||||
<main>
|
|
||||||
<p>You or someone tried to create a GoCMS admin account with this e-mail address. If you are not responsible for
|
|
||||||
this, please do not validate the account creation.</p>
|
|
||||||
<p><b>If you want to validate the account creation, please <a
|
|
||||||
href="{{.Host}}/register/validate?c={{.VerificationCode}}" target="_blank">click HERE</a>.</b>
|
|
||||||
</p>
|
|
||||||
<p>If you can't click the link:</p>
|
|
||||||
<p>{{.Host}}/register/validate?c={{.VerificationCode}}</p>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<body>
|
|
||||||
<h1>
|
|
||||||
<img src="{{.Host}}/static/gocms-favicon-128.png" alt="G" style="width: 2.5rem"/>
|
|
||||||
GoCMS
|
|
||||||
</h1>
|
|
||||||
<main>
|
|
||||||
<p>You or someone tried to create reset your GoCMS account with this e-mail address. If you are not responsible for
|
|
||||||
this, please do not validate the password reset.</p>
|
|
||||||
<p><b>If you want to reset your password, please <a
|
|
||||||
href="{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}" target="_blank">click HERE</a>.</b>
|
|
||||||
</p>
|
|
||||||
<p>If you can't click the link:</p>
|
|
||||||
<p>{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}</p>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package gateways
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/domain/gateways"
|
|
||||||
"bytes"
|
|
||||||
"embed"
|
|
||||||
"gopkg.in/gomail.v2"
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MailRepository struct{}
|
|
||||||
|
|
||||||
//go:embed mail/templates/*
|
|
||||||
var mailTemplateFiles embed.FS
|
|
||||||
|
|
||||||
func (m MailRepository) Send(receiverAddress string, templateName string, data interface{}) error {
|
|
||||||
from := os.Getenv("SMTP_EMAIL")
|
|
||||||
password := os.Getenv("SMTP_PASSWORD")
|
|
||||||
smtpHost := os.Getenv("SMTP_HOST")
|
|
||||||
smtpPort, _ := strconv.Atoi(os.Getenv("SMTP_PORT"))
|
|
||||||
|
|
||||||
d := gomail.NewDialer(smtpHost, smtpPort, from, password)
|
|
||||||
|
|
||||||
tmpl, err := template.ParseFS(mailTemplateFiles, "mail/templates/"+templateName+".html")
|
|
||||||
if err != nil {
|
|
||||||
log.Println("error when template.ParseFS:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var body bytes.Buffer
|
|
||||||
err = tmpl.Execute(&body, data)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("error when tmpl.Execute:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := gomail.NewMessage()
|
|
||||||
msg.SetHeaders(map[string][]string{
|
|
||||||
"From": {"GoCMS <" + from + ">"},
|
|
||||||
"To": {receiverAddress},
|
|
||||||
"MIME-version": {"1.0"},
|
|
||||||
"Content-Type": {"text/html"},
|
|
||||||
"charset": {"UTF-8"},
|
|
||||||
"Subject": {"GoCMS | Action required"},
|
|
||||||
})
|
|
||||||
msg.SetBody("text/html", body.String())
|
|
||||||
|
|
||||||
if err := d.DialAndSend(msg); err != nil {
|
|
||||||
log.Println("error when sending email:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ gateways.IMailRepository = &MailRepository{}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Image struct {
|
|
||||||
gorm.Model
|
|
||||||
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
|
|
||||||
Path string
|
|
||||||
PostID uint32
|
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
gorm.Model
|
|
||||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
|
||||||
Username string `gorm:"unique;not null"`
|
|
||||||
Password string `gorm:"not null"`
|
|
||||||
PasswordResetCode string `gorm:"unique"`
|
|
||||||
Email string `gorm:"unique;not null"`
|
|
||||||
IsVerified bool `gorm:"default=false;not null"`
|
|
||||||
VerificationCode string `gorm:"unique"`
|
|
||||||
VerificationExpiration time.Time
|
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package gateways
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/domain/gateways"
|
|
||||||
"bytes"
|
|
||||||
"embed"
|
|
||||||
"html/template"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PageRepository struct{}
|
|
||||||
|
|
||||||
//go:embed web/templates/*
|
|
||||||
var webTemplateFiles embed.FS
|
|
||||||
|
|
||||||
func NewPageRepository() *PageRepository {
|
|
||||||
return &PageRepository{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PageRepository) Get(name string, data interface{}) ([]byte, error) {
|
|
||||||
var processedHTML bytes.Buffer
|
|
||||||
tmpl, err := template.ParseFS(webTemplateFiles, "web/templates/"+name+".html")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
err = tmpl.Execute(&processedHTML, data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return processedHTML.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ gateways.IPageRepository = &PageRepository{}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package gateways
|
|
||||||
|
|
||||||
import (
|
|
||||||
entity "GoCMS/adapters/secondary/gateways/models"
|
|
||||||
"GoCMS/domain/gateways"
|
|
||||||
domain "GoCMS/domain/post"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PostRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPostRepository(db *gorm.DB) *PostRepository {
|
|
||||||
return &PostRepository{db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapPostToDomain(post entity.Post) domain.Post {
|
|
||||||
return domain.FromDb(post.ID, post.Title, post.Body, post.Images, post.IsOnline, post.CreatedAt, post.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PostRepository) Get(id uint32) (domain.Post, error) {
|
|
||||||
var post entity.Post
|
|
||||||
err := a.db.Model(&entity.Post{}).Preload("Images").First(&post, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.Post{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapPostToDomain(post), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PostRepository) GetByName(name string) (domain.Post, error) {
|
|
||||||
var post entity.Post
|
|
||||||
err := a.db.Model(&entity.Post{}).Where("title = ?", name).First(&post).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.Post{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapPostToDomain(post), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PostRepository) Create(post domain.Post) (domain.Post, error) {
|
|
||||||
creationResult := a.db.Create(&entity.Post{
|
|
||||||
Title: post.Title,
|
|
||||||
Body: post.Body,
|
|
||||||
})
|
|
||||||
if creationResult.Error != nil {
|
|
||||||
return domain.Post{}, creationResult.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
var createdPost entity.Post
|
|
||||||
creationResult.Scan(&createdPost)
|
|
||||||
|
|
||||||
return mapPostToDomain(createdPost), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PostRepository) GetAll() []domain.Post {
|
|
||||||
var posts []entity.Post
|
|
||||||
err := a.db.Model(&entity.Post{}).Find(&posts).Error
|
|
||||||
if err != nil {
|
|
||||||
return []domain.Post{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainPosts = make([]domain.Post, 0)
|
|
||||||
for _, post := range posts {
|
|
||||||
domainPosts = append(domainPosts, mapPostToDomain(post))
|
|
||||||
}
|
|
||||||
|
|
||||||
return domainPosts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PostRepository) UpdateBody(id uint32, body string) (domain.Post, error) {
|
|
||||||
var localPost entity.Post
|
|
||||||
err := a.db.Model(&entity.Post{}).First(&localPost, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.Post{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
localPost.Body = body
|
|
||||||
err = a.db.Save(&localPost).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.Post{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
newPost := domain.FromDb(
|
|
||||||
localPost.ID,
|
|
||||||
localPost.Title,
|
|
||||||
localPost.Body,
|
|
||||||
localPost.Images,
|
|
||||||
localPost.IsOnline,
|
|
||||||
localPost.CreatedAt,
|
|
||||||
localPost.UpdatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
return newPost, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PostRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Post, error) {
|
|
||||||
var localPost entity.Post
|
|
||||||
err := a.db.Model(&entity.Post{}).First(&localPost, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.Post{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
localPost.IsOnline = isOnline
|
|
||||||
err = a.db.Save(&localPost).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.Post{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapPostToDomain(localPost), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PostRepository) Delete(id uint32) error {
|
|
||||||
return a.db.Delete(&entity.Post{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PostRepository) AddImage(postId uint32, imageId uint32) error {
|
|
||||||
var localPost entity.Post
|
|
||||||
err := a.db.Model(&entity.Post{}).First(&localPost, postId).Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var localImage entity.Image
|
|
||||||
err = a.db.Model(&entity.Image{}).First(&localImage, imageId).Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.db.Model(&localPost).Association("Images").Append(&localImage)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ gateways.IPostRepository = &PostRepository{}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
package gateways
|
|
||||||
|
|
||||||
import (
|
|
||||||
entity "GoCMS/adapters/secondary/gateways/models"
|
|
||||||
"GoCMS/domain/gateways"
|
|
||||||
"GoCMS/domain/user"
|
|
||||||
domain "GoCMS/domain/user"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
|
||||||
return &UserRepository{db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapUserToDomain(user entity.User) domain.User {
|
|
||||||
return domain.FromDb(
|
|
||||||
user.ID,
|
|
||||||
user.Username,
|
|
||||||
user.Password,
|
|
||||||
user.PasswordResetCode,
|
|
||||||
user.Email,
|
|
||||||
user.IsVerified,
|
|
||||||
user.VerificationCode,
|
|
||||||
user.VerificationExpiration,
|
|
||||||
user.CreatedAt, user.UpdatedAt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserRepository) Get(id uint32) (domain.User, error) {
|
|
||||||
var localUser entity.User
|
|
||||||
err := u.db.Model(&entity.User{}).First(&localUser, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapUserToDomain(localUser), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserRepository) Create(user domain.User) (domain.User, error) {
|
|
||||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(user.Password), 12)
|
|
||||||
hashedVerificationCode, _ := bcrypt.GenerateFromPassword([]byte(user.VerificationCode), 12)
|
|
||||||
|
|
||||||
creationResult := u.db.Create(&entity.User{
|
|
||||||
Username: user.Username,
|
|
||||||
Password: string(hashedPassword),
|
|
||||||
Email: user.Email,
|
|
||||||
VerificationCode: string(hashedVerificationCode),
|
|
||||||
VerificationExpiration: user.VerificationExpiration,
|
|
||||||
})
|
|
||||||
if creationResult.Error != nil {
|
|
||||||
return domain.User{}, creationResult.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
var createdUser entity.User
|
|
||||||
creationResult.Scan(&createdUser)
|
|
||||||
|
|
||||||
return mapUserToDomain(createdUser),
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserRepository) Delete(id uint32) error {
|
|
||||||
return u.db.Delete(&user.User{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserRepository) GetAll() []domain.User {
|
|
||||||
var users []entity.User
|
|
||||||
u.db.Model(&entity.User{}).Find(&users)
|
|
||||||
|
|
||||||
var domainUsers []domain.User
|
|
||||||
for _, localUser := range users {
|
|
||||||
domainUsers = append(domainUsers, mapUserToDomain(localUser))
|
|
||||||
}
|
|
||||||
|
|
||||||
return domainUsers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserRepository) GetByUsername(username string) (domain.User, error) {
|
|
||||||
var localUser entity.User
|
|
||||||
err := u.db.Model(&entity.User{}).Where("username = ?", username).First(&localUser).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapUserToDomain(localUser), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserRepository) GetByEmail(email string) (domain.User, error) {
|
|
||||||
var localUser entity.User
|
|
||||||
err := u.db.Model(&entity.User{}).Where("email = ?", email).First(&localUser).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapUserToDomain(localUser), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserRepository) UpdateVerificationStatus(userId uint32, isVerified bool) (domain.User, error) {
|
|
||||||
var localUser entity.User
|
|
||||||
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
localUser.IsVerified = isVerified
|
|
||||||
err = u.db.Save(&localUser).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapUserToDomain(localUser), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserRepository) UpdatePassword(userId uint32, password string) (domain.User, error) {
|
|
||||||
var localUser entity.User
|
|
||||||
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), 12)
|
|
||||||
localUser.Password = string(hashedPassword)
|
|
||||||
err = u.db.Save(&localUser).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapUserToDomain(localUser), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserRepository) UpdatePasswordResetCode(userId uint32, code string) (domain.User, error) {
|
|
||||||
var localUser entity.User
|
|
||||||
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), 12)
|
|
||||||
localUser.PasswordResetCode = string(hashedCode)
|
|
||||||
err = u.db.Save(&localUser).Error
|
|
||||||
if err != nil {
|
|
||||||
return domain.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapUserToDomain(localUser), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ gateways.IUserRepository = &UserRepository{}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex gap-2 justify-content-center align-items-center">
|
|
||||||
<img src="/static/gocms-favicon-128.png" alt="Logo" style="width: 2.5rem"/>
|
|
||||||
<a class="navbar-brand h1 my-auto" href="https://github.com/Floriansylvain/GoCMS" target="_blank">
|
|
||||||
GoCMS
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
|
||||||
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/home">Home</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/post">Posts</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/integration">Integration</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link text-body-tertiary text-decoration-underline" href="https://www.paypal.com/donate/?hosted_button_id=S6LUFUYK84CYY">support me!</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link disconnect-link" href="/logout">Log out</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Home</title>
|
|
||||||
{{.Head}}
|
|
||||||
</head>
|
|
||||||
<body class="text-dark">
|
|
||||||
{{.Navbar}}
|
|
||||||
<div class="container mt-3 text-black-50">
|
|
||||||
<h1>Accueil</h1>
|
|
||||||
<p>Welcome on GoCMS !</p>
|
|
||||||
<a href="/post">
|
|
||||||
<button class="btn btn-outline-primary">Posts »</button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Home</title>
|
|
||||||
{{.Head}}
|
|
||||||
</head>
|
|
||||||
<body class="text-dark">
|
|
||||||
{{.Navbar}}
|
|
||||||
<div class="container mt-3 text-black-50">
|
|
||||||
<h1>Implementation</h1>
|
|
||||||
<p>How to use headless API</p>
|
|
||||||
<div class="mt-5">
|
|
||||||
<section>
|
|
||||||
<h2>Introduction</h2>
|
|
||||||
<p>This documentation explains how to integrate our headless CMS API to retrieve post data via the <code>/post</code>
|
|
||||||
route.</p>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h2>/v1/post</h2>
|
|
||||||
<p>The <code>{{ .Host }}/v1/post</code> route returns a list of posts in JSON format. Each post has the
|
|
||||||
following
|
|
||||||
structure:</p>
|
|
||||||
<pre>
|
|
||||||
<code>[
|
|
||||||
{
|
|
||||||
"id": 12,
|
|
||||||
"title": "This is a post!",
|
|
||||||
"body": "<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/post/{id}</h2>
|
|
||||||
<p>The <code>{{ .Host }}/v1/post/{id}</code> route returns a single post in JSON format. The post has the
|
|
||||||
following
|
|
||||||
structure:</p>
|
|
||||||
<pre>
|
|
||||||
<code>{
|
|
||||||
"id": 12,
|
|
||||||
"title": "ravioli sans le ra et le i",
|
|
||||||
"body": "<\p>?<\/p>\r\n<\p><\img src=\"../../static/uploadedImages/649691a8-4f2e-48ca-9abe-27a60621d2e4.png\" alt=\"\"
|
|
||||||
width=\"391\" height=\"465\"><\/p>",
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"id": 17,
|
|
||||||
"path": "/static/uploadedImages/649691a8-4f2e-48ca-9abe-27a60621d2e4.png",
|
|
||||||
"post_id": 0,
|
|
||||||
"created_at": "2024-06-13T15:31:09.728359+02:00",
|
|
||||||
"updated_at": "2024-06-13T15:31:09.728359+02:00"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created_at": "2024-06-13T15:30:55.3563238+02:00",
|
|
||||||
"updated_at": "2024-06-13T15:31:17.5508931+02:00"
|
|
||||||
}</code>
|
|
||||||
</pre>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Login</title>
|
|
||||||
{{.Head}}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.form-container {
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex flex-column m-auto form-container">
|
|
||||||
<div>
|
|
||||||
<h1>GoCMS</h1>
|
|
||||||
<h2>Login</h2>
|
|
||||||
</div>
|
|
||||||
<form action="login" method="POST" class="mt-5" id="loginForm">
|
|
||||||
<div class="d-flex flex-column gap-4">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control {{ if .PageError.IsError }} is-invalid {{ end }}"
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
placeholder="Username"
|
|
||||||
required
|
|
||||||
type="text"
|
|
||||||
value="{{.Username}}">
|
|
||||||
<label for="username">Username</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control {{ if .PageError.IsError }} is-invalid {{ end }}"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Password"
|
|
||||||
required
|
|
||||||
type="password">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<div class="invalid-feedback">{{.PageError.Message}}</div>
|
|
||||||
</div>
|
|
||||||
<button id="loginFormButton" class="btn btn-primary" disabled type="submit">
|
|
||||||
<span class="loginFormButtonLoading visually-hidden spinner-border spinner-border-sm"
|
|
||||||
role="status"></span>
|
|
||||||
<span class="loginFormButtonDefault">Log in</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div class="d-flex gap-2 my-2 w-100">
|
|
||||||
<a href="register" class="btn btn-outline-secondary w-100" type="submit">
|
|
||||||
First setup? Register
|
|
||||||
</a>
|
|
||||||
<a href="register/reset/request" class="btn btn-outline-secondary w-100" type="submit">
|
|
||||||
Forgot password
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{{ if .Success }}
|
|
||||||
<div class="alert alert-success my-4" role="alert">
|
|
||||||
{{ .Success }}
|
|
||||||
</div>
|
|
||||||
{{ else if .Failure }}
|
|
||||||
<div class="alert alert-danger my-4" role="alert">
|
|
||||||
{{ .Failure }}
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const button = document.querySelector("#loginFormButton")
|
|
||||||
const inputs = document.querySelectorAll('input')
|
|
||||||
const form = document.querySelector("#loginForm")
|
|
||||||
|
|
||||||
function formFieldsEmpty() {
|
|
||||||
return Array.from(inputs).some((input) => input.value === "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFormValid() {
|
|
||||||
return !formFieldsEmpty() && validateEmail()
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonDisabled() {
|
|
||||||
button.disabled = isFormValid() ? "disabled" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonLoading() {
|
|
||||||
button.classList.add("disabled")
|
|
||||||
button.querySelector(".loginFormButtonDefault").classList.add("visually-hidden")
|
|
||||||
button.querySelector(".loginFormButtonLoading").classList.remove("visually-hidden")
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLoginFormSubmit(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
setButtonLoading()
|
|
||||||
event.target.submit()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInput(event) {
|
|
||||||
if (event.target.tagName === "INPUT") setButtonDisabled()
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', onLoginFormSubmit)
|
|
||||||
window.addEventListener('input', onInput)
|
|
||||||
setButtonDisabled()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Password reset</title>
|
|
||||||
{{.Head}}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.form-container {
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex flex-column m-auto form-container">
|
|
||||||
<div>
|
|
||||||
<h1>GoCMS</h1>
|
|
||||||
<h2>Admin account password reset</h2>
|
|
||||||
</div>
|
|
||||||
<form action="request" method="POST" class="mt-5" id="registerForm">
|
|
||||||
<div class="d-flex flex-column gap-4">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control {{ if .PageError.Email }} is-invalid {{ end }}"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
placeholder="E-mail"
|
|
||||||
required
|
|
||||||
type="email"
|
|
||||||
value="{{.Email}}"
|
|
||||||
onblur="validateEmail()"
|
|
||||||
{{ if .Email }} disabled {{ end }}>
|
|
||||||
<label for="email">E-mail</label>
|
|
||||||
</div>
|
|
||||||
<button id="registerFormButton" class="btn btn-primary" disabled type="submit">
|
|
||||||
<span class="registerFormButtonLoading visually-hidden spinner-border spinner-border-sm"
|
|
||||||
role="status"></span>
|
|
||||||
<span class="registerFormButtonDefault">Send e-mail</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{{ if .Success }}
|
|
||||||
<div class="alert alert-success my-4" role="alert">
|
|
||||||
{{ .Success }}
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const docElems = {
|
|
||||||
form: document.querySelector("#registerForm"),
|
|
||||||
button: document.querySelector("#registerFormButton"),
|
|
||||||
inputs: document.querySelectorAll("input"),
|
|
||||||
email: document.querySelector("#email"),
|
|
||||||
}
|
|
||||||
|
|
||||||
function formSomeFieldsEmpty() {
|
|
||||||
return Array.from(docElems.inputs).some((input) => input.value === "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonLoading() {
|
|
||||||
docElems.button.classList.add("disabled")
|
|
||||||
docElems.button.querySelector(".registerFormButtonDefault").classList.add("visually-hidden")
|
|
||||||
docElems.button.querySelector(".registerFormButtonLoading").classList.remove("visually-hidden")
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRegisterFormSubmit(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
setButtonLoading()
|
|
||||||
if (formSomeFieldsEmpty()) return
|
|
||||||
event.target.submit()
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateEmail() {
|
|
||||||
const email = docElems.email.value.trim()
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
||||||
|
|
||||||
if (!emailRegex.test(email)) {
|
|
||||||
docElems.email.classList.add("is-invalid")
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
docElems.email.classList.remove("is-invalid")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInput(event) {
|
|
||||||
if (event.target.tagName === "INPUT") {
|
|
||||||
docElems.button.disabled = !validateEmail() || formSomeFieldsEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
docElems.form.addEventListener('submit', onRegisterFormSubmit)
|
|
||||||
window.addEventListener('input', onInput)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Password reset</title>
|
|
||||||
{{.Head}}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.form-container {
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex flex-column m-auto form-container">
|
|
||||||
<div>
|
|
||||||
<h1>GoCMS</h1>
|
|
||||||
<h2>Admin account password reset</h2>
|
|
||||||
</div>
|
|
||||||
<form action="validate" method="POST" class="mt-5" id="registerForm">
|
|
||||||
<div class="d-flex flex-column gap-4">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control"
|
|
||||||
id="code"
|
|
||||||
name="code"
|
|
||||||
placeholder="Code"
|
|
||||||
type="text"
|
|
||||||
value="{{.Code}}"
|
|
||||||
readonly>
|
|
||||||
<label for="code">Code</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
placeholder="E-mail"
|
|
||||||
type="email"
|
|
||||||
value="{{.Email}}"
|
|
||||||
readonly>
|
|
||||||
<label for="email">E-mail</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Password"
|
|
||||||
required
|
|
||||||
type="password">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<div class="invalid-feedback">The password must contain at least 8 characters.</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
|
|
||||||
id="confirmPassword"
|
|
||||||
name="confirmPassword"
|
|
||||||
placeholder="Password confirmation"
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
onblur="validatePasswordMatch()"
|
|
||||||
oninput="validatePasswordMatch()">
|
|
||||||
<label for="confirmPassword">Password confirmation</label>
|
|
||||||
<div class="invalid-feedback">The passwords do not match.</div>
|
|
||||||
</div>
|
|
||||||
<button id="registerFormButton" class="btn btn-primary" disabled type="submit">
|
|
||||||
<span class="registerFormButtonLoading visually-hidden spinner-border spinner-border-sm"
|
|
||||||
role="status"></span>
|
|
||||||
<span class="registerFormButtonDefault">Confirm changes</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{{ if .Error.Message }}
|
|
||||||
<div class="alert alert-danger my-4" role="alert">
|
|
||||||
{{ .Error.Message }}
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const docElems = {
|
|
||||||
form: document.querySelector("#registerForm"),
|
|
||||||
button: document.querySelector("#registerFormButton"),
|
|
||||||
inputs: document.querySelectorAll("input"),
|
|
||||||
email: document.querySelector("#email"),
|
|
||||||
password: document.querySelector("#password"),
|
|
||||||
confirmPassword: document.querySelector("#confirmPassword"),
|
|
||||||
}
|
|
||||||
|
|
||||||
function formSomeFieldsEmpty() {
|
|
||||||
return Array.from(docElems.inputs).some((input) => input.value === "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonLoading() {
|
|
||||||
docElems.button.classList.add("disabled")
|
|
||||||
docElems.button.querySelector(".registerFormButtonDefault").classList.add("visually-hidden")
|
|
||||||
docElems.button.querySelector(".registerFormButtonLoading").classList.remove("visually-hidden")
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRegisterFormSubmit(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
setButtonLoading()
|
|
||||||
if (formSomeFieldsEmpty()) return
|
|
||||||
event.target.submit()
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateEmail() {
|
|
||||||
const email = docElems.email.value.trim()
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
||||||
|
|
||||||
if (!emailRegex.test(email)) {
|
|
||||||
docElems.email.classList.add("is-invalid")
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
docElems.email.classList.remove("is-invalid")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInput(event) {
|
|
||||||
if (event.target.tagName === "INPUT") {
|
|
||||||
docElems.button.disabled = !validateEmail() || formSomeFieldsEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validatePasswordMatch() {
|
|
||||||
if (docElems.password.value !== docElems.confirmPassword.value) {
|
|
||||||
docElems.confirmPassword.classList.add("is-invalid")
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
docElems.confirmPassword.classList.remove("is-invalid")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
docElems.form.addEventListener('submit', onRegisterFormSubmit)
|
|
||||||
window.addEventListener('input', onInput)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Create post</title>
|
|
||||||
{{.Head}}
|
|
||||||
<style>
|
|
||||||
form {
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="text-dark">
|
|
||||||
{{.Navbar}}
|
|
||||||
<div class="container mt-3 text-black-50">
|
|
||||||
<h1>Post - creation</h1>
|
|
||||||
<p>Create a new post</p>
|
|
||||||
<form action="create" method="post" class="d-flex align-items-center flex-column gap-3" id="createPostForm">
|
|
||||||
<div class="form-floating w-100">
|
|
||||||
<input type="text" class="form-control {{ if .PageError.IsError }} is-invalid {{ end }}" id="name"
|
|
||||||
name="name" placeholder="Chose the post name" value="{{ .Name }}">
|
|
||||||
<label for="name">Name</label>
|
|
||||||
<div class="invalid-feedback">{{ .PageError.Message }}</div>
|
|
||||||
</div>
|
|
||||||
<button id="createPostButton" class="btn btn-primary w-100" disabled type="submit">
|
|
||||||
<span class="createPostFormButtonLoading visually-hidden spinner-border spinner-border-sm"
|
|
||||||
role="status"></span>
|
|
||||||
<span class="createPostFormButtonDefault">Create</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const button = document.querySelector("#createPostButton")
|
|
||||||
const inputs = document.querySelectorAll('input')
|
|
||||||
const form = document.querySelector("#createPostForm")
|
|
||||||
|
|
||||||
function formFieldsEmpty() {
|
|
||||||
return Array.from(inputs).some((input) => input.value === "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonDisabled() {
|
|
||||||
button.disabled = formFieldsEmpty() ? "disabled" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonLoading() {
|
|
||||||
button.classList.add("disabled")
|
|
||||||
button.querySelector(".createPostFormButtonDefault").classList.add("visually-hidden")
|
|
||||||
button.querySelector(".createPostFormButtonLoading").classList.remove("visually-hidden")
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCreatePostFormSubmit(event) {
|
|
||||||
setButtonLoading()
|
|
||||||
event.target.submit()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInput(event) {
|
|
||||||
if (event.target.tagName === "INPUT") setButtonDisabled()
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', onCreatePostFormSubmit)
|
|
||||||
window.addEventListener('input', onInput)
|
|
||||||
setButtonDisabled()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Post edition</title>
|
|
||||||
{{.Head}}
|
|
||||||
</head>
|
|
||||||
<body class="text-dark vh-100 d-flex flex-column">
|
|
||||||
{{.Navbar}}
|
|
||||||
<form class="container mt-3 text-black-50 d-flex flex-column h-100" action="edit" method="post">
|
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
|
||||||
<div>
|
|
||||||
<h1>Post - edition</h1>
|
|
||||||
<label for="postBody">Edition</label>
|
|
||||||
</div>
|
|
||||||
{{ if .Alert.Message }}
|
|
||||||
<div class="alert {{ if .Alert.IsError }} alert-danger {{ else }} alert-success {{ end }} alert-dismissible"
|
|
||||||
role="alert">
|
|
||||||
{{ .Alert.Message }}
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
<div class="d-flex justify-content-center align-items-center">
|
|
||||||
<button class="btn btn-success d-flex justify-content-center align-items-center gap-1" style="fill: white;"
|
|
||||||
type="submit">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
|
||||||
<path d="M840-680v480q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h480l160 160Zm-80 34L646-760H200v560h560v-446ZM480-240q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35ZM240-560h360v-160H240v160Zm-40-86v446-560 114Z"/>
|
|
||||||
</svg>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-black-50 d-flex align-items-center justify-content-center h-100 py-3">
|
|
||||||
<textarea id="postBody" name="postBody">{{.Post.Body}}</textarea>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script src="/static/tinymce/js/tinymce/tinymce.min.js"></script>
|
|
||||||
<script>
|
|
||||||
tinymce.init({
|
|
||||||
selector: '#postBody',
|
|
||||||
promotion: false,
|
|
||||||
plugins: 'image lists visualblocks',
|
|
||||||
toolbar: 'undo redo | formatselect | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist | outdent indent | removeformat | image',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
setup: function (editor) {
|
|
||||||
editor.on('init', function () {
|
|
||||||
console.log("LOADED")
|
|
||||||
});
|
|
||||||
},
|
|
||||||
license_key: 'gpl',
|
|
||||||
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.withCredentials = false;
|
|
||||||
xhr.open('POST', "/post/{{.Post.ID}}/image/create");
|
|
||||||
|
|
||||||
xhr.upload.onprogress = (e) => {
|
|
||||||
progress(e.loaded / e.total * 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status === 403) {
|
|
||||||
reject({message: 'HTTP Error: ' + xhr.status, remove: true});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xhr.status < 200 || xhr.status >= 300) {
|
|
||||||
reject('HTTP Error: ' + xhr.status);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = JSON.parse(xhr.responseText);
|
|
||||||
|
|
||||||
if (!json || typeof json.location != 'string') {
|
|
||||||
reject('Invalid JSON: ' + xhr.responseText);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(`http{{ if .Secured }}s{{ end }}://${window.location.host}${json.location}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = () => {
|
|
||||||
reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', blobInfo.blob(), blobInfo.filename());
|
|
||||||
|
|
||||||
xhr.send(formData);
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Posts</title>
|
|
||||||
{{.Head}}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.btn-outline-info:hover > *,
|
|
||||||
.btn-outline-danger:hover > * {
|
|
||||||
fill: #fff !important;
|
|
||||||
transition: fill ease-in-out 50ms;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="text-dark">
|
|
||||||
{{.Navbar}}
|
|
||||||
<div class="container mt-3 text-black-50">
|
|
||||||
<h1>Posts</h1>
|
|
||||||
<p>List of your posts</p>
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Name</th>
|
|
||||||
<th scope="col">Creation Date</th>
|
|
||||||
<th scope="col">Edition Date</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="text-center">
|
|
||||||
<form action="post/create" method="get">
|
|
||||||
<button class="btn btn-sm btn-link w-100 h-100" type="submit">Create a new post...</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{ range $post := .Posts }}
|
|
||||||
<tr>
|
|
||||||
<td>{{ $post.Title }}</td>
|
|
||||||
<td class="date">{{ $post.CreatedAt }}</td>
|
|
||||||
<td class="date">{{ $post.UpdatedAt }}</td>
|
|
||||||
<td>{{ if $post.IsOnline }}🟢 Online{{ else }}🟠 Offline{{ end }}</td>
|
|
||||||
<td>
|
|
||||||
<a href="/post/{{ $post.ID }}/edit" class="btn btn-outline-primary btn-sm">
|
|
||||||
<svg width="12px" height="12px" fill="currentColor">
|
|
||||||
<use xlink:href="/static/bootstrap-icons.svg#pen"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{{ if $post.IsOnline }}
|
|
||||||
<a href="/post/{{ $post.ID }}/unpublish" class="btn btn-outline-info btn-sm">
|
|
||||||
<svg width="12px" height="12px" fill="currentColor">
|
|
||||||
<use xlink:href="/static/bootstrap-icons.svg#eye-slash"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{{ else }}
|
|
||||||
<a href="/post/{{ $post.ID }}/publish" class="btn btn-outline-info btn-sm">
|
|
||||||
<svg width="12px" height="12px" fill="currentColor">
|
|
||||||
<use xlink:href="/static/bootstrap-icons.svg#eye"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{{ end }}
|
|
||||||
<a href="/post/{{ $post.Title }}/delete" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#{{ $post.ID }}">
|
|
||||||
<svg width=" 12px" height="12px" fill="currentColor">
|
|
||||||
<use xlink:href="/static/bootstrap-icons.svg#trash"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<div class="modal fade" id="{{ $post.ID }}" tabindex="-1" aria-labelledby="{{ $post.ID }}Label"
|
|
||||||
aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h1 class="modal-title fs-5" id="{{ $post.ID }}Label">Delete post</h1>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
|
||||||
aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>You are about to delete the post <b>« {{ $post.Title }} »</b>, this is definitive,
|
|
||||||
are you sure?</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel
|
|
||||||
</button>
|
|
||||||
<a href="/post/{{$post.ID}}/delete" class="btn btn-danger">Delete</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{ end }}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const datesElements = document.querySelectorAll("td.date")
|
|
||||||
datesElements.forEach(date => {
|
|
||||||
date.innerText = new Date(date.innerText).toLocaleDateString(undefined, {
|
|
||||||
year: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Setup</title>
|
|
||||||
{{.Head}}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.form-container {
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex flex-column m-auto form-container">
|
|
||||||
<div>
|
|
||||||
<h1>GoCMS</h1>
|
|
||||||
<h2>Admin account creation</h2>
|
|
||||||
</div>
|
|
||||||
<form action="register" method="POST" class="mt-5" id="registerForm">
|
|
||||||
<div class="d-flex flex-column gap-4">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control {{ if .PageError.Email }} is-invalid {{ end }}"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
placeholder="E-mail"
|
|
||||||
required
|
|
||||||
type="email"
|
|
||||||
value="{{.Email}}"
|
|
||||||
onblur="validateEmail()">
|
|
||||||
<label for="email">E-mail</label>
|
|
||||||
<div class="invalid-feedback">The e-mail must be a valid e-mail and not already in use.</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control {{ if .PageError.Username }} is-invalid {{ end }}"
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
placeholder="Username"
|
|
||||||
required
|
|
||||||
type="text"
|
|
||||||
value="{{.Username}}">
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<div class="invalid-feedback">The username must be between 3 and 20 characters long.</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Password"
|
|
||||||
required
|
|
||||||
type="password">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<div class="invalid-feedback">The password must contain at least 8 characters.</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating">
|
|
||||||
<input class="form-control {{ if .PageError.Password }} is-invalid {{ end }}"
|
|
||||||
id="confirmPassword"
|
|
||||||
name="confirmPassword"
|
|
||||||
placeholder="Password confirmation"
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
onblur="validatePasswordMatch()"
|
|
||||||
oninput="validatePasswordMatch()">
|
|
||||||
<label for="confirmPassword">Password confirmation</label>
|
|
||||||
<div class="invalid-feedback">The passwords do not match.</div>
|
|
||||||
</div>
|
|
||||||
<button id="registerFormButton" class="btn btn-primary" disabled type="submit">
|
|
||||||
<span class="registerFormButtonLoading visually-hidden spinner-border spinner-border-sm"
|
|
||||||
role="status"></span>
|
|
||||||
<span class="registerFormButtonDefault">Next</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<form action="login" method="get" class="mt-2 w-100">
|
|
||||||
<button class="btn btn-outline-primary w-100" type="submit">
|
|
||||||
Already registered? Log in!
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const docElems = {
|
|
||||||
form: document.querySelector("#registerForm"),
|
|
||||||
button: document.querySelector("#registerFormButton"),
|
|
||||||
inputs: document.querySelectorAll("input"),
|
|
||||||
email: document.querySelector("#email"),
|
|
||||||
password: document.querySelector("#password"),
|
|
||||||
confirmPassword: document.querySelector("#confirmPassword"),
|
|
||||||
}
|
|
||||||
|
|
||||||
function formSomeFieldsEmpty() {
|
|
||||||
return Array.from(docElems.inputs).some((input) => input.value === "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFormValid() {
|
|
||||||
return !formSomeFieldsEmpty() && validateEmail() && validatePasswordMatch()
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonLoading() {
|
|
||||||
docElems.button.classList.add("disabled")
|
|
||||||
docElems.button.querySelector(".registerFormButtonDefault").classList.add("visually-hidden")
|
|
||||||
docElems.button.querySelector(".registerFormButtonLoading").classList.remove("visually-hidden")
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRegisterFormSubmit(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
setButtonLoading()
|
|
||||||
if (!isFormValid()) return
|
|
||||||
event.target.submit()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInput(event) {
|
|
||||||
if (event.target.tagName === "INPUT") {
|
|
||||||
docElems.button.disabled = !isFormValid()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateEmail() {
|
|
||||||
const email = docElems.email.value.trim()
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
||||||
|
|
||||||
if (!emailRegex.test(email)) {
|
|
||||||
docElems.email.classList.add("is-invalid")
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
docElems.email.classList.remove("is-invalid")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validatePasswordMatch() {
|
|
||||||
if (docElems.password.value !== docElems.confirmPassword.value) {
|
|
||||||
docElems.confirmPassword.classList.add("is-invalid")
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
docElems.confirmPassword.classList.remove("is-invalid")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
docElems.form.addEventListener('submit', onRegisterFormSubmit)
|
|
||||||
window.addEventListener('input', onInput)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Setup</title>
|
|
||||||
{{.Head}}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.form-container {
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex flex-column m-auto form-container">
|
|
||||||
<div>
|
|
||||||
<h1>GoCMS</h1>
|
|
||||||
<h2>Verify your email</h2>
|
|
||||||
</div>
|
|
||||||
<p>An e-mail with the validation link was sent to the address you just registered.</p>
|
|
||||||
<p>If you misspelled the address or just didn't receive any e-mails, please check your spams or cancel the
|
|
||||||
account creation.</p>
|
|
||||||
<form action="" method="post" class="d-flex">
|
|
||||||
<button class="btn btn-outline-primary w-100" type="submit">
|
|
||||||
<span class="visually-hidden spinner-border spinner-border-sm"
|
|
||||||
role="status"></span>
|
|
||||||
<span>Cancel</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const button = document.querySelector("button")
|
|
||||||
|
|
||||||
function setButtonLoading() {
|
|
||||||
button.classList.add("disabled")
|
|
||||||
button.querySelector("button > span + span").classList.add("visually-hidden")
|
|
||||||
button.querySelector("button > span:first-child").classList.remove("visually-hidden")
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('submit', setButtonLoading)
|
|
||||||
button.addEventListener('click', setButtonLoading)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>GoCMS | Setup</title>
|
|
||||||
{{.Head}}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.form-container {
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="d-flex min-vh-100 vw-100 justify-content-center align-items-center text-dark">
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex flex-column gap-5 m-auto form-container">
|
|
||||||
<div>
|
|
||||||
<h1>GoCMS</h1>
|
|
||||||
<h2>E-mail verification</h2>
|
|
||||||
</div>
|
|
||||||
{{ if .PageError.IsError }}
|
|
||||||
<p>{{.PageError.Message}}</p>
|
|
||||||
{{ else }}
|
|
||||||
<p>Your e-mail was successfully validated!</p>
|
|
||||||
<form action="../home" method="get" class="d-flex">
|
|
||||||
<button class="btn btn-primary w-100" type="submit">
|
|
||||||
<span class="visually-hidden spinner-border spinner-border-sm"
|
|
||||||
role="status"></span>
|
|
||||||
<span>Continue</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const button = document.querySelector("button")
|
|
||||||
|
|
||||||
function setButtonLoading() {
|
|
||||||
button.classList.add("disabled")
|
|
||||||
button.querySelector("button > span + span").classList.add("visually-hidden")
|
|
||||||
button.querySelector("button > span:first-child").classList.remove("visually-hidden")
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('submit', setButtonLoading)
|
|
||||||
button.addEventListener('click', setButtonLoading)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<meta charset="UTF-8">
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
|
||||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
|
||||||
<link rel="stylesheet" href="/static/bootstrap.min.css" type="text/css">
|
|
||||||
<script src="/static/bootstrap.min.js"
|
|
||||||
type="application/javascript"
|
|
||||||
defer></script>
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/domain/user"
|
|
||||||
"GoCMS/useCases"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RegisterCredentials struct {
|
|
||||||
Username string `json:"username" validate:"required,min=3,max=20"`
|
|
||||||
Password string `json:"password" validate:"required,min=8"`
|
|
||||||
Email string `json:"email" validate:"required,email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginCredentials struct {
|
|
||||||
Username string `json:"username" validate:"required,min=3,max=20"`
|
|
||||||
Password string `json:"password" validate:"required,min=8"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var shouldCookieBeSecure = os.Getenv("ENVIRONMENT") == "production"
|
|
||||||
var Token *jwtauth.JWTAuth
|
|
||||||
|
|
||||||
// TODO Move into its own file or package that handles api errors
|
|
||||||
const LogsErrorMessage = "Access to the requested resource is forbidden due to incorrect password and/or username."
|
|
||||||
const BodyErrorMessage = "The request cannot be processed due to a mismatch in the format of the body."
|
|
||||||
|
|
||||||
func SetJwtCookie(w *http.ResponseWriter, userId uint32) error {
|
|
||||||
_, tokenString, err := Token.Encode(map[string]interface{}{"user_id": userId})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
http.SetCookie(*w, &http.Cookie{
|
|
||||||
Name: "jwt",
|
|
||||||
Value: tokenString,
|
|
||||||
Expires: time.Now().Add(24 * time.Hour),
|
|
||||||
Secure: shouldCookieBeSecure,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SomeUsersVerified() bool {
|
|
||||||
users := api.Container.ListUsersUseCase.ListUsers()
|
|
||||||
for _, localUser := range users {
|
|
||||||
if localUser.IsVerified {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsUserTableEmpty() bool {
|
|
||||||
users := api.Container.ListUsersUseCase.ListUsers()
|
|
||||||
return len(users) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsLoggedIn(r *http.Request) bool {
|
|
||||||
token, err := jwtauth.VerifyRequest(
|
|
||||||
Token,
|
|
||||||
r,
|
|
||||||
jwtauth.TokenFromCookie,
|
|
||||||
jwtauth.TokenFromHeader,
|
|
||||||
jwtauth.TokenFromQuery)
|
|
||||||
return token != nil && err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsVerified(r *http.Request) bool {
|
|
||||||
token, err := jwtauth.VerifyRequest(
|
|
||||||
Token,
|
|
||||||
r,
|
|
||||||
jwtauth.TokenFromCookie,
|
|
||||||
jwtauth.TokenFromHeader,
|
|
||||||
jwtauth.TokenFromQuery)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
userId := token.PrivateClaims()["user_id"].(float64)
|
|
||||||
currentUser, _ := api.Container.GetUserUseCase.GetUser(uint32(userId))
|
|
||||||
return currentUser.IsVerified
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUserFromCredentials(credentials LoginCredentials) (user.User, error) {
|
|
||||||
dbUser, err := api.Container.GetUserUseCase.GetUserByUsername(credentials.Username)
|
|
||||||
if err != nil {
|
|
||||||
return user.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.Password), []byte(credentials.Password))
|
|
||||||
if err != nil {
|
|
||||||
return user.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return dbUser, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetNewUser(newUserCredentials RegisterCredentials, verificationCode string) (user.User, error) {
|
|
||||||
createdUser, err := api.Container.CreateUserUseCase.CreateUser(useCases.CreateUserCommand{
|
|
||||||
Username: newUserCredentials.Username,
|
|
||||||
Password: newUserCredentials.Password,
|
|
||||||
Email: newUserCredentials.Email,
|
|
||||||
VerificationCode: verificationCode,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return user.User{}, err
|
|
||||||
}
|
|
||||||
return createdUser, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func login(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var credentials LoginCredentials
|
|
||||||
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&credentials)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, BodyErrorMessage, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = api.Validate.Struct(credentials)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dbUser, err := GetUserFromCredentials(credentials)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, LogsErrorMessage, http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = SetJwtCookie(&w, dbUser.ID)
|
|
||||||
|
|
||||||
message, _ := json.Marshal(map[string]interface{}{"message": "User logged in! HTTPonly jwt cookie created"})
|
|
||||||
_, _ = w.Write(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func register(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !IsLoggedIn(r) && !SomeUsersVerified() {
|
|
||||||
http.Error(w, "You are not allowed to create a user. Log in or reset database.", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var credentials RegisterCredentials
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&credentials)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, BodyErrorMessage, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = api.Validate.Struct(credentials)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationCode := uuid.NewString()
|
|
||||||
createdUser, err := GetNewUser(credentials, verificationCode)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = SetJwtCookie(&w, createdUser.ID)
|
|
||||||
|
|
||||||
message, _ := json.Marshal(map[string]interface{}{"message": "User registered! HTTPonly jwt cookie created"})
|
|
||||||
_, _ = w.Write(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RemoveJwtCookie(w http.ResponseWriter) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "jwt",
|
|
||||||
Value: "",
|
|
||||||
Expires: time.Now(),
|
|
||||||
MaxAge: -1,
|
|
||||||
Secure: shouldCookieBeSecure,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func logout(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
RemoveJwtCookie(w)
|
|
||||||
message, _ := json.Marshal(map[string]interface{}{"message": "User logged out! HTTPonly jwt cookie deleted"})
|
|
||||||
_, _ = w.Write(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAuthRouter() http.Handler {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Post("/register", register)
|
|
||||||
r.Post("/login", login)
|
|
||||||
r.Post("/logout", logout)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package image
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PostImage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = r.ParseForm()
|
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
idInt, _ := strconv.Atoi(id)
|
|
||||||
|
|
||||||
file, fileHeader, err := r.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newImage, err := api.Container.CreateImageUseCase.CreateImage(file, *fileHeader)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = api.Container.UpdatePostUseCase.AddImage(uint32(idInt), newImage.ID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newJson := map[string]interface{}{"location": newImage.Path}
|
|
||||||
newJsonBytes, _ := json.Marshal(newJson)
|
|
||||||
|
|
||||||
_, _ = w.Write(newJsonBytes)
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetHomePage(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
|
|
||||||
homeTmpl, _ := api.Container.GetPageUseCase.GetPage("home", map[string]interface{}{
|
|
||||||
"Navbar": template.HTML(navbarTmpl),
|
|
||||||
"Head": headTmpl,
|
|
||||||
})
|
|
||||||
_, _ = w.Write(homeTmpl)
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetPageIntegration(w http.ResponseWriter, r *http.Request) {
|
|
||||||
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
|
|
||||||
templ, _ := api.Container.GetPageUseCase.GetPage("integration", map[string]interface{}{
|
|
||||||
"Navbar": template.HTML(navbarTmpl),
|
|
||||||
"Head": headTmpl,
|
|
||||||
"Host": os.Getenv("HOST"),
|
|
||||||
})
|
|
||||||
_, _ = w.Write(templ)
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/api/controllers/auth"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
)
|
|
||||||
|
|
||||||
const LoginRoute = "/login"
|
|
||||||
|
|
||||||
type LoginPage struct {
|
|
||||||
PageError *PageError `json:"error"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var EmptyLoginPage = &LoginPage{
|
|
||||||
PageError: NewPageError(""),
|
|
||||||
Username: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetLoginPageHandler(loginPage *LoginPage) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if auth.IsLoggedIn(r) {
|
|
||||||
http.Redirect(w, r, "/home", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
PostLoginPage(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if auth.IsUserTableEmpty() {
|
|
||||||
http.Redirect(w, r, "/register", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
success, _ := url.QueryUnescape(r.URL.Query().Get("success"))
|
|
||||||
failure, _ := url.QueryUnescape(r.URL.Query().Get("failure"))
|
|
||||||
bs, _ := api.Container.GetPageUseCase.GetPage("login", map[string]interface{}{
|
|
||||||
"PageError": loginPage.PageError,
|
|
||||||
"Username": loginPage.Username,
|
|
||||||
"Head": headTmpl,
|
|
||||||
"Success": success,
|
|
||||||
"Failure": failure,
|
|
||||||
})
|
|
||||||
_, _ = w.Write(bs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PostLoginPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = r.ParseForm()
|
|
||||||
|
|
||||||
credentials := auth.LoginCredentials{
|
|
||||||
Username: r.FormValue("username"),
|
|
||||||
Password: r.FormValue("password"),
|
|
||||||
}
|
|
||||||
err := api.Validate.Struct(credentials)
|
|
||||||
if err != nil {
|
|
||||||
r.Method = http.MethodGet
|
|
||||||
GetLoginPageHandler(&LoginPage{
|
|
||||||
PageError: NewPageError("Invalid username or password format."),
|
|
||||||
Username: r.FormValue("username"),
|
|
||||||
})(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dbUser, err := auth.GetUserFromCredentials(credentials)
|
|
||||||
if err != nil {
|
|
||||||
r.Method = http.MethodGet
|
|
||||||
GetLoginPageHandler(&LoginPage{
|
|
||||||
PageError: NewPageError("Invalid username or password combination."),
|
|
||||||
Username: r.FormValue("username"),
|
|
||||||
})(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = auth.SetJwtCookie(&w, dbUser.ID)
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/home", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/api/controllers/auth"
|
|
||||||
"GoCMS/api/controllers/image"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var headTmpl template.HTML
|
|
||||||
|
|
||||||
var contentTypes = map[string]string{
|
|
||||||
".css": "text/css",
|
|
||||||
".js": "application/javascript",
|
|
||||||
".png": "image/png",
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".webp": "image/webp",
|
|
||||||
".svg": "image/svg+xml",
|
|
||||||
".ico": "image/x-icon",
|
|
||||||
}
|
|
||||||
|
|
||||||
type PageError struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
IsError bool `json:"isError"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPageError(message string) *PageError {
|
|
||||||
return &PageError{
|
|
||||||
Message: message,
|
|
||||||
IsError: strings.Compare(message, "") != 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsLoggedInMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !auth.IsLoggedIn(r) {
|
|
||||||
http.Redirect(w, r, LoginRoute, http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsVerifiedMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !auth.IsVerified(r) {
|
|
||||||
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsNotVerifiedMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if auth.IsVerified(r) {
|
|
||||||
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Redirect(w, r, LoginRoute, http.StatusPermanentRedirect)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetLogout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
auth.RemoveJwtCookie(w)
|
|
||||||
http.Redirect(w, r, LoginRoute, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
func StaticFileServerWithContentType(fsys http.FileSystem) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := r.URL.Path
|
|
||||||
filePath := filepath.Join("api/static", r.URL.Path)
|
|
||||||
fileInfo, err := os.Stat(filePath)
|
|
||||||
if err == nil && fileInfo.IsDir() {
|
|
||||||
http.Error(w, "Folder direct access is disabled.", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ext := filepath.Ext(path); ext != "" {
|
|
||||||
if ct, ok := contentTypes[ext]; ok {
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
|
||||||
w.Header().Set("Content-Type", ct)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.FileServer(fsys).ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func InitHeadTmpl() {
|
|
||||||
headTmplHtml, _ := api.Container.GetPageUseCase.GetPage("utilsHead", nil)
|
|
||||||
headTmpl = template.HTML(headTmplHtml)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPageRouter() http.Handler {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
|
|
||||||
InitHeadTmpl()
|
|
||||||
|
|
||||||
fileServer := StaticFileServerWithContentType(http.Dir("api/static"))
|
|
||||||
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
|
|
||||||
|
|
||||||
r.Get("/", GetLogin)
|
|
||||||
|
|
||||||
r.Get(LoginRoute, GetLoginPageHandler(EmptyLoginPage))
|
|
||||||
r.Post(LoginRoute, GetLoginPageHandler(EmptyLoginPage))
|
|
||||||
|
|
||||||
r.Get("/register", GetRegisterPageHandler(EmptyRegisterPage))
|
|
||||||
r.Post("/register", GetRegisterPageHandler(EmptyRegisterPage))
|
|
||||||
|
|
||||||
r.Get("/logout", GetLogout)
|
|
||||||
|
|
||||||
r.Get("/register/reset/request", GetPasswordResetRequest)
|
|
||||||
r.Post("/register/reset/request", PostPasswordResetRequest)
|
|
||||||
|
|
||||||
r.Get("/register/reset/validate", GetPasswordResetValidate)
|
|
||||||
r.Post("/register/reset/validate", PostPasswordResetValidate)
|
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(IsLoggedInMiddleware)
|
|
||||||
r.Use(IsNotVerifiedMiddleware)
|
|
||||||
|
|
||||||
r.Get("/register/pending", GetRegisterPendingPage)
|
|
||||||
r.Post("/register/pending", PostRegisterPendingPage)
|
|
||||||
|
|
||||||
r.Get("/register/validate", GetRegisterValidatePage)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(IsLoggedInMiddleware)
|
|
||||||
r.Use(IsVerifiedMiddleware)
|
|
||||||
|
|
||||||
r.Get("/home", GetHomePage)
|
|
||||||
|
|
||||||
r.Get("/post", GetPostsPage)
|
|
||||||
|
|
||||||
r.Get("/post/{id}/edit", GetPostEditPage)
|
|
||||||
r.Post("/post/{id}/edit", PostPostEditPage)
|
|
||||||
|
|
||||||
r.Get("/post/{id}/delete", GetPostDeletePage)
|
|
||||||
|
|
||||||
r.Get("/post/create", GetPostCreatePage)
|
|
||||||
r.Post("/post/create", PostPostCreatePage)
|
|
||||||
|
|
||||||
r.Post("/post/{id}/image/create", image.PostImage)
|
|
||||||
|
|
||||||
r.Get("/post/{id}/publish", GetPostPublishPage)
|
|
||||||
r.Get("/post/{id}/unpublish", GetPostUnpublishPage)
|
|
||||||
|
|
||||||
r.Get("/integration", GetPageIntegration)
|
|
||||||
})
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PasswordResetRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var successMessage = "An email has been sent to the provided email address if it exists."
|
|
||||||
|
|
||||||
func GetPasswordResetRequest(w http.ResponseWriter, r *http.Request) {
|
|
||||||
success := r.URL.Query().Get("success")
|
|
||||||
email := r.URL.Query().Get("email")
|
|
||||||
|
|
||||||
bs, _ := api.Container.GetPageUseCase.GetPage("passwordResetRequest", map[string]interface{}{
|
|
||||||
"Head": headTmpl,
|
|
||||||
"Email": email,
|
|
||||||
"Success": success,
|
|
||||||
})
|
|
||||||
_, _ = w.Write(bs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func PostPasswordResetRequest(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = r.ParseForm()
|
|
||||||
passResetReq := PasswordResetRequest{
|
|
||||||
Email: r.FormValue("email"),
|
|
||||||
}
|
|
||||||
var getRedirectUrl = "/register/reset/request?success=" + successMessage + "&email=" + passResetReq.Email
|
|
||||||
|
|
||||||
err := api.Validate.Struct(passResetReq)
|
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchedUser, err := api.Container.GetUserUseCase.GetUserByEmail(passResetReq.Email)
|
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationCode := uuid.NewString()
|
|
||||||
updatedUser, _ := api.Container.UpdateUserUseCase.UpdatePasswordResetCode(fetchedUser.ID, verificationCode)
|
|
||||||
|
|
||||||
_ = api.Container.SendMailUseCase.SendMail(updatedUser.Email, "passwordReset", map[string]string{
|
|
||||||
"Host": os.Getenv("HOST"),
|
|
||||||
"VerificationCode": verificationCode,
|
|
||||||
"Email": updatedUser.Email,
|
|
||||||
})
|
|
||||||
|
|
||||||
http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
var PasswordLinkErrorMessage = "The reset password link you used is invalid."
|
|
||||||
var PasswordLinkSuccessMessage = "Your account password was successfully updated."
|
|
||||||
|
|
||||||
func GetPasswordResetValidate(w http.ResponseWriter, r *http.Request) {
|
|
||||||
failure := r.URL.Query().Get("failure")
|
|
||||||
pageError := NewPageError("")
|
|
||||||
if failure != "" {
|
|
||||||
pageError = NewPageError(failure)
|
|
||||||
}
|
|
||||||
template, _ := api.Container.GetPageUseCase.GetPage("passwordResetValidate", map[string]interface{}{
|
|
||||||
"Head": headTmpl,
|
|
||||||
"Error": pageError,
|
|
||||||
"Email": r.URL.Query().Get("email"),
|
|
||||||
"Code": r.URL.Query().Get("c"),
|
|
||||||
})
|
|
||||||
_, _ = w.Write(template)
|
|
||||||
}
|
|
||||||
|
|
||||||
func PostPasswordResetValidate(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = r.ParseForm()
|
|
||||||
email := r.FormValue("email")
|
|
||||||
password := r.FormValue("password")
|
|
||||||
resetCode := r.FormValue("code")
|
|
||||||
|
|
||||||
var redirectionErrorLink = "/register/reset/validate?email=" + email + "&c=" + resetCode
|
|
||||||
|
|
||||||
if len(password) < 8 {
|
|
||||||
http.Redirect(w, r, redirectionErrorLink+"&failure=Password length should be at least 8 characters.", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchedUser, err := api.Container.GetUserUseCase.GetUserByEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, redirectionErrorLink+"&failure="+PasswordLinkErrorMessage, http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(fetchedUser.PasswordResetCode), []byte(resetCode))
|
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, redirectionErrorLink+"&failure="+PasswordLinkErrorMessage, http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = api.Container.UpdateUserUseCase.UpdatePassword(fetchedUser.ID, password)
|
|
||||||
_, _ = api.Container.UpdateUserUseCase.UpdatePasswordResetCode(fetchedUser.ID, "")
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/login?success="+PasswordLinkSuccessMessage, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/useCases"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PostCreatePageError struct {
|
|
||||||
IsError bool
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPostCreatePageTemplate(postName string, errorMessage string) ([]byte, error) {
|
|
||||||
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
|
|
||||||
return api.Container.GetPageUseCase.GetPage("postCreate", map[string]interface{}{
|
|
||||||
"Navbar": template.HTML(navbarTmpl),
|
|
||||||
"Head": headTmpl,
|
|
||||||
"PageError": PostCreatePageError{
|
|
||||||
IsError: errorMessage != "",
|
|
||||||
Message: errorMessage,
|
|
||||||
},
|
|
||||||
"Name": postName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func PostPostCreatePage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = r.ParseForm()
|
|
||||||
|
|
||||||
postName := r.FormValue("name")
|
|
||||||
pattern := regexp.MustCompile("^[a-zA-Z0-9À-ÖØ-öø-ÿĀ-ſḀ-ỿ ]{3,50}$")
|
|
||||||
|
|
||||||
if !pattern.MatchString(postName) {
|
|
||||||
postsTmpl, _ := GetPostCreatePageTemplate(postName, "Name should be alphanumeric, and between 3 and 50 characters.")
|
|
||||||
_, _ = w.Write(postsTmpl)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, err := api.Container.CreatePostUseCase.CreatePost(useCases.CreatePostCommand{
|
|
||||||
Title: postName,
|
|
||||||
Body: "",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
postsTmpl, _ := GetPostCreatePageTemplate(postName, "Something went wrong when creating the post, please contact admin.")
|
|
||||||
_, _ = w.Write(postsTmpl)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/post/"+strconv.Itoa(int(post.ID))+"/edit", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPostCreatePage(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
postsTmpl, _ := GetPostCreatePageTemplate("", "")
|
|
||||||
_, _ = w.Write(postsTmpl)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/api/controllers/post"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetPostDeletePage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, post.IdUint32ErrorMessage, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
localPost, err := api.Container.GetPostUseCase.GetPost(uint32(id))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(localPost.Images)
|
|
||||||
for _, image := range localPost.Images {
|
|
||||||
err = api.Container.DeleteImageUseCase.DeleteImage(image.ID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = api.Container.DeletePostUseCase.DeletePost(uint32(id))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, http.StatusText(400), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/post", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/domain/post"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PostEditPageAlert struct {
|
|
||||||
IsError bool
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPostEditPageTemplate(post post.Post, alert PostEditPageAlert) []byte {
|
|
||||||
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
|
|
||||||
postTmpl, _ := api.Container.GetPageUseCase.GetPage("postEdit", map[string]interface{}{
|
|
||||||
"Navbar": template.HTML(navbarTmpl),
|
|
||||||
"Head": headTmpl,
|
|
||||||
"Post": post,
|
|
||||||
"Alert": alert,
|
|
||||||
"Secured": os.Getenv("ENVIRONMENT") == "production",
|
|
||||||
})
|
|
||||||
return postTmpl
|
|
||||||
}
|
|
||||||
|
|
||||||
func PostPostEditPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
postID := chi.URLParam(r, "id")
|
|
||||||
postIDint, err := strconv.Atoi(postID)
|
|
||||||
if err != nil {
|
|
||||||
_, _ = w.Write(getPostEditPageTemplate(post.Post{}, PostEditPageAlert{
|
|
||||||
IsError: true,
|
|
||||||
Message: "Could not find the requested post.",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = r.ParseForm()
|
|
||||||
postBody := r.FormValue("postBody")
|
|
||||||
|
|
||||||
getPost, _ := api.Container.GetPostUseCase.GetPost(uint32(postIDint))
|
|
||||||
updatedPost, err := api.Container.UpdatePostUseCase.UpdateBody(getPost.ID, postBody)
|
|
||||||
if err != nil {
|
|
||||||
_, _ = w.Write(getPostEditPageTemplate(post.Post{Body: postBody}, PostEditPageAlert{
|
|
||||||
IsError: true,
|
|
||||||
Message: "Could not save the post: " + err.Error(),
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = w.Write(getPostEditPageTemplate(updatedPost, PostEditPageAlert{
|
|
||||||
IsError: false,
|
|
||||||
Message: "Post successfully edited!",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPostEditPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
postID := chi.URLParam(r, "id")
|
|
||||||
postIDint, err := strconv.Atoi(postID)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
getPost, _ := api.Container.GetPostUseCase.GetPost(uint32(postIDint))
|
|
||||||
|
|
||||||
_, _ = w.Write(getPostEditPageTemplate(getPost, PostEditPageAlert{
|
|
||||||
IsError: false,
|
|
||||||
Message: "",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func updateIsOnline(postId string, isOnline bool) (error, int) {
|
|
||||||
postIdInt, err := strconv.Atoi(postId)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("the server expects the ID to be in the format of an unsigned 32-bit integer (uint32)"), http.StatusBadRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = api.Container.UpdatePostUseCase.UpdateIsOnline(uint32(postIdInt), isOnline)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("the requested resource, identified by its unique ID, could not be found on the server"), http.StatusNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, http.StatusOK
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPostUnpublishPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
postId := chi.URLParam(r, "id")
|
|
||||||
err, statusCode := updateIsOnline(postId, false)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), statusCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/post", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPostPublishPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
postId := chi.URLParam(r, "id")
|
|
||||||
err, statusCode := updateIsOnline(postId, true)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), statusCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/post", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetPostsPage(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
posts := api.Container.ListPostsUseCase.ListPosts()
|
|
||||||
|
|
||||||
var formattedPosts []map[string]interface{}
|
|
||||||
for _, post := range posts {
|
|
||||||
formattedPosts = append(formattedPosts, map[string]interface{}{
|
|
||||||
"ID": post.ID,
|
|
||||||
"Title": post.Title,
|
|
||||||
"IsOnline": post.IsOnline,
|
|
||||||
"CreatedAt": post.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
||||||
"UpdatedAt": post.UpdatedAt.Format("2006-01-02 15:04:05"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
navbarTmpl, _ := api.Container.GetPageUseCase.GetPage("componentNavbar", nil)
|
|
||||||
postsTmpl, _ := api.Container.GetPageUseCase.GetPage("posts", map[string]interface{}{
|
|
||||||
"Navbar": template.HTML(navbarTmpl),
|
|
||||||
"Head": headTmpl,
|
|
||||||
"Posts": formattedPosts,
|
|
||||||
})
|
|
||||||
|
|
||||||
_, _ = w.Write(postsTmpl)
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/api/controllers/auth"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RegisterPageError struct {
|
|
||||||
Email bool `json:"email"`
|
|
||||||
Password bool `json:"password"`
|
|
||||||
Username bool `json:"username"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RegisterPage struct {
|
|
||||||
PageError *RegisterPageError `json:"error"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var EmptyRegisterPage = &RegisterPage{
|
|
||||||
PageError: &RegisterPageError{
|
|
||||||
Email: false,
|
|
||||||
Password: false,
|
|
||||||
Username: false,
|
|
||||||
},
|
|
||||||
Username: "",
|
|
||||||
Email: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRegisterPageHandler(registerPage *RegisterPage) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !auth.IsLoggedIn(r) && auth.SomeUsersVerified() {
|
|
||||||
http.Redirect(w, r, "/login?failure=There is already a verified account, please login.", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
if (auth.IsLoggedIn(r) && auth.IsVerified(r)) || auth.SomeUsersVerified() {
|
|
||||||
http.Redirect(w, r, "/home", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
PostRegisterPage(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bs, err := api.Container.GetPageUseCase.GetPage("register", map[string]interface{}{
|
|
||||||
"PageError": registerPage.PageError,
|
|
||||||
"Username": registerPage.Username,
|
|
||||||
"Email": registerPage.Email,
|
|
||||||
"Head": headTmpl,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
_, _ = w.Write([]byte(err.Error()))
|
|
||||||
}
|
|
||||||
_, _ = w.Write(bs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PostRegisterPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = r.ParseForm()
|
|
||||||
|
|
||||||
credentials := auth.RegisterCredentials{
|
|
||||||
Username: r.FormValue("username"),
|
|
||||||
Password: r.FormValue("password"),
|
|
||||||
Email: r.FormValue("email"),
|
|
||||||
}
|
|
||||||
err := api.Validate.Struct(credentials)
|
|
||||||
if err != nil {
|
|
||||||
r.Method = http.MethodGet
|
|
||||||
GetRegisterPageHandler(&RegisterPage{
|
|
||||||
PageError: &RegisterPageError{
|
|
||||||
Email: true,
|
|
||||||
Password: true,
|
|
||||||
Username: true,
|
|
||||||
},
|
|
||||||
Username: r.FormValue("username"),
|
|
||||||
Email: r.FormValue("email"),
|
|
||||||
})(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationCode := uuid.NewString()
|
|
||||||
createdUser, err := auth.GetNewUser(credentials, verificationCode)
|
|
||||||
if err != nil {
|
|
||||||
r.Method = http.MethodGet
|
|
||||||
GetRegisterPageHandler(&RegisterPage{
|
|
||||||
PageError: &RegisterPageError{
|
|
||||||
Email: true,
|
|
||||||
Password: false,
|
|
||||||
Username: false,
|
|
||||||
},
|
|
||||||
Username: r.FormValue("username"),
|
|
||||||
Email: r.FormValue("email"),
|
|
||||||
})(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = api.Container.SendMailUseCase.SendMail(createdUser.Email, "mailValidation", map[string]string{
|
|
||||||
"Host": os.Getenv("HOST"),
|
|
||||||
"VerificationCode": verificationCode,
|
|
||||||
})
|
|
||||||
|
|
||||||
_ = auth.SetJwtCookie(&w, createdUser.ID)
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/api/controllers/auth"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PostRegisterPendingPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token, _ := jwtauth.VerifyRequest(
|
|
||||||
auth.Token, r,
|
|
||||||
jwtauth.TokenFromCookie,
|
|
||||||
jwtauth.TokenFromHeader,
|
|
||||||
jwtauth.TokenFromQuery)
|
|
||||||
userId := token.PrivateClaims()["user_id"].(float64)
|
|
||||||
err := api.Container.DeleteUserUseCase.DeleteUser(uint32(userId))
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
r.Method = http.MethodGet
|
|
||||||
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth.RemoveJwtCookie(w)
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/register", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRegisterPendingPage(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
registerPendingTmpl, _ := api.Container.GetPageUseCase.GetPage("registerPending", map[string]interface{}{
|
|
||||||
"Head": headTmpl,
|
|
||||||
})
|
|
||||||
_, _ = w.Write(registerPendingTmpl)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/api/controllers/auth"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetRegisterValidatePage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
queryVerificationCode := r.URL.Query().Get("c")
|
|
||||||
if queryVerificationCode == "" {
|
|
||||||
http.Redirect(w, r, "/register/pending", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, _ := jwtauth.VerifyRequest(
|
|
||||||
auth.Token,
|
|
||||||
r,
|
|
||||||
jwtauth.TokenFromCookie,
|
|
||||||
jwtauth.TokenFromHeader,
|
|
||||||
jwtauth.TokenFromQuery)
|
|
||||||
|
|
||||||
userId := token.PrivateClaims()["user_id"].(float64)
|
|
||||||
user, _ := api.Container.GetUserUseCase.GetUser(uint32(userId))
|
|
||||||
errorMessage := ""
|
|
||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(user.VerificationCode), []byte(queryVerificationCode))
|
|
||||||
if err != nil || user.VerificationExpiration.Before(time.Now()) {
|
|
||||||
errorMessage = "Verification link is incorrect or has expired."
|
|
||||||
} else {
|
|
||||||
_, err := api.Container.UpdateUserUseCase.UpdateVerificationStatus(user.ID, true)
|
|
||||||
if err != nil {
|
|
||||||
errorMessage = "Something went wrong server-side. User account may not exist."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerValidateTmpl, _ := api.Container.GetPageUseCase.GetPage("registerValidate", map[string]interface{}{
|
|
||||||
"Head": headTmpl,
|
|
||||||
"PageError": NewPageError(errorMessage),
|
|
||||||
})
|
|
||||||
_, _ = w.Write(registerValidateTmpl)
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package post
|
|
||||||
|
|
||||||
import (
|
|
||||||
"GoCMS/api"
|
|
||||||
"GoCMS/api/controllers/auth"
|
|
||||||
"GoCMS/domain/post"
|
|
||||||
"GoCMS/useCases"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PostPost struct {
|
|
||||||
Title string `json:"title" validate:"required,min=3,max=50"`
|
|
||||||
Body string `json:"body" validate:"required,max=10000"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
|
|
||||||
|
|
||||||
func getPost(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, IdUint32ErrorMessage, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
localPost, err := api.Container.GetPostUseCase.GetPost(uint32(id))
|
|
||||||
if err != nil || !localPost.IsOnline {
|
|
||||||
http.Error(w, "The requested resource, identified by its unique ID, could not be found on the server.", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
postJson, _ := json.Marshal(localPost)
|
|
||||||
_, _ = w.Write(postJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
func postPost(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var localPost PostPost
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&localPost)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, auth.BodyErrorMessage, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = api.Validate.Struct(localPost)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
createdPost, err := api.Container.CreatePostUseCase.CreatePost(useCases.CreatePostCommand{
|
|
||||||
Title: localPost.Title,
|
|
||||||
Body: localPost.Body,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
postJson, _ := json.Marshal(createdPost)
|
|
||||||
|
|
||||||
_, _ = w.Write(postJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listPosts(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
posts := api.Container.ListPostsUseCase.ListPosts()
|
|
||||||
onlinePosts := make([]post.Post, 0)
|
|
||||||
for _, localPost := range posts {
|
|
||||||
if localPost.IsOnline {
|
|
||||||
onlinePosts = append(onlinePosts, localPost)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
postsJson, _ := json.Marshal(onlinePosts)
|
|
||||||
_, _ = w.Write(postsJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deletePost(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, IdUint32ErrorMessage, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = api.Container.DeletePostUseCase.DeletePost(uint32(id))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = w.Write([]byte("post deleted"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPostRouter() http.Handler {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/{id}", getPost)
|
|
||||||
r.Post("/", postPost)
|
|
||||||
r.Get("/", listPosts)
|
|
||||||
r.Delete("/{id}", deletePost)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
+443
-49
@@ -1,73 +1,467 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"GoCMS/adapters/secondary/gateways/models"
|
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||||
"GoCMS/useCases"
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
"github.com/glebarez/sqlite"
|
domainAudit "RenewCMS/internal/domain/audit"
|
||||||
"go.uber.org/dig"
|
domainImage "RenewCMS/internal/domain/image"
|
||||||
"gorm.io/gorm"
|
domainInvite "RenewCMS/internal/domain/invite"
|
||||||
|
domainMail "RenewCMS/internal/domain/mail"
|
||||||
|
domainPurge "RenewCMS/internal/domain/purge"
|
||||||
|
domainUpdate "RenewCMS/internal/domain/update"
|
||||||
|
domainUser "RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/auditing"
|
||||||
|
"RenewCMS/internal/infrastructure/config"
|
||||||
|
"RenewCMS/internal/infrastructure/hashing"
|
||||||
|
"RenewCMS/internal/infrastructure/imaging"
|
||||||
|
"RenewCMS/internal/infrastructure/mailer"
|
||||||
|
"RenewCMS/internal/infrastructure/markdown"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/database"
|
||||||
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
|
"RenewCMS/internal/infrastructure/scheduler"
|
||||||
|
"RenewCMS/internal/infrastructure/storage"
|
||||||
|
"RenewCMS/internal/infrastructure/update"
|
||||||
|
"RenewCMS/internal/infrastructure/usage"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UseCases struct {
|
type UseCases struct {
|
||||||
CreatePostUseCase *useCases.CreatePostUseCase
|
apiKeyAuthOnce sync.Once
|
||||||
GetPostUseCase *useCases.GetPostUseCase
|
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||||
ListPostsUseCase *useCases.ListPostsUseCase
|
Token *jwtauth.JWTAuth
|
||||||
UpdatePostUseCase *useCases.UpdatePostUseCase
|
PurgeScheduler *scheduler.Runner
|
||||||
DeletePostUseCase *useCases.DeletePostUseCase
|
ActivityCleanupScheduler *scheduler.Runner
|
||||||
|
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||||
|
GetArticleUseCase *useCases.GetArticleUseCase
|
||||||
|
ListArticlesUseCase *useCases.ListArticlesUseCase
|
||||||
|
UpdateArticleUseCase *useCases.UpdateArticleUseCase
|
||||||
|
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
||||||
GetUserUseCase *useCases.GetUserUseCase
|
GetUserUseCase *useCases.GetUserUseCase
|
||||||
CreateUserUseCase *useCases.CreateUserUseCase
|
CreateUserUseCase *useCases.CreateUserUseCase
|
||||||
UpdateUserUseCase *useCases.UpdateUserUseCase
|
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
|
||||||
DeleteUserUseCase *useCases.DeleteUserUseCase
|
VerifyUserUseCase *useCases.VerifyUserUseCase
|
||||||
|
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
|
||||||
|
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
|
||||||
|
ResetPasswordUseCase *useCases.ResetPasswordUseCase
|
||||||
|
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
|
||||||
ListUsersUseCase *useCases.ListUsersUseCase
|
ListUsersUseCase *useCases.ListUsersUseCase
|
||||||
GetPageUseCase *useCases.GetPageUseCase
|
CreateInviteUseCase *useCases.CreateInviteUseCase
|
||||||
|
CancelInviteUseCase *useCases.CancelInviteUseCase
|
||||||
|
RenewInviteUseCase *useCases.RenewInviteUseCase
|
||||||
|
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||||
|
GetInviteUseCase *useCases.GetInviteUseCase
|
||||||
|
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||||
SendMailUseCase *useCases.SendMailUseCase
|
SendMailUseCase *useCases.SendMailUseCase
|
||||||
CreateImageUseCase *useCases.CreateImageUseCase
|
CreateImageUseCase *useCases.CreateImageUseCase
|
||||||
DeleteImageUseCase *useCases.DeleteImageUseCase
|
OpenMediaUseCase *useCases.OpenMediaUseCase
|
||||||
|
ListMediaUseCase *useCases.ListMediaUseCase
|
||||||
|
RenameMediaUseCase *useCases.RenameMediaUseCase
|
||||||
|
DeleteMediaUseCase *useCases.DeleteMediaUseCase
|
||||||
|
MergeMediaUseCase *useCases.MergeMediaUseCase
|
||||||
|
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
|
||||||
|
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
|
||||||
|
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
|
||||||
|
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
|
||||||
|
RunPurgeUseCase *useCases.RunPurgeUseCase
|
||||||
|
GetSettingsUseCase *useCases.GetSettingsUseCase
|
||||||
|
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
|
||||||
|
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||||
|
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||||
|
RequestRestartUseCase *useCases.RequestRestartUseCase
|
||||||
|
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
|
||||||
|
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
|
||||||
|
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
|
||||||
|
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
|
||||||
|
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
|
||||||
|
ListActivityUseCase *useCases.ListActivityUseCase
|
||||||
|
GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase
|
||||||
|
ExportActivityUseCase *useCases.ExportActivityUseCase
|
||||||
|
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
|
||||||
|
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
|
||||||
|
ListVariantsUseCase *useCases.ListVariantsUseCase
|
||||||
|
CreateVariantUseCase *useCases.CreateVariantUseCase
|
||||||
|
UpdateVariantUseCase *useCases.UpdateVariantUseCase
|
||||||
|
SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase
|
||||||
|
DeleteVariantUseCase *useCases.DeleteVariantUseCase
|
||||||
|
PurgeVariantUseCase *useCases.PurgeVariantUseCase
|
||||||
|
ServeVariantUseCase *useCases.ServeVariantUseCase
|
||||||
|
ListImageVariantsUseCase *useCases.ListImageVariantsUseCase
|
||||||
|
GenerateImageVariantUseCase *useCases.GenerateImageVariantUseCase
|
||||||
|
GenerateAllImageVariantsUseCase *useCases.GenerateAllImageVariantsUseCase
|
||||||
|
PurgeImageVariantUseCase *useCases.PurgeImageVariantUseCase
|
||||||
|
GenerateVariantsOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
|
||||||
|
GetVariantCacheStatsUseCase *useCases.GetVariantCacheStatsUseCase
|
||||||
|
PurgeVariantCacheUseCase *useCases.PurgeVariantCacheUseCase
|
||||||
|
VariantUsageTracker *usage.Tracker
|
||||||
}
|
}
|
||||||
|
|
||||||
var Container *UseCases
|
func (c *UseCases) RunSchedulers(ctx context.Context) {
|
||||||
|
var running sync.WaitGroup
|
||||||
func getDb() *gorm.DB {
|
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler} {
|
||||||
dbName := os.Getenv("DB_FILE")
|
if runner == nil {
|
||||||
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
|
continue
|
||||||
panic("Unable to create necessary subdirectories: " + err.Error())
|
|
||||||
}
|
}
|
||||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
running.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer running.Done()
|
||||||
|
runner.Run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
running.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repositories struct {
|
||||||
|
ArticleRepo domainArticle.Repository
|
||||||
|
UserRepo domainUser.Repository
|
||||||
|
ImageRepo domainImage.Repository
|
||||||
|
VariantRepo domainImage.VariantRepository
|
||||||
|
VariantFileRepo domainImage.VariantFileRepository
|
||||||
|
VariantCacheRepo domainImage.VariantCacheRepository
|
||||||
|
VariantUsageRepo domainImage.VariantUsageRepository
|
||||||
|
VariantRenderer domainImage.VariantRenderer
|
||||||
|
InviteRepo domainInvite.Repository
|
||||||
|
APIKeyRepo domainAPIKey.Repository
|
||||||
|
PurgeSettingsRepo domainPurge.Repository
|
||||||
|
AuditRepo domainAudit.Repository
|
||||||
|
AuditSettingsRepo domainAudit.SettingsRepository
|
||||||
|
MailRepo domainMail.Repository
|
||||||
|
ReleaseFeed domainUpdate.ReleaseFeed
|
||||||
|
UpdateStore domainUpdate.StatusStore
|
||||||
|
UpdateJobs domainUpdate.JobStore
|
||||||
|
UpdateHelper domainUpdate.HelperDetector
|
||||||
|
AssetFetcher domainUpdate.AssetFetcher
|
||||||
|
SignatureVerifier domainUpdate.SignatureVerifier
|
||||||
|
StagedRunner domainUpdate.StagedBinaryRunner
|
||||||
|
DatabaseBackup domainUpdate.DatabaseBackup
|
||||||
|
UpdateResults domainUpdate.ResultStore
|
||||||
|
RestartRequests domainUpdate.RestartRequestStore
|
||||||
|
RunningInfo domainUpdate.RunningInfoStore
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
updateStatusFileName = "update-status.json"
|
||||||
|
updateJobFileName = "update-job.json"
|
||||||
|
updateHelperFileName = "update-helper.json"
|
||||||
|
updatesDirName = "updates"
|
||||||
|
backupsDirName = "backups"
|
||||||
|
)
|
||||||
|
|
||||||
|
func imageStorageConfig(settings config.Settings) storage.Config {
|
||||||
|
if settings.Storage.Driver == config.StorageDriverS3 {
|
||||||
|
s3 := settings.Storage.S3
|
||||||
|
return storage.Config{
|
||||||
|
Driver: storage.DriverS3,
|
||||||
|
S3: storage.S3Config{
|
||||||
|
Bucket: s3.Bucket,
|
||||||
|
Region: s3.Region,
|
||||||
|
Endpoint: s3.Endpoint,
|
||||||
|
AccessKeyID: s3.AccessKeyID,
|
||||||
|
SecretAccessKey: s3.SecretAccessKey,
|
||||||
|
KeyPrefix: s3.KeyPrefix,
|
||||||
|
PathStyle: s3.PathStyle,
|
||||||
|
TLS: s3.TLS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storage.Config{Driver: storage.DriverLocal, Location: settings.UploadDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openImageStorage(settings config.Settings) domainImage.Storage {
|
||||||
|
imageStorage, err := storage.Open(imageStorageConfig(settings))
|
||||||
|
if err != nil {
|
||||||
|
panic("Unable to open the image storage: " + err.Error())
|
||||||
|
}
|
||||||
|
return imageStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
func databaseConfig(settings config.Settings) database.Config {
|
||||||
|
switch settings.Database.Driver {
|
||||||
|
case config.DatabaseDriverMySQL:
|
||||||
|
return mysqlDatabaseConfig(settings.Database)
|
||||||
|
case config.DatabaseDriverPostgres:
|
||||||
|
return postgresDatabaseConfig(settings.Database)
|
||||||
|
case "", config.DatabaseDriverSQLite:
|
||||||
|
return database.Config{Driver: database.DriverSQLite, Source: settings.DBFile}
|
||||||
|
default:
|
||||||
|
return database.Config{Driver: settings.Database.Driver}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlDatabaseConfig(settings config.DatabaseSettings) database.Config {
|
||||||
|
return database.Config{
|
||||||
|
Driver: database.DriverMySQL,
|
||||||
|
Source: database.MySQLDSN(database.MySQLConfig{
|
||||||
|
Host: settings.Host,
|
||||||
|
Port: settings.Port,
|
||||||
|
User: settings.User,
|
||||||
|
Password: settings.Password,
|
||||||
|
Name: settings.Name,
|
||||||
|
TLS: settings.TLS,
|
||||||
|
}),
|
||||||
|
Pool: &database.PoolSettings{
|
||||||
|
MaxOpenConns: settings.MaxOpenConns,
|
||||||
|
MaxIdleConns: settings.MaxIdleConns,
|
||||||
|
ConnMaxLifetime: settings.ConnMaxLifetime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postgresDatabaseConfig(settings config.DatabaseSettings) database.Config {
|
||||||
|
return database.Config{
|
||||||
|
Driver: database.DriverPostgres,
|
||||||
|
Source: database.PostgresDSN(database.PostgresConfig{
|
||||||
|
Host: settings.Host,
|
||||||
|
Port: settings.Port,
|
||||||
|
User: settings.User,
|
||||||
|
Password: settings.Password,
|
||||||
|
Name: settings.Name,
|
||||||
|
SSLMode: settings.SSLMode,
|
||||||
|
}),
|
||||||
|
Pool: &database.PoolSettings{
|
||||||
|
MaxOpenConns: settings.MaxOpenConns,
|
||||||
|
MaxIdleConns: settings.MaxIdleConns,
|
||||||
|
ConnMaxLifetime: settings.ConnMaxLifetime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDatabase(settings config.Settings) *database.Connection {
|
||||||
|
connection, err := database.Connect(databaseConfig(settings))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("Unable to open the database: " + err.Error())
|
panic("Unable to open the database: " + err.Error())
|
||||||
}
|
}
|
||||||
return db
|
if err := database.Migrate(connection.DB); err != nil {
|
||||||
|
panic("Failed to migrate database: " + err.Error())
|
||||||
|
}
|
||||||
|
return connection
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitContainer() {
|
func releasePublicKey() ed25519.PublicKey {
|
||||||
digContainer := dig.New()
|
key, err := update.EmbeddedPublicKey()
|
||||||
|
|
||||||
database := getDb()
|
|
||||||
_ = database.AutoMigrate(&models.Post{}, &models.User{})
|
|
||||||
_ = digContainer.Provide(func() *gorm.DB { return database })
|
|
||||||
|
|
||||||
_ = digContainer.Provide(func(db *gorm.DB) *UseCases {
|
|
||||||
return &UseCases{
|
|
||||||
CreatePostUseCase: useCases.NewCreatePostUseCase(db),
|
|
||||||
GetPostUseCase: useCases.NewGetPostUseCase(db),
|
|
||||||
ListPostsUseCase: useCases.NewListPostsUseCase(db),
|
|
||||||
UpdatePostUseCase: useCases.NewUpdatePostUseCase(db),
|
|
||||||
DeletePostUseCase: useCases.NewDeletePostUseCase(db),
|
|
||||||
GetUserUseCase: useCases.NewGetUserUseCase(db),
|
|
||||||
CreateUserUseCase: useCases.NewCreateUserUseCase(db),
|
|
||||||
UpdateUserUseCase: useCases.NewUpdateUserUseCase(db),
|
|
||||||
DeleteUserUseCase: useCases.NewDeleteUserUseCase(db),
|
|
||||||
ListUsersUseCase: useCases.NewListUsersUseCase(db),
|
|
||||||
GetPageUseCase: useCases.NewGetPageUseCase(),
|
|
||||||
SendMailUseCase: useCases.NewSendMailUseCase(),
|
|
||||||
CreateImageUseCase: useCases.NewCreateImageUseCase(db),
|
|
||||||
DeleteImageUseCase: useCases.NewDeleteImageUseCase(db),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
err := digContainer.Invoke(func(useCases *UseCases) { Container = useCases })
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("Unable to invoke container: " + err.Error())
|
panic("Unable to read the embedded release key: " + err.Error())
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
|
||||||
|
db := connection.DB
|
||||||
|
dataDir := settings.DataDir()
|
||||||
|
variantFiles := persistence.NewVariantFileRepository(db)
|
||||||
|
return &Repositories{
|
||||||
|
ArticleRepo: persistence.NewArticleRepository(db),
|
||||||
|
UserRepo: persistence.NewUserRepository(db),
|
||||||
|
ImageRepo: persistence.NewImageRepository(db),
|
||||||
|
VariantRepo: persistence.NewVariantRepository(db),
|
||||||
|
VariantFileRepo: variantFiles,
|
||||||
|
VariantCacheRepo: variantFiles,
|
||||||
|
VariantUsageRepo: persistence.NewVariantUsageRepository(db),
|
||||||
|
VariantRenderer: imaging.NewVariantRenderer(),
|
||||||
|
InviteRepo: persistence.NewInviteRepository(db),
|
||||||
|
APIKeyRepo: persistence.NewAPIKeyRepository(db),
|
||||||
|
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
|
||||||
|
AuditRepo: persistence.NewAuditEventRepository(db),
|
||||||
|
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
|
||||||
|
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
||||||
|
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
||||||
|
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
|
||||||
|
UpdateJobs: update.NewJSONJobStore(filepath.Join(dataDir, updateJobFileName)),
|
||||||
|
UpdateHelper: update.NewContainerHelperDetector(update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)), settings.Container),
|
||||||
|
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
|
||||||
|
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
|
||||||
|
StagedRunner: update.NewExecStagedBinaryRunner(),
|
||||||
|
DatabaseBackup: connection.NewBackup(filepath.Join(dataDir, backupsDirName), time.Now),
|
||||||
|
UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)),
|
||||||
|
RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)),
|
||||||
|
RunningInfo: update.NewJSONRunningInfoStore(filepath.Join(dataDir, domainUpdate.RunningInfoFileName)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
|
||||||
|
hasher := hashing.NewBcryptHasher()
|
||||||
|
renderer := markdown.NewConverter()
|
||||||
|
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||||
|
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
|
||||||
|
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder)
|
||||||
|
|
||||||
|
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder)
|
||||||
|
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
|
||||||
|
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
|
||||||
|
|
||||||
|
usageTracker := usage.NewTracker(repos.VariantUsageRepo, time.Now)
|
||||||
|
serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}).WithUsageTracker(usageTracker)
|
||||||
|
|
||||||
|
return &UseCases{
|
||||||
|
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||||
|
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
||||||
|
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
||||||
|
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
|
||||||
|
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||||
|
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
|
||||||
|
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
|
||||||
|
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
|
||||||
|
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
|
||||||
|
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||||
|
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
|
||||||
|
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
|
||||||
|
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
||||||
|
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||||
|
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
|
||||||
|
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
|
||||||
|
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||||
|
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||||
|
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
|
||||||
|
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||||
|
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
|
||||||
|
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
||||||
|
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
|
||||||
|
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
|
||||||
|
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
|
||||||
|
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
|
||||||
|
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo),
|
||||||
|
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
|
||||||
|
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder),
|
||||||
|
MergeMediaUseCase: mergeMedia,
|
||||||
|
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||||
|
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||||
|
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
|
||||||
|
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||||
|
RunPurgeUseCase: runPurge,
|
||||||
|
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
||||||
|
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
|
||||||
|
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder),
|
||||||
|
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder),
|
||||||
|
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now, recorder),
|
||||||
|
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
|
||||||
|
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
|
||||||
|
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
|
||||||
|
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
|
||||||
|
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
|
||||||
|
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
|
||||||
|
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
|
||||||
|
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
|
||||||
|
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
|
||||||
|
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now),
|
||||||
|
ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo),
|
||||||
|
CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder),
|
||||||
|
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, repos.VariantRenderer, time.Now, recorder),
|
||||||
|
SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(repos.VariantRepo, repos.VariantFileRepo, time.Now, recorder),
|
||||||
|
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
|
||||||
|
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
|
||||||
|
ServeVariantUseCase: serveVariant,
|
||||||
|
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer),
|
||||||
|
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant),
|
||||||
|
GenerateAllImageVariantsUseCase: useCases.NewGenerateAllImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant, recorder),
|
||||||
|
PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
|
||||||
|
GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(repos.VariantRepo, serveVariant, log.Default()),
|
||||||
|
GetVariantCacheStatsUseCase: useCases.NewGetVariantCacheStatsUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.VariantCacheRepo, repos.ImageRepo, repos.VariantUsageRepo, usageTracker, time.Now),
|
||||||
|
PurgeVariantCacheUseCase: useCases.NewPurgeVariantCacheUseCase(repos.VariantRepo, repos.ImageRepo, repos.VariantCacheRepo, repos.VariantFileRepo, imageStorage, time.Now, recorder),
|
||||||
|
VariantUsageTracker: usageTracker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings, recorder domainAudit.Recorder) *useCases.StartUpdateDownloadUseCase {
|
||||||
|
return useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{
|
||||||
|
Store: repos.UpdateStore,
|
||||||
|
Jobs: repos.UpdateJobs,
|
||||||
|
Helper: repos.UpdateHelper,
|
||||||
|
Results: repos.UpdateResults,
|
||||||
|
Requests: repos.RestartRequests,
|
||||||
|
Fetcher: repos.AssetFetcher,
|
||||||
|
Verifier: repos.SignatureVerifier,
|
||||||
|
Runner: repos.StagedRunner,
|
||||||
|
Backup: repos.DatabaseBackup,
|
||||||
|
Staging: update.NewOSStagingArea(filepath.Join(settings.DataDir(), updatesDirName)),
|
||||||
|
Logger: log.Default(),
|
||||||
|
CurrentVersion: settings.Version,
|
||||||
|
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||||
|
FeedURL: settings.UpdateFeedURL,
|
||||||
|
Now: time.Now,
|
||||||
|
Recorder: recorder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func backfillImageMetadata(imageRepo domainImage.Repository, imageStorage domainImage.Storage, settings config.Settings) error {
|
||||||
|
run := func(readable domainImage.Storage) error {
|
||||||
|
return useCases.NewBackfillImageMetadataUseCase(imageRepo, readable).Run()
|
||||||
|
}
|
||||||
|
if settings.Storage.Driver == config.StorageDriverS3 {
|
||||||
|
return storage.RunBackfillReportingReads(imageStorage, log.Default(), run)
|
||||||
|
}
|
||||||
|
return run(imageStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitContainer(settings config.Settings) (*UseCases, func() error) {
|
||||||
|
imageStorage := openImageStorage(settings)
|
||||||
|
connection := openDatabase(settings)
|
||||||
|
repos := getRepositories(connection, settings)
|
||||||
|
if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
|
||||||
|
panic("Failed to backfill image metadata: " + err.Error())
|
||||||
|
}
|
||||||
|
container := getUseCases(repos, imageStorage, settings)
|
||||||
|
return container, container.runUsageTracker(connection.Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *UseCases) runUsageTracker(closeDatabase func() error) func() error {
|
||||||
|
ctx, stop := context.WithCancel(context.Background())
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(stopped)
|
||||||
|
c.VariantUsageTracker.Run(ctx)
|
||||||
|
}()
|
||||||
|
return func() error {
|
||||||
|
stop()
|
||||||
|
<-stopped
|
||||||
|
return closeDatabase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserUpdateCommands struct {
|
||||||
|
Enable *useCases.EnableBrowserUpdatesUseCase
|
||||||
|
Disable *useCases.DisableBrowserUpdatesUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitBrowserUpdateCommands() *BrowserUpdateCommands {
|
||||||
|
units := update.NewSystemdUnits()
|
||||||
|
dependencies := useCases.ManageBrowserUpdatesDependencies{
|
||||||
|
Probe: update.NewSystemMachineProbe(),
|
||||||
|
Locator: units,
|
||||||
|
Units: units,
|
||||||
|
Manager: update.NewSystemctlControl(domainUpdate.ServiceUnitName),
|
||||||
|
Status: update.NewJSONHelperStatusWriter(),
|
||||||
|
Now: time.Now,
|
||||||
|
}
|
||||||
|
return &BrowserUpdateCommands{
|
||||||
|
Enable: useCases.NewEnableBrowserUpdatesUseCase(dependencies),
|
||||||
|
Disable: useCases.NewDisableBrowserUpdatesUseCase(dependencies),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitApplyUpdateCommand(options domainUpdate.InstallOptions) *useCases.ApplyRestartRequestUseCase {
|
||||||
|
layout := domainUpdate.HelperLayout{DataDir: options.DataDir, BinaryPath: options.Binary}
|
||||||
|
files := update.NewNoFollowFilesystem()
|
||||||
|
return useCases.NewApplyRestartRequestUseCase(useCases.ApplyRestartRequestDependencies{
|
||||||
|
Requests: update.NewFSRestartRequestStore(files, layout.RestartRequestPath()),
|
||||||
|
Results: update.NewFSResultStore(files, layout.ResultPath()),
|
||||||
|
Running: update.NewJSONRunningInfoStore(filepath.Join(options.DataDir, domainUpdate.RunningInfoFileName)),
|
||||||
|
Files: files,
|
||||||
|
Service: update.NewSystemctlControl(options.Service),
|
||||||
|
Health: update.NewHTTPHealthChecker(options.Port),
|
||||||
|
Verifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
|
||||||
|
Clock: update.NewSystemClock(),
|
||||||
|
Logger: log.Default(),
|
||||||
|
Layout: layout,
|
||||||
|
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package activity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/claims"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
"RenewCMS/internal/domain/audit"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
csvContentType = "text/csv; charset=utf-8"
|
||||||
|
csvContentDisposition = `attachment; filename="activity.csv"`
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
ListUseCase *useCases.ListActivityUseCase
|
||||||
|
SummaryUseCase *useCases.GetActivitySummaryUseCase
|
||||||
|
ExportUseCase *useCases.ExportActivityUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
type filterQuery struct {
|
||||||
|
Q string `validate:"max=200"`
|
||||||
|
Actor string `validate:"max=40"`
|
||||||
|
Category string `validate:"omitempty,oneof=articles media users api_keys auth system"`
|
||||||
|
Outcome string `validate:"omitempty,oneof=all success failed"`
|
||||||
|
Security string `validate:"omitempty,oneof=true"`
|
||||||
|
Range string `validate:"omitempty,oneof=today 7d 30d 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,327 @@
|
|||||||
|
package article
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/claims"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
domain "RenewCMS/internal/domain/article"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
CreateUseCase *useCases.CreateArticleUseCase
|
||||||
|
GetUseCase *useCases.GetArticleUseCase
|
||||||
|
ListUseCase *useCases.ListArticlesUseCase
|
||||||
|
DeleteUseCase *useCases.DeleteArticleUseCase
|
||||||
|
UpdateUseCase *useCases.UpdateArticleUseCase
|
||||||
|
GetUserUseCase *useCases.GetUserUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostArticle struct {
|
||||||
|
Title string `json:"title" validate:"required,min=3,max=200"`
|
||||||
|
Body string `json:"body" validate:"required,max=10000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, getUser *useCases.GetUserUseCase) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
CreateUseCase: create,
|
||||||
|
GetUseCase: get,
|
||||||
|
ListUseCase: list,
|
||||||
|
DeleteUseCase: delete,
|
||||||
|
UpdateUseCase: update,
|
||||||
|
GetUserUseCase: getUser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type articleResponse struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body json.RawMessage `json:"body"`
|
||||||
|
BodyFormat domain.BodyFormat `json:"body_format"`
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) {
|
||||||
|
body := json.RawMessage(article.Body)
|
||||||
|
if format != domain.BodyFormatJSON {
|
||||||
|
encoded, err := json.Marshal(article.Body)
|
||||||
|
if err != nil {
|
||||||
|
return articleResponse{}, err
|
||||||
|
}
|
||||||
|
body = encoded
|
||||||
|
}
|
||||||
|
return articleResponse{
|
||||||
|
ID: article.ID,
|
||||||
|
Title: article.Title,
|
||||||
|
Body: body,
|
||||||
|
BodyFormat: format,
|
||||||
|
IsOnline: article.IsOnline,
|
||||||
|
CreatedAt: article.CreatedAt,
|
||||||
|
UpdatedAt: article.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) {
|
||||||
|
response, err := toArticleResponse(article, format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBodyFormat(w http.ResponseWriter, r *http.Request) (domain.BodyFormat, bool) {
|
||||||
|
format, err := domain.ParseBodyFormat(r.URL.Query().Get("format"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.ArticleBodyFormatErrorMessage, http.StatusBadRequest)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return format, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArticleId(r *http.Request) (uint32, error) {
|
||||||
|
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||||
|
return uint32(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseArticleId(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var article domain.Article
|
||||||
|
if h.callerIsVerified(r) {
|
||||||
|
article, err = h.GetUseCase.GetArticle(id, format)
|
||||||
|
} else {
|
||||||
|
article, err = h.GetUseCase.GetOnlineArticle(id, format)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||||
|
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
articleJson, err := encodeArticle(article, format)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write(articleJson); err != nil {
|
||||||
|
fmt.Printf("Failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var localArticle PostArticle
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
|
||||||
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validator.Validate.Struct(localArticle); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
|
||||||
|
Title: localArticle.Title,
|
||||||
|
Body: localArticle.Body,
|
||||||
|
Format: format,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrInvalidTitle) {
|
||||||
|
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Failed to create article", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||||
|
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||||
|
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) {
|
||||||
|
articleJson, err := encodeArticle(article, format)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != http.StatusOK {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(articleJson); err != nil {
|
||||||
|
log.Printf("Failed to write response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline bool) {
|
||||||
|
id, err := parseArticleId(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, isOnline, format)
|
||||||
|
if err != nil {
|
||||||
|
writeArticleErrorResponse(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateArticleBody struct {
|
||||||
|
Body string `json:"body" validate:"required,max=10000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseArticleId(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var localArticle UpdateArticleBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
|
||||||
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validator.Validate.Struct(localArticle); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedArticle, err := h.UpdateUseCase.UpdateBody(r.Context(), id, localArticle.Body, format)
|
||||||
|
if err != nil {
|
||||||
|
writeArticleErrorResponse(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setIsOnline(w, r, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setIsOnline(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) callerIsVerified(r *http.Request) bool {
|
||||||
|
userId, ok := claims.UserId(r)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, err := h.GetUserUseCase.GetUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentUser.IsVerified
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
format, ok := parseBodyFormat(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var articles []domain.Article
|
||||||
|
var err error
|
||||||
|
if h.callerIsVerified(r) {
|
||||||
|
articles, err = h.ListUseCase.ListAllArticles(format)
|
||||||
|
} else {
|
||||||
|
articles, err = h.ListUseCase.ListOnlineArticles(format)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]articleResponse, 0, len(articles))
|
||||||
|
for _, article := range articles {
|
||||||
|
response, err := toArticleResponse(article, format)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
responses = append(responses, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
articlesJson, err := json.Marshal(responses)
|
||||||
|
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,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,48 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/claims"
|
||||||
|
"RenewCMS/api/pkg/publicuser"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MeHandler struct {
|
||||||
|
GetUseCase *useCases.GetUserUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMeHandler(getUser *useCases.GetUserUseCase) *MeHandler {
|
||||||
|
return &MeHandler{
|
||||||
|
GetUseCase: getUser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userId, ok := claims.UserId(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedUser, err := h.GetUseCase.GetUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(publicuser.New(loadedUser))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
@@ -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,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,348 @@
|
|||||||
|
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"`
|
||||||
|
NextCursor *string `json:"next_cursor"`
|
||||||
|
Counts countsResponse `json:"counts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type usageResponse struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Published bool `json:"published"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mergeResponse struct {
|
||||||
|
ArticlesUpdated int `json:"articles_updated"`
|
||||||
|
Merged int `json:"merged"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase, listVariants *useCases.ListImageVariantsUseCase, generateVariant *useCases.GenerateImageVariantUseCase, generateAll *useCases.GenerateAllImageVariantsUseCase, purgeVariant *useCases.PurgeImageVariantUseCase, generateOnUpload *useCases.GenerateVariantsOnUploadUseCase) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
CreateUseCase: create,
|
||||||
|
ListUseCase: list,
|
||||||
|
RenameUseCase: rename,
|
||||||
|
DeleteUseCase: deleteUseCase,
|
||||||
|
MergeUseCase: merge,
|
||||||
|
UsageUseCase: usage,
|
||||||
|
PurgeSettingsUseCase: purgeSettings,
|
||||||
|
UpdatePurgeSettingsUseCase: updatePurgeSettings,
|
||||||
|
PreviewPurgeUseCase: previewPurge,
|
||||||
|
RunPurgeUseCase: runPurge,
|
||||||
|
ListVariantsUseCase: listVariants,
|
||||||
|
GenerateVariantUseCase: generateVariant,
|
||||||
|
GenerateAllUseCase: generateAll,
|
||||||
|
PurgeVariantUseCase: purgeVariant,
|
||||||
|
GenerateOnUploadUseCase: generateOnUpload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInternalServerError(w http.ResponseWriter) {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalServerError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeImageError(w http.ResponseWriter, err error) {
|
||||||
|
if errors.Is(err, domainImage.ErrImageNotFound) {
|
||||||
|
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeInternalServerError(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImageId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
|
||||||
|
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint32(id), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dimension(value int) *int {
|
||||||
|
if value == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func toResponse(item useCases.MediaItem) mediaItemResponse {
|
||||||
|
return mediaItemResponse{
|
||||||
|
ID: item.Image.ID,
|
||||||
|
Filename: item.Image.Filename,
|
||||||
|
URL: item.Image.Path,
|
||||||
|
Size: item.Image.Size,
|
||||||
|
ContentType: item.Image.ContentType,
|
||||||
|
Width: dimension(item.Image.Width),
|
||||||
|
Height: dimension(item.Image.Height),
|
||||||
|
SHA256: item.Image.SHA256,
|
||||||
|
UploadedBy: item.UploadedBy,
|
||||||
|
CreatedAt: item.Image.CreatedAt,
|
||||||
|
UsedIn: item.UsedIn,
|
||||||
|
Duplicate: item.Duplicate,
|
||||||
|
|
||||||
|
VariantCount: item.VariantCount,
|
||||||
|
VariantSize: item.VariantSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseListQuery(r *http.Request) (domainImage.ListQuery, error) {
|
||||||
|
values, err := url.ParseQuery(r.URL.RawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
query := domainImage.NewListQuery()
|
||||||
|
query.Search = values.Get("q")
|
||||||
|
query.Hash = strings.ToLower(values.Get("hash"))
|
||||||
|
query.Cursor = values.Get("cursor")
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
if values.Has("limit") {
|
||||||
|
limit, err := strconv.Atoi(values.Get("limit"))
|
||||||
|
if err != nil {
|
||||||
|
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
|
||||||
|
}
|
||||||
|
query.Limit = limit
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates},
|
||||||
|
}
|
||||||
|
if page.NextCursor != "" {
|
||||||
|
response.NextCursor = &page.NextCursor
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userId, _ := claims.UserId(r)
|
||||||
|
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer closeFile()
|
||||||
|
|
||||||
|
created, err := h.CreateUseCase.CreateImage(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
upload.WriteCreateError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.GenerateOnUploadUseCase.Generate(r.Context(), created)
|
||||||
|
h.writeItem(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeItem(w http.ResponseWriter, status int, img domainImage.Image) {
|
||||||
|
item, err := h.ListUseCase.Describe(img)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalServerError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, status, toResponse(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseImageId(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request RenameRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validator.Validate.Struct(request); err != nil {
|
||||||
|
http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renamed, err := h.RenameUseCase.Rename(r.Context(), id, request.Filename)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domainImage.ErrInvalidFilename) {
|
||||||
|
http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeImageError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeItem(w, http.StatusOK, renamed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseImageId(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DeleteUseCase.Delete(r.Context(), id); err != nil {
|
||||||
|
writeImageError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseImageId(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
articles, err := h.UsageUseCase.Usage(id)
|
||||||
|
if err != nil {
|
||||||
|
writeImageError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]usageResponse, 0, len(articles))
|
||||||
|
for _, a := range articles {
|
||||||
|
response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request MergeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validator.Validate.Struct(request); err != nil {
|
||||||
|
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.MergeUseCase.Merge(r.Context(), request.KeepID, request.MergeIDs)
|
||||||
|
if err != nil {
|
||||||
|
writeMergeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, Merged: result.Merged})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMergeError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domainImage.ErrInvalidMergeRequest):
|
||||||
|
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
|
||||||
|
case errors.Is(err, domainImage.ErrMergeCopyMissing), errors.Is(err, domainImage.ErrMergeHashMismatch):
|
||||||
|
http.Error(w, apperr.MediaMergeConflictErrorMessage, http.StatusConflict)
|
||||||
|
default:
|
||||||
|
writeImageError(w, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
"RenewCMS/internal/domain/purge"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PurgeSettingsRequest struct {
|
||||||
|
AutoPurgeEnabled *bool `json:"auto_purge_enabled"`
|
||||||
|
AutoPurgeIntervalDays *int `json:"auto_purge_interval_days" validate:"omitempty,gte=1,lte=365"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurgeRunRequest struct {
|
||||||
|
Fingerprint *string `json:"fingerprint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type purgeSettingsResponse struct {
|
||||||
|
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
|
||||||
|
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
|
||||||
|
LastPurgeAt *time.Time `json:"last_purge_at"`
|
||||||
|
LastPurgeMerged int `json:"last_purge_merged"`
|
||||||
|
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
|
||||||
|
EnabledAt *time.Time `json:"enabled_at"`
|
||||||
|
NextPurgeAt *time.Time `json:"next_purge_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type purgePreviewGroupResponse struct {
|
||||||
|
KeepID uint32 `json:"keep_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
UsedIn int `json:"used_in"`
|
||||||
|
Merged int `json:"merged"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type purgePreviewResponse struct {
|
||||||
|
Groups int `json:"groups"`
|
||||||
|
Copies int `json:"copies"`
|
||||||
|
Articles int `json:"articles"`
|
||||||
|
List []purgePreviewGroupResponse `json:"list"`
|
||||||
|
More int `json:"more"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type purgeRunResponse struct {
|
||||||
|
GroupsTotal int `json:"groups_total"`
|
||||||
|
GroupsPurged int `json:"groups_purged"`
|
||||||
|
Merged int `json:"merged"`
|
||||||
|
ArticlesUpdated int `json:"articles_updated"`
|
||||||
|
FailedGroups int `json:"failed_groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPurgeSettingsResponse(settings purge.Settings) purgeSettingsResponse {
|
||||||
|
return purgeSettingsResponse{
|
||||||
|
AutoPurgeEnabled: settings.AutoPurgeEnabled,
|
||||||
|
AutoPurgeIntervalDays: settings.AutoPurgeIntervalDays,
|
||||||
|
LastPurgeAt: settings.LastPurgeAt,
|
||||||
|
LastPurgeMerged: settings.LastPurgeMerged,
|
||||||
|
LastPurgeArticlesUpdated: settings.LastPurgeArticlesUpdated,
|
||||||
|
EnabledAt: settings.EnabledAt,
|
||||||
|
NextPurgeAt: settings.NextPurgeAt(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePurgeError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, purge.ErrInvalidPurgeInterval):
|
||||||
|
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
|
||||||
|
case errors.Is(err, purge.ErrPurgeConflict):
|
||||||
|
http.Error(w, apperr.PurgeConflictErrorMessage, http.StatusConflict)
|
||||||
|
case errors.Is(err, purge.ErrPurgeBusy):
|
||||||
|
http.Error(w, apperr.PurgeBusyErrorMessage, http.StatusConflict)
|
||||||
|
default:
|
||||||
|
writeInternalServerError(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetPurgeSettings(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
settings, err := h.PurgeSettingsUseCase.Get()
|
||||||
|
if err != nil {
|
||||||
|
writePurgeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdatePurgeSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request PurgeSettingsRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validator.Validate.Struct(request); err != nil {
|
||||||
|
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := h.UpdatePurgeSettingsUseCase.Update(useCases.UpdatePurgeSettingsCommand{
|
||||||
|
AutoPurgeEnabled: request.AutoPurgeEnabled,
|
||||||
|
AutoPurgeIntervalDays: request.AutoPurgeIntervalDays,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writePurgeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PreviewPurge(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
preview, err := h.PreviewPurgeUseCase.Preview()
|
||||||
|
if err != nil {
|
||||||
|
writePurgeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]purgePreviewGroupResponse, 0, len(preview.List))
|
||||||
|
for _, group := range preview.List {
|
||||||
|
list = append(list, purgePreviewGroupResponse{
|
||||||
|
KeepID: group.KeepID,
|
||||||
|
Filename: group.Filename,
|
||||||
|
URL: group.URL,
|
||||||
|
UsedIn: group.UsedIn,
|
||||||
|
Merged: group.Merged,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, purgePreviewResponse{
|
||||||
|
Groups: preview.Groups,
|
||||||
|
Copies: preview.Copies,
|
||||||
|
Articles: preview.Articles,
|
||||||
|
List: list,
|
||||||
|
More: preview.More,
|
||||||
|
Fingerprint: preview.Fingerprint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request PurgeRunRequest
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &request); err != nil {
|
||||||
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.RunPurgeUseCase.Run(r.Context(), request.Fingerprint)
|
||||||
|
if err != nil {
|
||||||
|
writePurgeError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, purgeRunResponse{
|
||||||
|
GroupsTotal: result.GroupsTotal,
|
||||||
|
GroupsPurged: result.GroupsPurged,
|
||||||
|
Merged: result.Merged,
|
||||||
|
ArticlesUpdated: result.ArticlesUpdated,
|
||||||
|
FailedGroups: result.FailedGroups,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,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,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/v2/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bearerScheme = "bearer "
|
||||||
|
|
||||||
|
type apiKeyIdContextKey struct{}
|
||||||
|
|
||||||
|
type apiKeyNameContextKey struct{}
|
||||||
|
|
||||||
|
func bearerAPIKey(r *http.Request) (string, bool) {
|
||||||
|
header := r.Header.Get("Authorization")
|
||||||
|
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
credential := strings.TrimSpace(header[len(bearerScheme):])
|
||||||
|
return credential, strings.HasPrefix(credential, apikey.RawKeyPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyIdFrom(r *http.Request) (uint32, bool) {
|
||||||
|
id, ok := r.Context().Value(apiKeyIdContextKey{}).(uint32)
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyNameFrom(r *http.Request) string {
|
||||||
|
name, _ := r.Context().Value(apiKeyNameContextKey{}).(string)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireSession(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, isKey := bearerAPIKey(r); isKey {
|
||||||
|
http.Error(w, apperr.SessionRequiredErrorMessage, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RejectAPIKey(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, authenticatedByKey := apiKeyIdFrom(r); authenticatedByKey {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
|
||||||
|
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
limited := limiter(next)
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rawKey, isKey := bearerAPIKey(r)
|
||||||
|
if !isKey {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated, err := authenticate.AuthenticateAPIKey(rawKey)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIKeyError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := sessionTokenFor(authenticated)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := jwtauth.NewContext(r.Context(), token, nil)
|
||||||
|
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
|
||||||
|
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
|
||||||
|
limited.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAPIKeyError(w http.ResponseWriter, err error) {
|
||||||
|
if errors.Is(err, apikey.ErrAPIKeyInvalid) {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) {
|
||||||
|
token := jwt.New()
|
||||||
|
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyRateLimitKey(r *http.Request) string {
|
||||||
|
id, _ := apiKeyIdFrom(r)
|
||||||
|
return strconv.FormatUint(uint64(id), 10)
|
||||||
|
}
|
||||||
@@ -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/v2/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequireSignedIn(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, _, err := jwtauth.FromContext(r.Context())
|
||||||
|
if err != nil || token == nil || jwt.Validate(token) != nil {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,59 @@
|
|||||||
|
package apperr
|
||||||
|
|
||||||
|
const LogsErrorMessage = "Access to the requested resource is forbidden due to incorrect password and/or username."
|
||||||
|
const BodyErrorMessage = "The request cannot be processed due to a mismatch in the format of the body."
|
||||||
|
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
|
||||||
|
const ArticleNotFoundErrorMessage = "Article not found"
|
||||||
|
const ImageNotFoundErrorMessage = "Image not found"
|
||||||
|
const DisplayNameErrorMessage = "Display name must be 60 characters or fewer."
|
||||||
|
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
|
||||||
|
const RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
|
||||||
|
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
|
||||||
|
const UnauthorizedErrorMessage = "You must be logged in to access this resource."
|
||||||
|
const VerificationFailedErrorMessage = "The verification code is invalid or has expired."
|
||||||
|
const AlreadyVerifiedErrorMessage = "This account is already verified and cannot be cancelled this way."
|
||||||
|
const VerificationRequiredErrorMessage = "You must verify your account to access this resource."
|
||||||
|
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
|
||||||
|
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
|
||||||
|
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
|
||||||
|
const EmailHasAccountErrorMessage = "This address already has an account."
|
||||||
|
const PendingInviteErrorMessage = "There's already a pending invite for this address."
|
||||||
|
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
|
||||||
|
const InviteNotFoundErrorMessage = "Invite not found"
|
||||||
|
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
|
||||||
|
const InviteInvalidErrorMessage = "This invite link doesn't work."
|
||||||
|
const InviteExpiredErrorMessage = "This invite has expired."
|
||||||
|
const OwnerRequiredErrorMessage = "Only the owner can do this."
|
||||||
|
const UsernameTakenErrorMessage = "That username is taken. Try another."
|
||||||
|
const MediaQueryErrorMessage = "The media query is invalid."
|
||||||
|
const MediaFilenameErrorMessage = "Enter a filename."
|
||||||
|
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
|
||||||
|
const MediaMergeRequestErrorMessage = "Choose an image to keep and at least one other image to merge into it."
|
||||||
|
const UpdateNotPreparedErrorMessage = "This server cannot update itself yet."
|
||||||
|
const UpdateAlreadyRunningErrorMessage = "An update is already running or waiting to be installed."
|
||||||
|
const NothingToUpdateErrorMessage = "There is nothing to update."
|
||||||
|
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
|
||||||
|
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
|
||||||
|
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
|
||||||
|
const APIKeyNotFoundErrorMessage = "API key not found"
|
||||||
|
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
|
||||||
|
const SessionRequiredErrorMessage = "This action needs a real session."
|
||||||
|
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
|
||||||
|
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
|
||||||
|
const PurgeBusyErrorMessage = "A purge is already running."
|
||||||
|
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
|
||||||
|
const VariantNotFoundErrorMessage = "Variant not found"
|
||||||
|
const VariantNameErrorMessage = "Use lowercase letters, numbers and dashes, 2 to 32 characters."
|
||||||
|
const VariantDimensionsErrorMessage = "Width and height must be between 1 and 8000, and one of them is required."
|
||||||
|
const VariantFitErrorMessage = "Fit must be cover, contain or inside."
|
||||||
|
const VariantQualityErrorMessage = "Quality must be between 1 and 100."
|
||||||
|
const VariantInvalidErrorMessage = "The variant is not valid."
|
||||||
|
const VariantExistsErrorMessageFormat = "A variant named “%s” already exists. Choose another name."
|
||||||
|
const VariantFormatErrorMessageFormat = "Format must be %s."
|
||||||
|
const VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant."
|
||||||
|
const VariantSourceUnsupportedErrorMessage = "Variants aren't generated for this format."
|
||||||
|
const VariantSourceUnreadableErrorMessage = "The source image can't be read, so this variant can't be generated."
|
||||||
|
const CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image."
|
||||||
|
const CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
|
||||||
|
const VariantUnknownMessage = "Unknown variant"
|
||||||
|
const VariantUnsupportedParameterMessage = "Unsupported parameter"
|
||||||
@@ -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,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,29 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/image"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
const inviteEmailTag = "inviteemail"
|
||||||
|
const variantNameTag = "variantname"
|
||||||
|
|
||||||
|
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
|
||||||
|
|
||||||
|
var Validate *validator.Validate
|
||||||
|
|
||||||
|
func InitValidator() {
|
||||||
|
Validate = validator.New()
|
||||||
|
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
|
||||||
|
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVariantName(field validator.FieldLevel) bool {
|
||||||
|
return image.IsValidVariantName(field.Field().String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInviteEmail(field validator.FieldLevel) bool {
|
||||||
|
return inviteEmailPattern.MatchString(field.Field().String())
|
||||||
|
}
|
||||||
+376
@@ -0,0 +1,376 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/handlers/activity"
|
||||||
|
"RenewCMS/api/handlers/apikey"
|
||||||
|
"RenewCMS/api/handlers/article"
|
||||||
|
"RenewCMS/api/handlers/auth"
|
||||||
|
"RenewCMS/api/handlers/frontend"
|
||||||
|
"RenewCMS/api/handlers/imagevariant"
|
||||||
|
"RenewCMS/api/handlers/media"
|
||||||
|
"RenewCMS/api/handlers/settings"
|
||||||
|
"RenewCMS/api/handlers/staticimages"
|
||||||
|
"RenewCMS/api/handlers/update"
|
||||||
|
"RenewCMS/api/handlers/user"
|
||||||
|
"RenewCMS/api/middleware"
|
||||||
|
"RenewCMS/internal/infrastructure/config"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/MadAppGang/httplog"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) {
|
||||||
|
if environment == "development" {
|
||||||
|
r.Use(httplog.LoggerWithName(routerName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
msg, _ := json.Marshal(map[string]string{"message": "Hello World"})
|
||||||
|
_, _ = w.Write(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
apiKeyMaxRequests = 120
|
||||||
|
apiKeyWindow = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
||||||
|
c.apiKeyAuthOnce.Do(func() {
|
||||||
|
c.apiKeyAuthMiddleware = middleware.APIKeyAuth(c.AuthenticateAPIKeyUseCase, apiKeyMaxRequests, apiKeyWindow)
|
||||||
|
})
|
||||||
|
return c.apiKeyAuthMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
|
||||||
|
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Get("/", articleHandler.ListArticles)
|
||||||
|
r.Get("/{id}", articleHandler.GetArticle)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Post("/", articleHandler.PostArticle)
|
||||||
|
r.Delete("/{id}", articleHandler.DeleteArticle)
|
||||||
|
r.Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||||
|
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||||
|
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
|
||||||
|
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
|
||||||
|
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
|
||||||
|
meHandler := auth.NewMeHandler(container.GetUserUseCase)
|
||||||
|
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
|
||||||
|
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
|
||||||
|
|
||||||
|
r.Post("/register", loginHandler.Register)
|
||||||
|
r.Get("/registration", loginHandler.RegistrationStatus)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||||
|
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||||
|
r.Post("/login", loginHandler.Login)
|
||||||
|
})
|
||||||
|
r.Post("/logout", loginHandler.Logout)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||||
|
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||||
|
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
|
||||||
|
})
|
||||||
|
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||||
|
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||||
|
r.Get("/invite", acceptInviteHandler.GetInvite)
|
||||||
|
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Post("/verify", verificationHandler.Verify)
|
||||||
|
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Get("/me", meHandler.Me)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
|
||||||
|
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||||
|
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Patch("/me", userHandler.UpdateMe)
|
||||||
|
r.Get("/", inviteHandler.ListUsers)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||||
|
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||||
|
r.Post("/invite", inviteHandler.CreateInvite)
|
||||||
|
r.Delete("/invite/{id}", inviteHandler.CancelInvite)
|
||||||
|
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Get("/", settingsHandler.GetSettings)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
r.Use(middleware.RequireSignedIn)
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||||
|
r.Patch("/", settingsHandler.UpdateSettings)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
|
||||||
|
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
r.Use(middleware.RequireSignedIn)
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Get("/", activityHandler.List)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||||
|
r.Get("/summary", activityHandler.Summary)
|
||||||
|
r.Get("/export", activityHandler.Export)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
updateCheckMaxAttempts = 5
|
||||||
|
updateCheckWindow = time.Minute
|
||||||
|
updateDownloadMaxAttempts = 5
|
||||||
|
updateDownloadWindow = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
|
||||||
|
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Get("/update", updateHandler.GetStatus)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
|
||||||
|
r.Post("/update/check", updateHandler.Check)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
|
||||||
|
r.Post("/update/download", updateHandler.Download)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||||
|
r.Post("/update/restart", updateHandler.Restart)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
|
||||||
|
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Post("/", apiKeyHandler.Create)
|
||||||
|
r.Get("/", apiKeyHandler.List)
|
||||||
|
r.Delete("/{id}", apiKeyHandler.Revoke)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase)
|
||||||
|
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
r.Use(middleware.RequireSignedIn)
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||||
|
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
|
||||||
|
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
|
||||||
|
r.Get("/purge-preview", mediaHandler.PreviewPurge)
|
||||||
|
r.Post("/purge", mediaHandler.RunPurge)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RejectAPIKey)
|
||||||
|
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||||
|
r.Get("/{id}/variants", mediaHandler.ListVariants)
|
||||||
|
r.Post("/{id}/variants", mediaHandler.GenerateAllVariants)
|
||||||
|
r.Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
|
||||||
|
r.Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
|
||||||
|
})
|
||||||
|
r.Get("/", mediaHandler.List)
|
||||||
|
r.Post("/", mediaHandler.Upload)
|
||||||
|
r.Post("/merge", mediaHandler.Merge)
|
||||||
|
r.Patch("/{id}", mediaHandler.Rename)
|
||||||
|
r.Delete("/{id}", mediaHandler.Delete)
|
||||||
|
r.Get("/{id}/usage", mediaHandler.Usage)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase, container.GetVariantCacheStatsUseCase, container.PurgeVariantCacheUseCase)
|
||||||
|
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||||
|
r.Use(middleware.RequireSignedIn)
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||||
|
r.Get("/", variantHandler.List)
|
||||||
|
r.Post("/", variantHandler.Create)
|
||||||
|
r.Get("/cache", variantHandler.CacheStats)
|
||||||
|
r.Post("/cache/purge", variantHandler.PurgeCache)
|
||||||
|
r.Put("/{name}", variantHandler.Update)
|
||||||
|
r.Patch("/{name}", variantHandler.SetEnabled)
|
||||||
|
r.Delete("/{name}", variantHandler.Delete)
|
||||||
|
r.Post("/{name}/purge", variantHandler.Purge)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
enableLoggingIfDev(r, "backend", settings.Environment)
|
||||||
|
|
||||||
|
r.Use(chimiddleware.GetHead)
|
||||||
|
r.Use(middleware.JsonContentTypeMiddleware)
|
||||||
|
r.Get("/", GetHelloWorld)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
|
||||||
|
})
|
||||||
|
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
|
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||||
|
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
|
||||||
|
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
|
||||||
|
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
|
||||||
|
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
|
||||||
|
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
|
||||||
|
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitFrontendRoutes(environment string) *chi.Mux {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
enableLoggingIfDev(r, "frontend", environment)
|
||||||
|
|
||||||
|
r.Mount("/", frontend.NewFrontendRouter())
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHealth(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableCorsIfConfigured(r *chi.Mux, origins []string) {
|
||||||
|
if len(origins) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: origins,
|
||||||
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
|
ExposedHeaders: []string{"Link"},
|
||||||
|
AllowCredentials: false,
|
||||||
|
MaxAge: 300,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||||
|
backend := InitBackendRoutes(container, settings)
|
||||||
|
frontend := InitFrontendRoutes(settings.Environment)
|
||||||
|
|
||||||
|
apiRouter := chi.NewRouter()
|
||||||
|
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
|
||||||
|
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
|
||||||
|
apiRouter.Use(chimiddleware.GetHead)
|
||||||
|
apiRouter.Get("/healthz", GetHealth)
|
||||||
|
apiRouter.Mount("/v1", backend)
|
||||||
|
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
|
||||||
|
apiRouter.Mount("/", frontend)
|
||||||
|
|
||||||
|
return apiRouter
|
||||||
|
}
|
||||||
@@ -1 +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: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
|||||||
tinymce.IconManager.add("default",{icons:{"accessibility-check":'<svg width="24" height="24"><path d="M12 2a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2c0-1.1.9-2 2-2Zm8 7h-5v12c0 .6-.4 1-1 1a1 1 0 0 1-1-1v-5c0-.6-.4-1-1-1a1 1 0 0 0-1 1v5c0 .6-.4 1-1 1a1 1 0 0 1-1-1V9H4a1 1 0 1 1 0-2h16c.6 0 1 .4 1 1s-.4 1-1 1Z" fill-rule="nonzero"/></svg>',"accordion-toggle":'<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 15c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1h-6a1 1 0 0 1-1-1Z"/><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M4 19c0-.6.4-1 1-1h6c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1-1-1Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.3 7.3a1 1 0 0 1 1.4 0L16 9.6l2.3-2.3a1 1 0 1 1 1.4 1.4L16 12.4l-3.7-3.7a1 1 0 0 1 0-1.4ZM4.3 11.7a1 1 0 0 1 0-1.4L6.6 8 4.3 5.7a1 1 0 0 1 1.4-1.4L9.4 8l-3.7 3.7a1 1 0 0 1-1.4 0Z"/></svg>',accordion:'<svg width="24" height="24"><rect x="12" y="7" width="10" height="2" rx="1"/><rect x="12" y="11" width="10" height="2" rx="1"/><rect x="12" y="15" width="6" height="2" rx="1"/><path fill-rule="evenodd" clip-rule="evenodd" d="M2.3 7.3a1 1 0 0 1 1.4 0L6 9.6l2.3-2.3a1 1 0 0 1 1.4 1.4L6 12.4 2.3 8.7a1 1 0 0 1 0-1.4Z"/></svg>',"action-next":'<svg width="24" height="24"><path fill-rule="nonzero" d="M5.7 7.3a1 1 0 0 0-1.4 1.4l7.7 7.7 7.7-7.7a1 1 0 1 0-1.4-1.4L12 13.6 5.7 7.3Z"/></svg>',"action-prev":'<svg width="24" height="24"><path fill-rule="nonzero" d="M18.3 15.7a1 1 0 0 0 1.4-1.4L12 6.6l-7.7 7.7a1 1 0 0 0 1.4 1.4L12 9.4l6.3 6.3Z"/></svg>',addtag:'<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 5a2 2 0 0 1 1.6.8L21 12l-4.4 6.2a2 2 0 0 1-1.6.8h-3v-2h3l3.5-5L15 7H5v3H3V7c0-1.1.9-2 2-2h10Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6 12a1 1 0 0 0-1 1v2H3a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0-2H7v-2c0-.6-.4-1-1-1Z"/></svg>',"ai-prompt":'<svg width="24" height="24"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M15 6.7a1 1 0 0 0-1.4 0l-9.9 10a1 1 0 0 0 0 1.3l2.1 2.1c.4.4 1 .4 1.4 0l10-9.9c.3-.3.3-1 0-1.4l-2.2-2Zm1.4 2.8-2-2-3 2.7 2.2 2.2 2.8-2.9Z"/><path d="m18.5 7.3-.7-1.5-1.5-.8 1.5-.7.7-1.5.7 1.5 1.5.7-1.5.8-.7 1.5ZM18.5 16.5l-.7-1.6-1.5-.7 1.5-.7.7-1.6.7 1.6 1.5.7-1.5.7-.7 1.6ZM9.7 7.3 9 5.8 7.5 5 9 4.3l.7-1.5.7 1.5L12 5l-1.5.8-.7 1.5Z"/></g><defs><clipPath id="a"><path d="M0 0h24v24H0z"/></clipPath></defs></svg>',ai:'<svg width="24" height="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M5 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3H5Zm6.8 11.5.5 1.2a68.3 68.3 0 0 0 .7 1.1l.4.1c.3 0 .5 0 .7-.3.2-.1.3-.3.3-.6l-.3-1-2.6-6.2a20.4 20.4 0 0 0-.5-1.3l-.5-.4-.7-.2c-.2 0-.5 0-.6.2-.2 0-.4.2-.5.4l-.3.6-.3.7L5.7 15l-.2.6-.1.4c0 .3 0 .5.3.7l.6.2c.3 0 .5 0 .7-.2l.4-1 .5-1.2h3.9ZM9.8 9l1.5 4h-3l1.5-4Zm5.6-.9v7.6c0 .4 0 .7.2 1l.7.2c.3 0 .6 0 .8-.3l.2-.9V8.1c0-.4 0-.7-.2-.9a1 1 0 0 0-.8-.3c-.2 0-.5.1-.7.3l-.2 1Z"/></svg>',"align-center":'<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm3 4h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 0 1 0-2Zm-3-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',"align-justify":'<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0 4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',"align-left":'<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 4h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Zm0-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',"align-none":'<svg width="24" height="24"><path d="M14.2 5 13 7H5a1 1 0 1 1 0-2h9.2Zm4 0h.8a1 1 0 0 1 0 2h-2l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h6.8Zm4 0H19a1 1 0 0 1 0 2h-4.4l1.2-2Zm-6.4 4-1.2 2H5a1 1 0 0 1 0-2h4.4Zm4 0H19a1 1 0 0 1 0 2h-6.8l1.2-2ZM7 17l-1.2 2H5a1 1 0 0 1 0-2h2Zm4 0h8a1 1 0 0 1 0 2H9.8l1.2-2Zm5.2-13.5 1.3.7-9.7 16.3-1.3-.7 9.7-16.3Z" fill-rule="evenodd"/></svg>',"align-right":'<svg width="24" height="24"><path d="M5 5h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 1 1 0-2Zm6 4h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm0 8h8c.6 0 1 .4 1 1s-.4 1-1 1h-8a1 1 0 0 1 0-2Zm-6-4h14c.6 0 1 .4 1 1s-.4 1-1 1H5a1 1 0 0 1 0-2Z" fill-rule="evenodd"/></svg>',"arrow-left":'<svg width="24" height="24"><path d="m5.6 13 12 6a1 1 0 0 0 1.4-1V6a1 1 0 0 0-1.4-.9l-12 6a1 1 0 0 0 0 1.8Z" fill-rule="evenodd"/></svg>',"arrow-right":'<svg width="24" height="24"><path d="m18.5 13-12 6A1 1 0 0 1 5 18V6a1 1 0 0 1 1.4-.9l12 6a1 1 0 0 1 0 1.8Z" fill-rule="evenodd"/></svg>',bold:'<svg width="24" height="24"><path d="M7.Line truncated
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
This is where language files should be placed.
|
|
||||||
|
|
||||||
Please DO NOT translate these directly, use this service instead: https://crowdin.com/project/tinymce
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# Software License Agreement
|
|
||||||
|
|
||||||
**TinyMCE** – [<https://github.com/tinymce/tinymce>](https://github.com/tinymce/tinymce)
|
|
||||||
Copyright (c) 2024, Ephox Corporation DBA Tiny Technologies, Inc.
|
|
||||||
|
|
||||||
Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html).
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* TinyMCE version 7.1.1 (2024-05-22)
|
|
||||||
*/
|
|
||||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.ModelManager");const t=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(o=n=e,(r=String).prototype.isPrototypeOf(o)||(null===(s=n.constructor)||void 0===s?void 0:s.name)===r.name)?"string":t;var o,n,r,s})(t)===e,o=e=>t=>typeof t===e,n=e=>t=>e===t,r=t("string"),s=t("object"),l=t("array"),a=n(null),c=o("boolean"),i=n(void 0),m=e=>!(e=>null==e)(e),d=o("function"),u=o("number"),f=()=>{},g=e=>()=>e,h=e=>e,p=(e,t)=>e===t;function b(e,...t){return(...o)=>{const n=t.concat(o);return e.apply(null,n)}}const w=e=>t=>!e(t),v=e=>e(),y=g(!1),x=g(!0);class C{constructor(e,t){this.tag=e,this.value=t}static some(e){return new C(!0,e)}static none(){return C.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?C.some(e(this.value)):C.none()}bind(e){return this.tag?e(this.value):C.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:C.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return m(e)?C.some(e):C.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}C.singletonNone=new C(!1);const S=Array.prototype.slice,T=Array.prototype.indexOf,R=Array.prototype.push,D=(e,t)=>{return o=e,n=t,T.call(o,n)>-1;var o,n},O=(e,t)=>{for(let o=0,n=e.length;o<n;o++)if(t(e[o],o))return!0;return!1},k=(e,t)=>{const o=[];for(let n=0;n<e;n++)o.push(t(n));return o},E=(e,t)=>{const o=e.length,n=new Array(o);for(let r=0;r<o;r++){const o=e[r];n[r]=t(o,r)}return n},N=(e,t)=>{for(let o=0,n=e.length;o<n;o++)t(e[o],o)},B=(e,t)=>{const o=[],n=[];for(let r=0,s=e.length;r<s;r++){const s=e[r];(t(s,r)?o:n).push(s)}return{pass:o,fail:n}},_=(e,t)=>{const o=[];for(let n=0,r=e.length;n<r;n++){const r=e[n];t(r,n)&&o.push(r)}return o},z=(e,t,o)=>(((e,t)=>{for(let o=e.length-1;o>=0;o--)t(e[o],o)})(e,((e,n)=>{o=t(o,e,n)})),o),A=(e,t,o)=>(N(e,((e,n)=>{o=t(o,e,n)})),o),L=(e,t)=>((e,t,o)=>{for(let n=0,r=e.length;n<r;n++){const r=e[n];if(t(r,n))return C.some(r);if(o(r,n))break}return C.none()})(e,t,y),W=(e,t)=>{for(let o=0,n=e.length;o<n;o++)if(t(e[o],o))return C.some(o);return C.none()},M=e=>{const t=[];for(let o=0,n=e.length;o<n;++o){if(!l(e[o]))throw new Error("Arr.flatten item "+o+" was not an array, input: "+e);R.apply(t,e[o])}return t},j=(e,t)=>M(E(e,t)),P=(e,t)=>{for(let o=0,n=e.length;o<n;++o)if(!0!==t(e[o],o))return!1;return!0},I=(e,t)=>{const o={};for(let n=0,r=e.length;n<r;n++){const r=e[n];o[String(r)]=t(r,n)}return o},F=(e,t)=>t>=0&&t<e.length?C.some(e[t]):C.none(),H=e=>F(e,0),$=e=>F(e,e.length-1),V=(e,t)=>{for(let o=0;o<e.length;o++){const n=t(e[o],o);if(n.isSome())return n}return C.none()},q=Object.keys,U=Object.hasOwnProperty,G=(e,t)=>{const o=q(e);for(let n=0,r=o.length;n<r;n++){const r=o[n];t(e[r],r)}},K=(e,t)=>Y(e,((e,o)=>({k:o,v:t(e,o)}))),Y=(e,t)=>{const o={};return G(e,((e,n)=>{const r=t(e,n);o[r.k]=r.v})),o},J=(e,t)=>{const o=[];return G(e,((e,n)=>{o.push(t(e,n))})),o},Q=e=>J(e,h),X=(e,t)=>U.call(e,t),Z="undefined"!=typeof window?window:Function("return this;")(),ee=(e,t)=>((e,t)=>{let o=null!=t?t:Z;for(let t=0;t<e.length&&null!=o;++t)o=o[e[t]];return o})(e.split("."),t),te=Object.getPrototypeOf,oe=e=>{const t=ee("ownerDocument.defaultView",e);return s(e)&&((e=>((e,t)=>{const o=((e,t)=>ee(e,t))(e,t);if(null==o)throw new Error(e+" not available on this browser");return o})("HTMLElement",e))(t).prototype.isPrototypeOf(e)||/^HTML\w*Element$/.test(te(e).constructor.name))},ne=e=>e.dom.nodeName.toLowerCase(),re=e=>e.dom.nodeType,se=e=>t=>re(t)===e,le=e=>8===re(e)||"#comment"===ne(e),ae=e=>ce(e)&&oe(e.dom),ce=se(1),ie=se(3),me=se(9),de=se(11),ue=e=>t=>ce(t)&&ne(t)===e,fe=(e,t,o)=>{if(!(r(o)||c(o)||u(o)))throw console.error("Invalid call to Attribute.set. Key ",t,":: Value ",o,":: Element ",e),new Error("Attribute value was not simple");e.setAttribute(t,o+"")},ge=(e,t,o)=>{fe(e.dom,t,o)},he=(e,t)=>{const o=e.dom;G(t,((e,t)=>{fe(o,t,e)}))},pe=(e,t)=>{const o=e.dom.getAttribute(t);return null===o?void 0:o},be=(e,t)=>C.from(pe(e,t)),we=(e,t)=>{e.dom.removeAttribute(t)},ve=e=>A(e.dom.attributes,((e,t)=>(e[t.name]=t.value,e)),{}),ye=e=>{if(null==e)throw new Error("Node cannot be null or undefined");return{dom:e}},xe={fromHtml:(e,t)=>{const o=(t||document).createElement("div");if(o.innerHTML=e,!o.hasChildNodes()||o.childNodes.length>1){const t="HTML does not have a single root node";throw console.error(t,e),new Error(t)}return ye(o.childNodes[0])},fromTag:(e,t)=>{const o=(t||document).createElement(e);return
Line truncated
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* TinyMCE version 7.1.1 (2024-05-22)
|
|
||||||
*/
|
|
||||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");let t=0;const o=e=>t=>typeof t===e,n=e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(o=n=e,(r=String).prototype.isPrototypeOf(o)||(null===(s=n.constructor)||void 0===s?void 0:s.name)===r.name)?"string":t;var o,n,r,s})(e),r=o("boolean"),s=e=>null==e,a=e=>!s(e),i=o("function"),d=o("number"),l=e=>()=>e,c=(e,t)=>e===t,m=l(!1);class u{constructor(e,t){this.tag=e,this.value=t}static some(e){return new u(!0,e)}static none(){return u.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?u.some(e(this.value)):u.none()}bind(e){return this.tag?e(this.value):u.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:u.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return a(e)?u.some(e):u.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}u.singletonNone=new u(!1);const g=Array.prototype.indexOf,p=(e,t)=>{return o=e,n=t,g.call(o,n)>-1;var o,n},h=(e,t)=>{const o=e.length,n=new Array(o);for(let r=0;r<o;r++){const o=e[r];n[r]=t(o,r)}return n},f=(e,t)=>{for(let o=0,n=e.length;o<n;o++)t(e[o],o)},y=Object.keys;"undefined"!=typeof window?window:Function("return this;")();const v=e=>e.dom.nodeName.toLowerCase(),w=e=>e.dom.nodeType,b=e=>t=>w(t)===e,N=b(1),T=b(3),A=b(9),C=b(11),S=(e,t,o)=>{if(!(n(o)||r(o)||d(o)))throw console.error("Invalid call to Attribute.set. Key ",t,":: Value ",o,":: Element ",e),new Error("Attribute value was not simple");e.setAttribute(t,o+"")},x=(e,t)=>{const o=e.dom.getAttribute(t);return null===o?void 0:o},E=(e,t)=>u.from(x(e,t)),D=(e,t)=>{e.dom.removeAttribute(t)},M=e=>{if(null==e)throw new Error("Node cannot be null or undefined");return{dom:e}},P={fromHtml:(e,t)=>{const o=(t||document).createElement("div");if(o.innerHTML=e,!o.hasChildNodes()||o.childNodes.length>1){const t="HTML does not have a single root node";throw console.error(t,e),new Error(t)}return M(o.childNodes[0])},fromTag:(e,t)=>{const o=(t||document).createElement(e);return M(o)},fromText:(e,t)=>{const o=(t||document).createTextNode(e);return M(o)},fromDom:M,fromPoint:(e,t,o)=>u.from(e.dom.elementFromPoint(t,o)).map(M)},O=(e,t)=>{const o=e.dom;if(1!==o.nodeType)return!1;{const e=o;if(void 0!==e.matches)return e.matches(t);if(void 0!==e.msMatchesSelector)return e.msMatchesSelector(t);if(void 0!==e.webkitMatchesSelector)return e.webkitMatchesSelector(t);if(void 0!==e.mozMatchesSelector)return e.mozMatchesSelector(t);throw new Error("Browser lacks native selectors")}},k=e=>1!==e.nodeType&&9!==e.nodeType&&11!==e.nodeType||0===e.childElementCount,B=O,R=(L=/^\s+|\s+$/g,e=>e.replace(L,""));var L;const $=e=>void 0!==e.style&&i(e.style.getPropertyValue),I=e=>A(e)?e:P.fromDom(e.dom.ownerDocument),V=e=>u.from(e.dom.parentNode).map(P.fromDom),j=e=>u.from(e.dom.nextSibling).map(P.fromDom),q=e=>h(e.dom.childNodes,P.fromDom),F=i(Element.prototype.attachShadow)&&i(Node.prototype.getRootNode)?e=>P.fromDom(e.dom.getRootNode()):I,H=e=>P.fromDom(e.dom.host),z=e=>{const t=T(e)?e.dom.parentNode:e.dom;if(null==t||null===t.ownerDocument)return!1;const o=t.ownerDocument;return(e=>{const t=F(e);return C(o=t)&&a(o.dom.host)?u.some(t):u.none();var o})(P.fromDom(t)).fold((()=>o.body.contains(t)),(n=z,r=H,e=>n(r(e))));var n,r},K=(e,t)=>$(e)?e.style.getPropertyValue(t):"",U=(e,t)=>{V(e).each((o=>{o.dom.insertBefore(t.dom,e.dom)}))},Y=(e,t)=>{j(e).fold((()=>{V(e).each((e=>{_(e,t)}))}),(e=>{U(e,t)}))},_=(e,t)=>{e.dom.appendChild(t.dom)},G=(e,t)=>{f(t,((o,n)=>{const r=0===n?e:t[n-1];Y(r,o)}))},J=(e,t)=>{let o=[];return f(q(e),(e=>{t(e)&&(o=o.concat([e])),o=o.concat(J(e,t))})),o},Q=(e,t,o)=>{let n=e.dom;const r=i(o)?o:m;for(;n.parentNode;){n=n.parentNode;const e=P.fromDom(n);if(t(e))return u.some(e);if(r(e))break}return u.none()},W=e=>{const t=e.dom;null!==t.parentNode&&t.parentNode.removeChild(t)},X=(e,t,o)=>Q(e,(e=>O(e,t)),o),Z=(e,t)=>((e,t)=>{const o=void 0===t?document:t.dom;return k(o)?u.none():u.from(o.querySelector(e)).map(P.fromDom)})(t,e),ee=((e,t)=>{const o=t=>e(t)?u.from(t.dom.nodeValue):u.none();return{get:t=>{if(!e(t))throw new Error("Can only get text value of a text node");return o(t).getOr("")},getOption:o,set:(t,o)=>{if(!e(t))throw new Error("Can only set raw text value of a text node");t.dom.nodeValue=o}}})(T);var te=["body","p","div","article","aside","figcaption","figure","footer","header","nav","section","ol","ul","li","table","thead","tbody","tfoot","caption","tr","td","th"Line truncated
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* TinyMCE version 7.1.1 (2024-05-22)
|
|
||||||
*/
|
|
||||||
!function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;e<n;e++){const n=t[e];if(d(r=n)&&!/\btox\-/.test(r.className))return a.some(n);if(s(n,e))break}var r;return a.none()})(e,0,g).exists((e=>e.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the List Styles plugin.")}))}();
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* TinyMCE version 7.1.1 (2024-05-22)
|
|
||||||
*/
|
|
||||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=("allow_html_in_named_anchor",e=>e.options.get("allow_html_in_named_anchor"));const a="a:not([href])",r=e=>!e,i=e=>e.getAttribute("id")||e.getAttribute("name")||"",l=e=>(e=>"a"===e.nodeName.toLowerCase())(e)&&!e.getAttribute("href")&&""!==i(e),s=e=>e.dom.getParent(e.selection.getStart(),a),d=(e,a)=>{const r=s(e);r?((e,t,o)=>{o.removeAttribute("name"),o.id=t,e.addVisual(),e.undoManager.add()})(e,a,r):((e,a)=>{e.undoManager.transact((()=>{n(e)||e.selection.collapse(!0),e.selection.isCollapsed()?e.insertContent(e.dom.createHTML("a",{id:a})):((e=>{const n=e.dom;t(n).walk(e.selection.getRng(),(e=>{o.each(e,(e=>{var t;l(t=e)&&!t.firstChild&&n.remove(e,!1)}))}))})(e),e.formatter.remove("namedAnchor",void 0,void 0,!0),e.formatter.apply("namedAnchor",{value:a}),e.addVisual())}))})(e,a),e.focus()},c=e=>(e=>r(e.attr("href"))&&!r(e.attr("id")||e.attr("name")))(e)&&!e.firstChild,m=e=>t=>{for(let o=0;o<t.length;o++){const n=t[o];c(n)&&n.attr("contenteditable",e)}},u=e=>t=>{const o=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",o),o(),()=>{e.off("NodeChange",o)}};e.add("anchor",(e=>{(e=>{(0,e.options.register)("allow_html_in_named_anchor",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreInit",(()=>{e.parser.addNodeFilter("a",m("false")),e.serializer.addNodeFilter("a",m(null))}))})(e),(e=>{e.addCommand("mceAnchor",(()=>{(e=>{const t=(e=>{const t=s(e);return t?i(t):""})(e);e.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:t=>{((e,t)=>/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)?(d(e,t),!0):(e.windowManager.alert("ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),!1))(e,t.getData().id)&&t.close()}})})(e)}))})(e),(e=>{const t=()=>e.execCommand("mceAnchor");e.ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:t,onSetup:t=>{const o=e.selection.selectorChangedWithUnbind("a:not([href])",t.setActive).unbind,n=u(e)(t);return()=>{o(),n()}}}),e.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:t,onSetup:u(e)})})(e),e.on("PreInit",(()=>{(e=>{e.formatter.register("namedAnchor",{inline:"a",selector:a,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:(e,t,o)=>l(e)})})(e)}))}))}();
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* TinyMCE version 7.1.1 (2024-05-22)
|
|
||||||
*/
|
|
||||||
!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}();
|
|
||||||
Loaded 100 of 1244 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user