diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9c34be4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +[*] +indent_size = 4 +indent_style = space + +[Makefile] +indent_style = tab + +[*.yml] +indent_size = 2 +indent_style = space + +[*.yaml] +indent_size = 2 +indent_style = space diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fd7a85..0e1532d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,11 +6,11 @@ on: - 'main' paths: - 'api/**' + - 'cmd/**' - 'internal/**' - 'pkg/**' - 'test/**' - 'web/**' - - '*.go' - 'go.*' - '*.md' @@ -51,7 +51,7 @@ jobs: if: ${{ contains(steps.latest.outputs.name, '.') }} uses: datamonsters/replace-action@v2 with: - files: 'journal.go' + files: 'cmd/journal/main.go' replacements: '${{ steps.latest_clean.outputs.name }}=${{ steps.version.outputs.value }}' - name: Update Version in Files (2) if: ${{ contains(steps.latest.outputs.name, '.') }} @@ -59,6 +59,12 @@ jobs: with: files: 'web/app/package.json' replacements: '${{ steps.latest_clean.outputs.name }}=${{ steps.version.outputs.value }}' + - name: Update Version in Files (3) + if: ${{ contains(steps.latest.outputs.name, '.') }} + uses: datamonsters/replace-action@v2 + with: + files: 'api/openapi.yml' + replacements: '${{ steps.latest_clean.outputs.name }}=${{ steps.version.outputs.value }}' - name: File Save Delay uses: jakejarvis/wait-action@master with: @@ -82,15 +88,12 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: '1.22' + go-version: '1.24' cache-dependency-path: go.sum - name: Build Binary run: | - sudo apt-get install -y build-essential libsqlite3-dev go mod download - CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal-bin_linux_x64-v${{ steps.version.outputs.value }} . - cp journal-bin_linux_x64-v${{ steps.version.outputs.value }} bootstrap - zip -r journal-lambda_al2023-v${{ steps.version.outputs.value }}.zip bootstrap web -x web/app/\* + GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal-bin_linux_x64-v${{ steps.version.outputs.value }} ./cmd/journal - name: Create Release uses: ncipollo/release-action@v1.12.0 with: @@ -99,4 +102,4 @@ jobs: makeLatest: true tag: v${{ steps.version.outputs.value }} name: v${{ steps.version.outputs.value }} - artifacts: "journal-bin_linux_x64-v${{ steps.version.outputs.value }},journal-lambda_al2023-v${{ steps.version.outputs.value }}.zip" + artifacts: "journal-bin_linux_x64-v${{ steps.version.outputs.value }}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1fad3ff..a2099f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,6 @@ name: Test on: - push: - branches: - - '*' - - '!main' pull_request: {} permissions: @@ -17,7 +13,6 @@ env: GOPATH: /home/runner/work/journal/journal/go J_ARTICLES_PER_PAGE: '' J_DB_PATH: '' - J_GIPHY_API_KEY: '' J_PORT: '' J_TITLE: '' @@ -32,7 +27,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: '1.22' + go-version: '1.24' cache-dependency-path: go/src/github.com/jamiefdhurst/journal/go.sum - name: Install Dependencies working-directory: go/src/github.com/jamiefdhurst/journal @@ -44,12 +39,12 @@ jobs: working-directory: go/src/github.com/jamiefdhurst/journal run: make test - name: Upload Test Results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: tests path: go/src/github.com/jamiefdhurst/journal/tests.xml - name: Upload Coverage - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage path: go/src/github.com/jamiefdhurst/journal/coverage.xml @@ -60,21 +55,16 @@ jobs: action_fail: true files: | go/src/github.com/jamiefdhurst/journal/tests.xml - - name: Publush Code Coverage - uses: irongut/CodeCoverageSummary@v1.3.0 + - name: Create Code Coverage Report + uses: im-open/code-coverage-report-generator@4.9.0 with: - filename: go/src/github.com/jamiefdhurst/journal/coverage.xml - badge: false - fail_below_min: true - format: markdown - hide_branch_rate: false - hide_complexity: true - indicators: true - output: both - thresholds: '80 90' - - name: Add Coverage PR Comment - uses: marocchino/sticky-pull-request-comment@v2 - if: github.event_name == 'pull_request' + reports: go/src/github.com/jamiefdhurst/journal/coverage.xml + reporttypes: MarkdownSummary + title: Go Test Code Coverage + - name: Publish Code Coverage + uses: im-open/process-code-coverage-summary@v2.2.3 with: - recreate: true - path: code-coverage-results.md \ No newline at end of file + github-token: ${{ secrets.GITHUB_TOKEN }} + summary-file: './coverage-results/Summary.md' + check-name: 'Code Coverage' + line-threshold: 80 diff --git a/.gitignore b/.gitignore index 6cf6a54..0b063b0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,12 +24,13 @@ _testmain.go !Dockerfile.test *.out *.prof +coverage.xml data -journal +/journal node_modules test/data/test.db +tests.xml .vscode .DS_Store .history -bootstrap -*.zip +.env diff --git a/Dockerfile b/Dockerfile index 54e9dc2..8e798f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ COPY . . RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends --assume-yes build-essential libsqlite3-dev; \ go mod download; \ - CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal .; \ + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal ./cmd/journal; \ mv journal /go/bin/journal FROM debian:bookworm @@ -13,15 +13,20 @@ LABEL org.opencontainers.image.source=https://github.com/jamiefdhurst/journal WORKDIR /go/src/github.com/jamiefdhurst/journal COPY --from=0 /go/bin/journal /usr/local/bin/ +COPY --from=0 /go/src/github.com/jamiefdhurst/journal/api api COPY --from=0 /go/src/github.com/jamiefdhurst/journal/web web RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends --assume-yes libsqlite3-0 ENV GOPATH "/go" -ENV J_ARTICLES_PER_PAGE "" +ENV J_CREATE "" ENV J_DB_PATH "" -ENV J_GIPHY_API_KEY "" +ENV J_DESCRIPTION "" +ENV J_EDIT "" +ENV J_GA_CODE "" ENV J_PORT "" +ENV J_POSTS_PER_PAGE "" +ENV J_THEME "" ENV J_TITLE "" VOLUME /go/data diff --git a/Dockerfile.test b/Dockerfile.test index 5b98d2d..21a9aa4 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,10 +1,14 @@ FROM golang:1.22-bookworm LABEL org.opencontainers.image.source=https://github.com/jamiefdhurst/journal -ENV J_ARTICLES_PER_PAGE "" +ENV J_CREATE "" ENV J_DB_PATH "" -ENV J_GIPHY_API_KEY "" +ENV J_DESCRIPTION "" +ENV J_EDIT "" +ENV J_GA_CODE "" ENV J_PORT "" +ENV J_POSTS_PER_PAGE "" +ENV J_THEME "" ENV J_TITLE "" WORKDIR /go/src/github.com/jamiefdhurst/journal @@ -12,7 +16,7 @@ COPY . . RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends --assume-yes build-essential libsqlite3-dev; \ go mod download; \ - CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal .; \ + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -v -o journal ./cmd/journal; \ go install github.com/tebeka/go2xunit@latest;\ go install github.com/axw/gocov/gocov@latest; \ go install github.com/AlekSi/gocov-xml@latest; \ diff --git a/Makefile b/Makefile index 881859b..a1dc29a 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,15 @@ -.PHONY: build test +.PHONY: build test coverage clean build: - @CC=x86_64-unknown-linux-gnu-gcc CGO_ENABLED=1 GOARCH=amd64 GOOS=linux go build -v -o bootstrap . - @zip -r lambda.zip bootstrap web -x web/app/\* + @go build -v -o journal ./cmd/journal test: @2>&1 go test -coverprofile=cover.out -coverpkg=./internal/...,./pkg/... -v ./... | go2xunit > tests.xml @gocov convert cover.out | gocov-xml > coverage.xml +coverage: + @go test -coverprofile=cover.out -coverpkg=./internal/...,./pkg/... ./... + @go tool cover -func=cover.out + clean: - @rm -f bootstrap - @rm -f lambda.zip \ No newline at end of file + @rm -f journal \ No newline at end of file diff --git a/README.md b/README.md index 813747e..ce1978f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,14 @@ with the addition of an API. It makes use of a SQLite database to store the journal entries. -[API Documentation](api/README.md) +[API Documentation](api/README.md) - also available via `openapi.yml` as a URL +when deployed. + +[Installation Guide](docs/installation.md) - binary and Docker installation +with configuration reference. + +[User Guide](docs/user-guide.md) - creating and editing entries, and +navigating the journal. ## Purpose @@ -43,23 +50,40 @@ _Please note: you will need Docker installed on your local machine._ docker run --rm -v ./data:/go/data -p 3000:3000 -it journal:latest ``` -## Environment Variables +## Configuration through Environment Variables + +The application uses environment variables to configure all aspects. + +You can optionally supply these through a `.env` file that will be parsed before +any additional environment variables. + +### General Configuration -* `J_ARTICLES_PER_PAGE` - Articles to display per page, default `20` -* `J_CREATE` - Set to `0` to disable article creation +* `J_CREATE` - Set to `0` to disable post creation * `J_DB_PATH` - Path to SQLite DB - default is `$GOPATH/data/journal.db` * `J_DESCRIPTION` - Set the HTML description of the Journal -* `J_EDIT` - Set to `0` to disable article modification +* `J_EDIT` - Set to `0` to disable post modification +* `J_EXCERPT_WORDS` - The length of the post shown as a preview/excerpt in the index, default `50` * `J_GA_CODE` - Google Analytics tag value, starts with `UA-`, or ignore to disable Google Analytics -* `J_GIPHY_API_KEY` - Set to a GIPHY API key to use, or ignore to disable GIPHY * `J_PORT` - Port to expose over HTTP, default is `3000` +* `J_POSTS_PER_PAGE` - Posts to display per page, default `20` +* `J_THEME` - Theme to use from within the _web/themes_ folder, defaults to `default` * `J_TITLE` - Set the title of the Journal -To use the API key within your Docker setup, include it as follows: +### SSL/TLS Configuration -```bash -docker run --rm -e J_GIPHY_API_KEY=... -v ./data:/go/data -p 3000:3000 -it journal:latest -``` +* `J_SSL_CERT` - Path to SSL certificate file for HTTPS (enables SSL when set) +* `J_SSL_KEY` - Path to SSL private key file for HTTPS + +### Session and Cookie Security + +* `J_SESSION_KEY` - 32-byte encryption key for session data (AES-256). Must be exactly 32 printable ASCII characters. If not set, a random key is generated on startup (sessions won't persist across restarts). +* `J_SESSION_NAME` - Cookie name for sessions, default `journal-session` +* `J_COOKIE_DOMAIN` - Domain restriction for cookies, default is current domain only +* `J_COOKIE_MAX_AGE` - Cookie expiry time in seconds, default `2592000` (30 days) +* `J_COOKIE_HTTPONLY` - Set to `0` or `false` to allow JavaScript access to cookies (not recommended). Default is `true` for XSS protection. + +**Note:** When `J_SSL_CERT` is configured, session cookies automatically use the `Secure` flag to prevent transmission over unencrypted connections. ## Layout @@ -77,9 +101,9 @@ The project layout follows the standard set out in the following document: * `/test` - API tests * `/test/data` - Test data * `/test/mocks` - Mock files for testing -* `/web/app` - CSS/JS source files * `/web/static` - Compiled static public assets * `/web/templates` - View templates +* `/web/themes` - Front-end themes, a default theme is included ## Development @@ -101,15 +125,15 @@ the binary itself. #### Dependencies -The application currently only has one dependency: +The application has the following dependencies (using go.mod and go.sum): -* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) +- [github.com/ncruces/go-sqlite3](https://github.com/ncruces/go-sqlite3) +- [github.com/gomarkdown/markdown](https://github.com/gomarkdown/markdown) This can be installed using the following commands from the journal folder: ```bash go get -v ./... -go install -v ./... ``` #### Templates @@ -121,13 +145,11 @@ content. ### Front-end -The front-end source files are in _web/app_ and require some tooling and -dependencies to be installed via `npm` such as gulp and webpack. You can then -use the following build targets: +The front-end source files are intended to be divided into themes within the +_web/themes_ folder. Each theme can include icons and a CSS stylesheet. -* `gulp sass` - Compiles the SASS source into CSS -* `gulp webpack` - Uglifies and minifies the JS -* `gulp` - Watches for changes in SASS/JS files and immediately compiles +A simple, basic and minimalist "default" theme is included, but any other +themes can be built and modified. ### Building/Testing @@ -140,26 +162,3 @@ To test locally, simply use: go test -v ./... ``` -### Building for Lambda - -The application is designed to run as a Lambda connected to an EFS for SQLite -storage. This requires a different method of building to ensure it includes the -appropriate libraries and is built for the correct architecture. - -To build for Lambda, you will need the x86_64-unknown-linux-gnu cross compiler -(if you're on a Mac): - -```bash -brew tap SergioBenitez/osxct -brew install x86_64-unknown-linux-gnu -``` - -To build, simply run: - -```bash -make build -``` - -This will produce a Lambda output: `lambda.zip`, that you can upload onto an -Amazon Linux 2023 (al2023) runtime Lambda, if you configure the appropriate -environment variables. diff --git a/api/README.md b/api/README.md index a699874..a38348d 100644 --- a/api/README.md +++ b/api/README.md @@ -20,7 +20,7 @@ and editing. ### URL Parameters When specified within endpoints, URL parameters are shown within `{}` curly -brackets. URLs are parameterised to include post slugs, as opposed to IDs. +brackets. URLs are parametrised to include post slugs, as opposed to IDs. ## Available Endpoints @@ -30,18 +30,33 @@ brackets. URLs are parameterised to include post slugs, as opposed to IDs. **Successful Response:** `200` -Contains all current post reources in reverse date order. +Contains all current post resources in reverse date order, paginated. The +`links` property containers next and previous links, and `pagination` contains +information on the total posts, pages and posts per page. ```json -[ - { - "id": 1, - "slug": "example-post", - "title": "An Example Post", - "date": "2018-05-18T12:53:22Z", - "content": "
TEST
:gif:id:cE1qRt8nl6Neo:
" - } -] +{ + "links": { + "prev": "/api/v1/post?page=1", + "next": "/api/v1/post?page=3" + }, + "pagination": { + "current_page": 2, + "total_pages": 3, + "posts_per_page": 1, + "total_posts": 3 + }, + "posts": [ + { + "url": "/api/v1/post/example-post", + "title": "An Example Post", + "date": "2018-05-18", + "content": "TEST", + "created_at": "2018-05-18T15:16:17Z", + "updated_at": "2018-05-18T15:16:17Z" + } + ] +} ``` **Error Responses:** *None* @@ -62,11 +77,12 @@ Contains the single post. ```json { - "id": 1, - "slug": "example-post", + "url": "/api/v1/post/example-post", "title": "An Example Post", - "date": "2018-05-18T12:53:22Z", - "content": "TEST
:gif:id:cE1qRt8nl6Neo:
" + "date": "2018-05-18", + "content": "TEST", + "created_at": "2018-05-18T15:16:17Z", + "updated_at": "2018-05-18T15:16:17Z" } ``` @@ -80,7 +96,7 @@ Contains the single post. **Method/URL:** `PUT /api/v1/post` -Post is provided as JSON, ommitting the ID and slug: +Post is provided as JSON, omitting the ID and slug: ```json { @@ -99,11 +115,10 @@ The date can be provided in the following formats: ```json { - "id": 2, - "slug": "a-brand-new-post", + "url": "/api/v1/post/a-brand-new-post", "title": "A Brand New Post", - "date": "2018-06-28T00:42:12Z", - "content": "This is a brand new post, completely.
" + "date": "2018-06-28", + "content": "This is a brand new post, completely." } ``` @@ -114,6 +129,29 @@ provided. -- +### Retrieve a random post + +**Method/URL:** `GET /api/v1/post/random` + +**Successful Response:** `200` + +Contains a randomly selected post. + +```json +{ + "url": "/api/v1/post/example-post", + "title": "An Example Post", + "date": "2018-05-18", + "content": "TEST" +} +``` + +**Error Responses:** + +`404` - No posts exist in the system. + +-- + ### Update a post **Method/URL:** `POST /api/v1/post/{slug}` @@ -127,7 +165,7 @@ Keys to update within the post can be one or more of `date`, `title` and ```json { - "content": "I'm only changing the content this time.
" + "content": "I'm only changing the content this time." } ``` @@ -137,7 +175,7 @@ Or: { "date": "2018-06-21T09:12:00Z", "title": "Even Braver New World", - "content": "I changed a bit more on this attempt.
" + "content": "I changed a bit more on this attempt." } ``` @@ -147,11 +185,10 @@ When updating the post, the slug remains constant, even when the title changes. ```json { - "id": 2, - "slug": "a-brand-new-post", + "url": "/api/v1/post/a-brand-new-post", "title": "Even Braver New World", - "date": "2018-06-21T09:12:00Z", - "content": "I changed a bit more on this attempt.
" + "date": "2018-06-21", + "content": "I changed a bit more on this attempt." } ``` @@ -160,3 +197,52 @@ When updating the post, the slug remains constant, even when the title changes. * `400` - Incorrect parameters supplied - at least one or more of the date, title and content must be provided. * `404` - Post with provided slug could not be found. + +--- + +### Stats + +**Method/URL:** `GET /api/v1/stats` + +**Successful Response:** `200` + +Retrieve statistics, configuration information and visit summaries for the +current installation. + +```json +{ + "posts": { + "count": 3, + "first_post_date": "Monday January 1, 2018" + }, + "configuration": { + "title": "Jamie's Journal", + "description": "A private journal containing Jamie's innermost thoughts", + "theme": "default", + "posts_per_page": 20, + "google_analytics": false, + "create_enabled": true, + "edit_enabled": true + }, + "visits": { + "daily": [ + { + "date": "2025-01-01", + "api_hits": 20, + "web_hits": 30, + "total": 50 + } + ], + "monthly": [ + { + "month": "2025-01", + "api_hits": 200, + "web_hits": 300, + "total": 500 + } + ] + } +} +``` + +**Error Responses:** *None* diff --git a/api/openapi.yml b/api/openapi.yml new file mode 100644 index 0000000..59f52e0 --- /dev/null +++ b/api/openapi.yml @@ -0,0 +1,282 @@ +openapi: '3.0.3' +info: + title: Journal + version: '0.9.6' +paths: + /api/v1/post: + get: + description: Retrieve all posts + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Posts' + put: + description: Create a post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PostRequest' + responses: + '200': + description: Post created + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + description: Incorrect parameters supplied - the date, title and content must be provided. + /api/v1/post/random: + get: + description: Retrieve a random post + responses: + '200': + description: Contains the single post. + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '404': + description: No posts available + /api/v1/post/{slug}: + get: + description: Retrieve a single post + parameters: + - name: slug + in: path + description: Slug of post + required: true + schema: + type: string + responses: + '200': + description: Contains the single post. + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '404': + description: Post with provided slug could not be found. + post: + description: Update a post + parameters: + - name: slug + in: path + description: Slug of post + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PostRequestPartial' + responses: + '200': + description: Post updated + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + description: Incorrect parameters supplied - the date, title and content must be provided. + '404': + description: Post with provided slug could not be found. + /api/v1/stats: + get: + description: Retrieve statistics about the journal system + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Stats' +components: + schemas: + Post: + required: + - url + - title + - date + - content + type: object + properties: + url: + type: string + example: /api/v1/post/my-journal-post + title: + type: string + example: 'My Journal Post' + date: + type: string + format: date + example: '2018-06-21' + content: + type: string + example: 'Some post content.' + created_at: + type: string + format: date-time + example: '2018-06-21T09:12:00Z' + updated_at: + type: string + format: date-time + example: '2018-06-21T09:12:00Z' + Posts: + required: + - links + - pagination + - posts + type: object + properties: + links: + type: object + properties: + next: + type: string + prev: + type: string + pagination: + type: object + properties: + current_page: + type: integer + example: 1 + total_pages: + type: integer + example: 16 + posts_per_page: + type: integer + example: 20 + total_posts: + type: integer + example: 307 + posts: + type: array + items: + $ref: '#/components/schemas/Post' + PostRequest: + required: + - title + - date + - content + type: object + properties: + title: + type: string + example: 'My Journal Post' + date: + type: string + format: date + example: '2018-06-21' + content: + type: string + example: 'Some post content.' + PostRequestPartial: + type: object + properties: + title: + type: string + example: 'My Journal Post' + date: + type: string + format: date + example: '2018-06-21' + content: + type: string + example: 'Some post content.' + Stats: + required: + - posts + - configuration + - visits + type: object + properties: + posts: + type: object + required: + - count + properties: + count: + type: integer + example: 42 + first_post_date: + type: string + format: date + example: '2018-01-01' + configuration: + type: object + required: + - title + - description + - theme + - posts_per_page + - google_analytics + - create_enabled + - edit_enabled + properties: + title: + type: string + example: "Jamie's Journal" + description: + type: string + example: "A private journal containing Jamie's innermost thoughts" + theme: + type: string + example: 'default' + posts_per_page: + type: integer + example: 20 + google_analytics: + type: boolean + create_enabled: + type: boolean + edit_enabled: + type: boolean + visits: + type: object + required: + - daily + - monthly + properties: + daily: + type: array + description: Daily visit statistics for the last 14 days + items: + type: object + properties: + date: + type: string + format: date + example: '2023-12-25' + api_hits: + type: integer + example: 15 + web_hits: + type: integer + example: 42 + total: + type: integer + example: 57 + monthly: + type: array + description: Monthly visit statistics for all available months + items: + type: object + properties: + month: + type: string + example: '2023-12' + api_hits: + type: integer + example: 450 + web_hits: + type: integer + example: 1250 + total: + type: integer + example: 1700 diff --git a/cmd/journal/main.go b/cmd/journal/main.go new file mode 100644 index 0000000..6878100 --- /dev/null +++ b/cmd/journal/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "crypto/tls" + "fmt" + "log" + "net/http" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/internal/app/router" + "github.com/jamiefdhurst/journal/pkg/database" + "github.com/jamiefdhurst/journal/pkg/markdown" +) + +var container *app.Container = &app.Container{} + +func config() app.Configuration { + configuration := app.DefaultConfiguration() + app.ApplyEnvConfiguration(&configuration) + + if !configuration.EnableCreate { + log.Println("Post creating is disabled...") + } + if !configuration.EnableEdit { + log.Println("Post editing is disabled...") + } + + return configuration +} + +func bootstrap(c *app.Container, db app.Database, mp app.MarkdownProcessor) (func(), error) { + c.Db = db + c.MarkdownProcessor = mp + + log.Printf("Loading DB from %s...\n", c.Configuration.DatabasePath) + if err := c.Db.Connect(c.Configuration.DatabasePath); err != nil { + return nil, fmt.Errorf("database connect: %w", err) + } + + js := model.Journals{Container: c} + if err := js.CreateTable(); err != nil { + return nil, fmt.Errorf("journal table: %w", err) + } + ms := model.Migrations{Container: c} + if err := ms.CreateTable(); err != nil { + return nil, fmt.Errorf("migrations table: %w", err) + } + vs := model.Visits{Container: c} + if err := vs.CreateTable(); err != nil { + return nil, fmt.Errorf("visits table: %w", err) + } + + if err := ms.MigrateHTMLToMarkdown(); err != nil { + return nil, fmt.Errorf("html to markdown migration: %w", err) + } + if err := ms.MigrateRandomSlugs(); err != nil { + return nil, fmt.Errorf("random slug migration: %w", err) + } + if err := ms.MigrateAddTimestamps(); err != nil { + return nil, fmt.Errorf("add timestamps migration: %w", err) + } + + return func() { c.Db.Close() }, nil +} + +func main() { + const version = "0.9.6" + fmt.Printf("Journal v%s\n-------------------\n\n", version) + + configuration := config() + container.Configuration = configuration + container.Version = version + + closeFunc, err := bootstrap(container, &database.Sqlite{}, &markdown.Markdown{}) + if err != nil { + log.Fatalf("Setup failed: %s\n", err) + } + defer closeFunc() + + rtr := router.NewRouter(container) + + var protocols http.Protocols + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ + Addr: ":" + configuration.Port, + Handler: rtr, + Protocols: &protocols, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + }, + } + log.Printf("Ready and listening on port %s...\n", configuration.Port) + if configuration.SSLCertificate == "" { + err = rtr.StartAndServe(server) + } else { + log.Printf("Certificate: %s\n", configuration.SSLCertificate) + log.Printf("Certificate Key: %s\n", configuration.SSLKey) + log.Println("Serving with SSL enabled...") + err = rtr.StartAndServeTLS(server, configuration.SSLCertificate, configuration.SSLKey) + } + + if err != nil { + log.Fatal("Error reported: ", err) + } +} diff --git a/cmd/journal/main_test.go b/cmd/journal/main_test.go new file mode 100644 index 0000000..784e798 --- /dev/null +++ b/cmd/journal/main_test.go @@ -0,0 +1,491 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" + "github.com/jamiefdhurst/journal/internal/app/router" + "github.com/jamiefdhurst/journal/pkg/database" + "github.com/jamiefdhurst/journal/pkg/markdown" + pkgrouter "github.com/jamiefdhurst/journal/pkg/router" +) + +var ( + rtr *pkgrouter.Router + server *httptest.Server +) + +func init() { + os.Chdir("../..") //nolint:errcheck + container = &app.Container{Configuration: app.DefaultConfiguration()} + rtr = router.NewRouter(container) + server = httptest.NewServer(rtr) + + log.Println("Serving on " + server.URL) +} + +func fixtures(t *testing.T) { + db := &database.Sqlite{} + if err := db.Connect("test/data/test.db"); err != nil { + t.Error("Could not open test database for writing...") + } + + // Setup container + container.Db = db + + js := model.Journals{Container: container} + ms := model.Migrations{Container: container} + vs := model.Visits{Container: container} + db.Exec("DROP TABLE journal") + db.Exec("DROP TABLE migration") + db.Exec("DROP TABLE visit") + js.CreateTable() + ms.CreateTable() + vs.CreateTable() + ms.MigrateAddTimestamps() + + // Set up data + db.Exec("INSERT INTO journal (slug, title, content, date) VALUES (?, ?, ?, ?)", "test", "Test", "Test!
", "2018-01-01") + db.Exec("INSERT INTO journal (slug, title, content, date) VALUES (?, ?, ?, ?)", "test-2", "Another Test", "Test again!
", "2018-02-01") + db.Exec("INSERT INTO journal (slug, title, content, date) VALUES (?, ?, ?, ?)", "test-3", "A Final Test", "Test finally!
", "2018-03-01") +} + +func TestConfig(t *testing.T) { + os.Setenv("J_TITLE", "A Test Title") + + configuration := config() + + if configuration.Title != "A Test Title" { + t.Error("Expected title to be set through environment") + } + if configuration.Port != "3000" { + t.Errorf("Expected default port to be set, got %s", configuration.Port) + } + if configuration.Theme != "default" { + t.Errorf("Expected default theme to be set, got %s", configuration.Theme) + } +} + +func TestBootstrap(t *testing.T) { + container.Configuration.DatabasePath = "test/data/test.db" + closeFunc, err := bootstrap(container, &database.Sqlite{}, &markdown.Markdown{}) + if err != nil { + t.Fatalf("Expected bootstrap to succeed, got: %s", err) + } + closeFunc() +} + +func TestBootstrap_ConnectError(t *testing.T) { + c := &app.Container{Configuration: app.DefaultConfiguration()} + c.Configuration.DatabasePath = "/nonexistent/path/test.db" + _, err := bootstrap(c, &database.Sqlite{}, &markdown.Markdown{}) + if err == nil { + t.Error("Expected bootstrap to fail with invalid database path") + } +} + +func TestApiv1List(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("GET", server.URL+"/api/v1/post", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + expected := `{"links":{},"pagination":{"current_page":1,"total_pages":1,"posts_per_page":20,"total_posts":3},"posts":[{"url":"/api/v1/post/test-3","title":"A Final Test","date":"2018-03-01","content":"Test finally!
"},{"url":"/api/v1/post/test-2","title":"Another Test","date":"2018-02-01","content":"Test again!
"},{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01","content":"Test!
"}]}` + + // Use contains to get rid of any extra whitespace that we can discount + if !strings.Contains(string(body[:]), expected) { + t.Errorf("Expected:\n\t%s\nGot:\n\t%s", expected, string(body[:])) + } + +} + +func TestApiV1Single(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("GET", server.URL+"/api/v1/post/test", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + expected := `{"url":"/api/v1/post/test","title":"Test","date":"2018-01-01","content":"Test!
"}` + + // Use contains to get rid of any extra whitespace that we can discount + if !strings.Contains(string(body[:]), expected) { + t.Errorf("Expected:\n\t%s\nGot:\n\t%s", expected, string(body[:])) + } +} + +func TestApiV1Single_NotFound(t *testing.T) { + fixtures(t) + + // Try a post that doesn't exist, but is not the new random endpoint + request, _ := http.NewRequest("GET", server.URL+"/api/v1/post/nonexistent", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 404 { + t.Error("Expected 404 status code") + } +} + +func TestApiV1Random(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("GET", server.URL+"/api/v1/post/random", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + // Make sure we got a valid JSON response + if !strings.Contains(string(body[:]), "\"url\":") || !strings.Contains(string(body[:]), "\"title\":") { + t.Errorf("Expected JSON with id and slug, got: %s", string(body[:])) + } +} + +func TestApiV1Create(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Test 4","date":"2018-06-01T00:00:00Z","content":"Test 4!
"}`)) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 201 { + t.Error("Expected 201 status code") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + bodyStr := string(body[:]) + + // Check for expected fields + expectedFields := []string{`"url":"/api/v1/post/test-4"`, `"title":"Test 4"`, `"date":"2018-06-01"`, `"content":"Test 4!
"`, `"created_at"`, `"updated_at"`} + for _, field := range expectedFields { + if !strings.Contains(bodyStr, field) { + t.Errorf("Expected response to contain %s\nGot:\n\t%s", field, bodyStr) + } + } +} + +func TestApiV1Create_InvalidRequest(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 400 { + t.Error("Expected 400 status code") + } +} + +func TestApiV1Create_MissingData(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Test 4"}`)) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 400 { + t.Error("Expected 400 status code") + } +} + +func TestApiV1Create_RepeatTitles(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Repeated","date":"2018-02-01T00:00:00Z","content":"Repeated content test!
"}`)) + res, err := http.DefaultClient.Do(request) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if res.StatusCode != 201 { + t.Error("Expected 201 status code") + } + + request, _ = http.NewRequest("PUT", server.URL+"/api/v1/post", strings.NewReader(`{"title":"Repeated","date":"2019-02-01T00:00:00Z","content":"Repeated content test again!
"}`)) + res, err = http.DefaultClient.Do(request) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if res.StatusCode != 201 { + t.Error("Expected 201 status code") + } + + request, _ = http.NewRequest("GET", server.URL+"/api/v1/post/repeated-1", nil) + res, err = http.DefaultClient.Do(request) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + bodyStr := string(body[:]) + + // Check for expected fields + expectedFields := []string{`"url":"/api/v1/post/repeated-1"`, `"title":"Repeated"`, `"date":"2019-02-01"`, `"content":"Repeated content test again!
"`, `"created_at"`, `"updated_at"`} + for _, field := range expectedFields { + if !strings.Contains(bodyStr, field) { + t.Errorf("Expected response to contain %s\nGot:\n\t%s", field, bodyStr) + } + } +} + +func TestApiV1Update(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("POST", server.URL+"/api/v1/post/test", strings.NewReader(`{"title":"A different title"}`)) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + bodyStr := string(body[:]) + + // Check for expected fields + expectedFields := []string{`"url":"/api/v1/post/test"`, `"title":"A different title"`, `"date":"2018-01-01"`, `"content":"Test!
"`, `"updated_at"`} + for _, field := range expectedFields { + if !strings.Contains(bodyStr, field) { + t.Errorf("Expected response to contain %s\nGot:\n\t%s", field, bodyStr) + } + } +} + +func TestApiV1Update_NotFound(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("POST", server.URL+"/api/v1/post/random", strings.NewReader(`{"title":"A different title"}`)) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 404 { + t.Error("Expected 404 status code") + } +} + +func TestApiV1Update_InvalidRequest(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("POST", server.URL+"/api/v1/post/test", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 400 { + t.Error("Expected 400 status code") + } +} + +func TestApiV1Stats(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("GET", server.URL+"/api/v1/stats", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + // Check that JSON is returned + if res.Header.Get("Content-Type") != "application/json" { + t.Error("Expected JSON content type") + } + + now := time.Now() + date := now.Format("2006-01-02") + month := now.Format("2006-01") + expected := fmt.Sprintf(`{"posts":{"count":3,"first_post_date":"2018-01-01"},"configuration":{"title":"A Fantastic Journal","description":"A fantastic journal containing some thoughts, ideas and reflections","theme":"default","posts_per_page":20,"google_analytics":false,"create_enabled":true,"edit_enabled":true},"visits":{"daily":[{"date":"%s","api_hits":1,"web_hits":0,"total":1}],"monthly":[{"month":"%s","api_hits":1,"web_hits":0,"total":1}]}}`, date, month) + + // Use contains to get rid of any extra whitespace that we can discount + if !strings.Contains(string(body[:]), expected) { + t.Errorf("Expected:\n\t%s\nGot:\n\t%s", expected, string(body[:])) + } +} + +func TestOpenapi(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("GET", server.URL+"/openapi.yml", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + expected := []string{"openapi: '3.0.3'", "/api/v1/post:", "/api/v1/post/{slug}:", "/api/v1/post/random:", "/api/v1/stats:"} + for _, e := range expected { + if !strings.Contains(string(body[:]), e) { + t.Errorf("Expected:\n\t%s\nGot:\n\t%s", e, string(body[:])) + } + } +} + +func TestWebStats(t *testing.T) { + fixtures(t) + + request, _ := http.NewRequest("GET", server.URL+"/stats", nil) + + res, err := http.DefaultClient.Do(request) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + if res.StatusCode != 200 { + t.Error("Expected 200 status code") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + // Check for stats page elements + if !strings.Contains(string(body[:]), "