Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
compose.yaml
|
||||||
|
docker-compose.yml
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Copy to .env for local development (docker compose reads it automatically).
|
||||||
|
|
||||||
|
# HTTP port the app listens on.
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Display name shown in the UI / health payload.
|
||||||
|
APP_NAME=Miabi Guestbook
|
||||||
|
|
||||||
|
# Version shown in the UI / /api/info. Overrides the build-time version.
|
||||||
|
# Set to different values per app (e.g. 1.0.0 vs 2.0.0) for a canary demo.
|
||||||
|
# APP_VERSION=1.0.0
|
||||||
|
|
||||||
|
# Seed sample entries on first run (only when the table is empty). Set to
|
||||||
|
# false to start with an empty wall.
|
||||||
|
SEED=true
|
||||||
|
|
||||||
|
# === Database ======================
|
||||||
|
# Driver: "postgres" or "sqlite". Leave unset to auto-detect: Postgres when
|
||||||
|
# it is configured (DATABASE_URL or DB_HOST present), otherwise SQLite.
|
||||||
|
# DB_DRIVER=postgres
|
||||||
|
|
||||||
|
# SQLite (zero-config; used when no Postgres is configured). File path, or
|
||||||
|
# ":memory:" for an ephemeral database. The parent directory is created on
|
||||||
|
# startup. Locally this defaults to ./data/guestbook.db; the container image
|
||||||
|
# uses /data/guestbook.db (a volume). On Miabi, mount a volume at /data.
|
||||||
|
# DB_PATH=data/guestbook.db
|
||||||
|
|
||||||
|
# Postgres — when you attach a Miabi managed database, Miabi injects these for
|
||||||
|
# you. DATABASE_URL takes precedence; otherwise the discrete DB_* parts are
|
||||||
|
# assembled into a DSN.
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@db:5432/guestbook?sslmode=disable
|
||||||
|
|
||||||
|
# Discrete parts (used only when DATABASE_URL is empty):
|
||||||
|
# DB_HOST=db
|
||||||
|
# DB_PORT=5432
|
||||||
|
# DB_USER=postgres
|
||||||
|
# DB_PASSWORD=postgres
|
||||||
|
# DB_NAME=guestbook
|
||||||
|
# DB_SSL_MODE=disable
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
- name: Verify modules
|
||||||
|
run: go mod verify
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v9
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
- name: Build
|
||||||
|
run: go build ./...
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set version
|
||||||
|
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# The agent image is published to Docker Hub (primary, matches the platform
|
||||||
|
# default miabi/agent) and mirrored to the GitHub Container Registry (GHCR).
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push guestbook
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ env.VERSION }}
|
||||||
|
tags: |
|
||||||
|
miabi/guestbook:latest
|
||||||
|
miabi/guestbook:${{ env.VERSION }}
|
||||||
|
ghcr.io/miabi-io/guestbook:latest
|
||||||
|
ghcr.io/miabi-io/guestbook:${{ env.VERSION }}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
/app
|
||||||
|
/tmp
|
||||||
|
/data
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
guestbook
|
||||||
|
data
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM golang:1.26-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Version baked into the binary. Build each canary image with e.g.
|
||||||
|
# docker build --build-arg VERSION=1.0.0 -t guestbook:1.0.0 .
|
||||||
|
# docker build --build-arg VERSION=2.0.0 -t guestbook:2.0.0 .
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
# Cache module downloads.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Build a static binary (the web UI is embedded via go:embed, so no assets to copy).
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.version=${VERSION}" -o /out/guestbook .
|
||||||
|
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /out/guestbook /app/guestbook
|
||||||
|
|
||||||
|
# Writable /data volume for the SQLite database, owned by the non-root user.
|
||||||
|
COPY --from=build --chown=nonroot:nonroot /data /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
ENV PORT=8080 \
|
||||||
|
DB_PATH=/data/guestbook.db
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Runs as the non-root user provided by the distroless image.
|
||||||
|
ENTRYPOINT ["/app/guestbook"]
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 Jonas Kaninda
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Miabi Guestbook — example app
|
||||||
|
|
||||||
|
A tiny, self-contained web app used as a **reference for deploying to
|
||||||
|
[Miabi](https://github.com/miabi-io/miabi)**. It is a classic guestbook:
|
||||||
|
visitors leave a name and a message, and the wall shows every signature.
|
||||||
|
|
||||||
|
It deliberately exercises everything a real Miabi deployment touches, in
|
||||||
|
miniature:
|
||||||
|
|
||||||
|
- **[Okapi](https://github.com/jkaninda/okapi) (Go)** REST API, driven by
|
||||||
|
**`okapicli`** — a `server` subcommand with graceful-shutdown lifecycle hooks
|
||||||
|
(the same CLI layout Miabi uses)
|
||||||
|
- **SPA served by `app.WebFS`** — the embedded UI is mounted straight from an
|
||||||
|
`embed.FS`; Okapi serves real assets and falls back to `index.html` for
|
||||||
|
client-side routes (home + an **All signatures** page), while registered API
|
||||||
|
routes keep precedence
|
||||||
|
- **Live updates over SSE** — new/removed signatures stream to every open tab,
|
||||||
|
with a real-time **connected-clients** ("N online") indicator
|
||||||
|
- **Version badge** — the serving build's version is shown in the UI and
|
||||||
|
`/api/info`; ideal for watching a **canary rollout** decide which build answers
|
||||||
|
- **[GORM](https://gorm.io) with PostgreSQL _or_ SQLite** — `uint` PKs and soft
|
||||||
|
deletes, the same conventions Miabi uses. Attach a Miabi managed Postgres, or
|
||||||
|
fall back to a zero-config SQLite file (pure-Go driver, no CGO).
|
||||||
|
- **Structured logging** via [`jkaninda/logger`](https://github.com/jkaninda/logger)
|
||||||
|
- **Env-based config** — auto-detects the driver; reads `DATABASE_URL` / `DB_*`
|
||||||
|
- **`AutoMigrate` on startup** with retry (tolerates the DB coming up a beat later)
|
||||||
|
- **First-run seeder** — populates the wall when the table is empty (`SEED=false` to skip)
|
||||||
|
- **`/healthz` readiness probe** that returns `503` until the database is reachable
|
||||||
|
- **Single static binary** — the whole UI is embedded with `go:embed`, so the
|
||||||
|
image is one distroless layer with **no Node build step**
|
||||||
|
|
||||||
|
## Why this UI approach
|
||||||
|
|
||||||
|
For an example that should deploy in seconds, the frontend is a **single
|
||||||
|
embedded `index.html`** — modern CSS (glassmorphism cards, gradient mesh, dark
|
||||||
|
theme) plus a little vanilla JS calling the JSON API. No CDN, no bundler, no
|
||||||
|
`node_modules`. It looks polished, loads instantly, works offline/air-gapped,
|
||||||
|
and keeps the container tiny.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|----------|----------------------|------------------------------------|
|
||||||
|
| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version` |
|
||||||
|
| `GET` | `/api/info` | App name, serving `version`, connected-client count |
|
||||||
|
| `GET` | `/api/entries` | List entries + total; paginated via `?limit=&offset=` |
|
||||||
|
| `POST` | `/api/entries` | Create `{ "name", "message" }` (broadcast live) |
|
||||||
|
| `DELETE` | `/api/entries/{id}` | Delete an entry (broadcast live) |
|
||||||
|
| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `deleted`, `presence` events |
|
||||||
|
| `GET` | `/` · `/all` | The web UI (home · all-signatures page) |
|
||||||
|
|
||||||
|
### Live updates (SSE)
|
||||||
|
|
||||||
|
`/api/stream` is a Server-Sent Events endpoint. On connect the client gets a
|
||||||
|
`welcome` event (serving version + online count); thereafter the server pushes
|
||||||
|
`created` / `deleted` events as the wall changes and `presence` events whenever
|
||||||
|
the number of connected clients changes. The UI uses these to update the wall in
|
||||||
|
real time and show a live **“N online”** indicator — no polling.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------------|------------------|-------------------------------------------|
|
||||||
|
| `PORT` | `8080` | HTTP listen port |
|
||||||
|
| `APP_NAME` | `Miabi Guestbook`| Display name in the UI / health payload |
|
||||||
|
| `APP_VERSION` | build version | Overrides the version shown in the UI / `/api/info`. Defaults to the value baked at build time (`-ldflags "-X main.version=…"`, or the `VERSION` build-arg). |
|
||||||
|
| `SEED` | `true` | Seed sample entries on first run (empty table only) |
|
||||||
|
| `DB_DRIVER` | _(auto)_ | `postgres` or `sqlite`. Unset → auto: Postgres when configured, else SQLite. |
|
||||||
|
| `DB_PATH` | `data/guestbook.db` (`/data/guestbook.db` in the image) | SQLite file path (or `:memory:`). Its parent dir is created on startup. The container stores it on the `/data` volume. |
|
||||||
|
| `DATABASE_URL` | — | Full Postgres DSN (URL or key=value). Preferred — set for you when you attach a Miabi **managed database**. Takes precedence over `DB_*`. |
|
||||||
|
| `DB_HOST` | `localhost` | Database host (used when `DATABASE_URL` is empty) |
|
||||||
|
| `DB_PORT` | `5432` | Database port |
|
||||||
|
| `DB_USER` | `postgres` | Database user |
|
||||||
|
| `DB_PASSWORD` | `postgres` | Database password |
|
||||||
|
| `DB_NAME` | `guestbook` | Database name |
|
||||||
|
| `DB_SSL_MODE` | `disable` | Postgres `sslmode` |
|
||||||
|
|
||||||
|
> **On Miabi:** attach a managed database to the app and Miabi injects these
|
||||||
|
> variables automatically — `DATABASE_URL` for the full DSN, plus the discrete
|
||||||
|
> `DB_*` parts. You don't set them by hand.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
With Docker Compose (Postgres + app):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
# open http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run the binary directly. With nothing configured it uses a zero-config
|
||||||
|
SQLite file, so this just works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . # runs the default "server" command
|
||||||
|
go run . server -p 9000 # okapicli flags: choose the port
|
||||||
|
go run . --help # list commands and flags
|
||||||
|
```
|
||||||
|
|
||||||
|
To point it at your own Postgres, set `DATABASE_URL` (or the `DB_*` vars) first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # edit DATABASE_URL
|
||||||
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build the image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --build-arg VERSION=1.0.0 -t miabi-guestbook:1.0.0 .
|
||||||
|
docker run -p 8080:8080 -e DATABASE_URL=postgres://... miabi/guestbook:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The version can also be overridden at runtime with `-e APP_VERSION=…`.
|
||||||
|
|
||||||
|
## Canary deployment demo
|
||||||
|
|
||||||
|
The version badge makes this app a ready-made **canary** example. Build two
|
||||||
|
otherwise-identical images and let Miabi split traffic between them — the badge
|
||||||
|
(and `/api/info`) tells you which build served each request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --build-arg VERSION=1.0.0 -t miabi-guestbook:1.0.0 .
|
||||||
|
docker build --build-arg VERSION=2.0.0 -t miabi/guestbook:2.0.0 .
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Deploy `1.0.0` as the app; attach a **shared** managed Postgres.
|
||||||
|
2. Roll out `2.0.0` as a **canary** with a small weight (e.g. 10%).
|
||||||
|
3. Refresh the page a few times: ~1 in 10 loads shows the `v2.0.0` badge (a
|
||||||
|
different colour), the rest `v1.0.0`. Because both versions share the same
|
||||||
|
database, signatures created on either build appear on both — and stream
|
||||||
|
live to every open tab via SSE.
|
||||||
|
4. Shift the weight up and promote `2.0.0` when you're happy.
|
||||||
|
|
||||||
|
> No build args? Set `APP_VERSION=1.0.0` / `APP_VERSION=2.0.0` in each app's
|
||||||
|
> environment instead — same effect.
|
||||||
|
|
||||||
|
## Deploy on Miabi
|
||||||
|
|
||||||
|
1. **Create a database** — provision a PostgreSQL database in your workspace.
|
||||||
|
2. **Create an application** — deploy this repo (Git build) or the image above.
|
||||||
|
3. **Attach the database** — Miabi injects the connection as `DATABASE_URL`.
|
||||||
|
4. **Set the health check** to `GET /healthz` and the port to `8080`.
|
||||||
|
5. **Connect a domain** — Miabi issues automatic SSL and routes traffic.
|
||||||
|
|
||||||
|
The app migrates its schema on first boot, so there is no separate release step.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// Event is a server-to-client message pushed over SSE.
|
||||||
|
//
|
||||||
|
// Type selects the shape of the payload:
|
||||||
|
// - "created" → Entry is set (a new signature)
|
||||||
|
// - "deleted" → ID is set (a removed signature)
|
||||||
|
// - "presence" → Online is set (connected-client count changed)
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Entry *Entry `json:"entry,omitempty"`
|
||||||
|
ID uint `json:"id,omitempty"`
|
||||||
|
Online int `json:"online,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broker is a tiny in-process pub/sub hub. Each connected SSE client holds one
|
||||||
|
// subscription; handlers Publish entry events, and the broker itself emits a
|
||||||
|
// "presence" event whenever the number of connected clients changes.
|
||||||
|
type Broker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
subs map[chan Event]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBroker creates an empty Broker.
|
||||||
|
func NewBroker() *Broker {
|
||||||
|
return &Broker{subs: make(map[chan Event]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a new client and returns its event channel. It also
|
||||||
|
// broadcasts the updated presence count to everyone.
|
||||||
|
func (b *Broker) Subscribe() chan Event {
|
||||||
|
ch := make(chan Event, 16)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.subs[ch] = struct{}{}
|
||||||
|
n := len(b.subs)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
b.emit(Event{Type: "presence", Online: n})
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a client, closes its channel, and broadcasts the updated
|
||||||
|
// presence count.
|
||||||
|
func (b *Broker) Unsubscribe(ch chan Event) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if _, ok := b.subs[ch]; !ok {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(b.subs, ch)
|
||||||
|
close(ch)
|
||||||
|
n := len(b.subs)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
b.emit(Event{Type: "presence", Online: n})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish broadcasts an event to every connected client.
|
||||||
|
func (b *Broker) Publish(ev Event) { b.emit(ev) }
|
||||||
|
|
||||||
|
// Online returns the current number of connected clients.
|
||||||
|
func (b *Broker) Online() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
return len(b.subs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit fans an event out to all subscribers. Sends are non-blocking: a slow
|
||||||
|
// client that has filled its buffer simply misses the event rather than
|
||||||
|
// stalling the broker.
|
||||||
|
func (b *Broker) emit(ev Event) {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
for ch := range b.subs {
|
||||||
|
select {
|
||||||
|
case ch <- ev:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Local development: Postgres + the app. Mirrors what Miabi wires up for you
|
||||||
|
# (a managed Postgres attached to the app via DATABASE_URL).
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: guestbook
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
PORT: 8080
|
||||||
|
DATABASE_URL: postgres://postgres:postgres@db:5432/guestbook?sslmode=disable
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
goutils "github.com/jkaninda/go-utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the runtime configuration, read entirely from environment
|
||||||
|
// variables — the twelve-factor way, and exactly how Miabi injects settings
|
||||||
|
// into a deployed application (Postgres connection details land here when you
|
||||||
|
// attach a managed database to the app).
|
||||||
|
type Config struct {
|
||||||
|
Port int
|
||||||
|
DBDriver string // "postgres" or "sqlite"
|
||||||
|
DatabaseDSN string
|
||||||
|
AppName string
|
||||||
|
Version string
|
||||||
|
Seed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig builds the Config from the environment.
|
||||||
|
func LoadConfig() (Config, error) {
|
||||||
|
driver, dsn := resolveDatabase()
|
||||||
|
return Config{
|
||||||
|
Port: goutils.EnvInt("PORT", 8080),
|
||||||
|
AppName: goutils.Env("APP_NAME", "Miabi Guestbook"),
|
||||||
|
Version: resolveVersion(),
|
||||||
|
Seed: goutils.Env("SEED", "true") == "true",
|
||||||
|
DBDriver: driver,
|
||||||
|
DatabaseDSN: dsn,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDatabase picks the database driver and DSN.
|
||||||
|
//
|
||||||
|
// The driver comes from DB_DRIVER (postgres|sqlite). When DB_DRIVER is unset it
|
||||||
|
// is inferred: if Postgres is configured (DATABASE_URL or DB_HOST is present)
|
||||||
|
// Postgres is used; otherwise the app falls back to a zero-config SQLite file —
|
||||||
|
// handy for local dev and demos with nothing else to set up. On Miabi, attach a
|
||||||
|
// managed Postgres and it is selected automatically.
|
||||||
|
func resolveDatabase() (driver, dsn string) {
|
||||||
|
driver = strings.ToLower(goutils.Env("DB_DRIVER", ""))
|
||||||
|
if driver == "" {
|
||||||
|
if goutils.Env("DATABASE_URL", "") != "" || goutils.Env("DB_HOST", "") != "" {
|
||||||
|
driver = "postgres"
|
||||||
|
} else {
|
||||||
|
driver = "sqlite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch driver {
|
||||||
|
case "sqlite", "sqlite3":
|
||||||
|
// A file path; use ":memory:" for an ephemeral in-memory database. The
|
||||||
|
// parent directory is created on startup. In the container this defaults
|
||||||
|
// to /data (a volume); see DB_PATH in the Dockerfile.
|
||||||
|
return "sqlite", goutils.Env("DB_PATH", "data/guestbook.db")
|
||||||
|
default:
|
||||||
|
return "postgres", postgresDSN()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postgresDSN resolves the Postgres connection, following Miabi's convention:
|
||||||
|
//
|
||||||
|
// - DATABASE_URL — a full DSN (URL or key=value). Preferred, and what Miabi's
|
||||||
|
// managed databases inject. When set, it is used verbatim.
|
||||||
|
// - Discrete DB_* variables — assembled into a key=value DSN otherwise.
|
||||||
|
func postgresDSN() string {
|
||||||
|
if url := goutils.Env("DATABASE_URL", ""); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"host=%s user=%s password=%s dbname=%s port=%d sslmode=%s",
|
||||||
|
goutils.Env("DB_HOST", "localhost"),
|
||||||
|
goutils.Env("DB_USER", "postgres"),
|
||||||
|
goutils.Env("DB_PASSWORD", "postgres"),
|
||||||
|
goutils.Env("DB_NAME", "guestbook"),
|
||||||
|
goutils.EnvInt("DB_PORT", 5432),
|
||||||
|
goutils.Env("DB_SSL_MODE", "disable"),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module github.com/miabi-io/guestbook
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/jkaninda/go-utils v0.1.4
|
||||||
|
github.com/jkaninda/logger v0.0.5
|
||||||
|
github.com/jkaninda/okapi v0.8.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/getkin/kin-openapi v0.140.0 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
|
github.com/oasdiff/yaml v0.1.0 // indirect
|
||||||
|
github.com/oasdiff/yaml3 v0.0.13 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
golang.org/x/crypto v0.37.0 // indirect
|
||||||
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
|
golang.org/x/text v0.24.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
|
||||||
|
github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/jkaninda/go-utils v0.1.4 h1:ZdNlI+yLWc4/S0qKcCNQIPj+6lHSdJcGaxtRADAifAU=
|
||||||
|
github.com/jkaninda/go-utils v0.1.4/go.mod h1:Aa54jEAcDykc3CnOdreqZG80UfSZOvrYecyusu+oPb4=
|
||||||
|
github.com/jkaninda/logger v0.0.5 h1:fTHKgDsHtuN8rkSBvwe6QStfi8yIdm8O3r0g99dRDTY=
|
||||||
|
github.com/jkaninda/logger v0.0.5/go.mod h1:ZUXJ2BdxDPG6e8t6mbKhc2ZFaFi2Iuy/4ukquFrPkFE=
|
||||||
|
github.com/jkaninda/okapi v0.8.0 h1:Ch/ZX9CCsiXlQqv4Ebm3/soGFwbklUAnKjtJ95XvSJo=
|
||||||
|
github.com/jkaninda/okapi v0.8.0/go.mod h1:TzBj5VE2rzr0H/6pANG2+ISbe6u46XfLzUxHu62BPv8=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||||
|
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg=
|
||||||
|
github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0=
|
||||||
|
github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg=
|
||||||
|
github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jkaninda/okapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler holds the dependencies for the HTTP layer.
|
||||||
|
type Handler struct {
|
||||||
|
store *Store
|
||||||
|
broker *Broker
|
||||||
|
appName string
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// createEntryRequest is the POST /api/entries body.
|
||||||
|
type createEntryRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxNameLen = 60
|
||||||
|
maxMessageLen = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// Health reports liveness/readiness. Returns 503 when the database is
|
||||||
|
// unreachable so Miabi's health checks hold traffic until the DB is up.
|
||||||
|
func (h *Handler) Health(c *okapi.Context) error {
|
||||||
|
if err := h.store.Ping(c.Request().Context()); err != nil {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, okapi.M{
|
||||||
|
"status": "unavailable",
|
||||||
|
"db": "down",
|
||||||
|
"version": h.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.OK(okapi.M{"status": "ok", "db": "up", "app": h.appName, "version": h.version})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns app metadata for the UI: name, the serving version (useful for
|
||||||
|
// spotting which build answered during a canary rollout), and how many clients
|
||||||
|
// are currently connected.
|
||||||
|
func (h *Handler) Info(c *okapi.Context) error {
|
||||||
|
return c.OK(okapi.M{
|
||||||
|
"app": h.appName,
|
||||||
|
"version": h.version,
|
||||||
|
"online": h.broker.Online(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEntries returns guestbook entries, newest first, with simple pagination.
|
||||||
|
// Query params: limit (default 100, max 200) and offset (default 0). The home
|
||||||
|
// page requests a small limit; the "All signatures" page pages through them.
|
||||||
|
func (h *Handler) ListEntries(c *okapi.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
limit := atoiDefault(c.Query("limit"), 100)
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
offset := atoiDefault(c.Query("offset"), 0)
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := h.store.List(ctx, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load entries"})
|
||||||
|
}
|
||||||
|
count, _ := h.store.Count(ctx)
|
||||||
|
return c.OK(okapi.M{
|
||||||
|
"entries": entries,
|
||||||
|
"total": count,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoiDefault parses s as an int, returning def when empty or invalid.
|
||||||
|
func atoiDefault(s string, def int) int {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEntry adds a new signature to the wall and broadcasts it live.
|
||||||
|
func (h *Handler) CreateEntry(c *okapi.Context) error {
|
||||||
|
var req createEntryRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
message := strings.TrimSpace(req.Message)
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = "Anonymous"
|
||||||
|
}
|
||||||
|
if len(name) > maxNameLen {
|
||||||
|
name = name[:maxNameLen]
|
||||||
|
}
|
||||||
|
if message == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, okapi.M{"error": "message is required"})
|
||||||
|
}
|
||||||
|
if len(message) > maxMessageLen {
|
||||||
|
return c.JSON(http.StatusBadRequest, okapi.M{
|
||||||
|
"error": "message is too long (max 500 characters)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := h.store.Create(c.Request().Context(), name, message)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save entry"})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.broker.Publish(Event{Type: "created", Entry: &entry})
|
||||||
|
return c.Created(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEntry removes an entry by id and broadcasts the removal live.
|
||||||
|
func (h *Handler) DeleteEntry(c *okapi.Context) error {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid id"})
|
||||||
|
}
|
||||||
|
ok, err := h.store.Delete(c.Request().Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not delete entry"})
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return c.JSON(http.StatusNotFound, okapi.M{"error": "entry not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.broker.Publish(Event{Type: "deleted", ID: uint(id)})
|
||||||
|
return c.NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream is the SSE endpoint. Each client subscribes to the broker and receives
|
||||||
|
// live "created" / "deleted" / "presence" events. A "welcome" event is sent
|
||||||
|
// first so the client immediately knows the serving version and online count.
|
||||||
|
func (h *Handler) Stream(c *okapi.Context) error {
|
||||||
|
ctx, cancel := context.WithCancel(c.Request().Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
subscription := h.broker.Subscribe()
|
||||||
|
defer h.broker.Unsubscribe(subscription)
|
||||||
|
|
||||||
|
msgs := make(chan okapi.Message, 32)
|
||||||
|
go func() {
|
||||||
|
defer close(msgs)
|
||||||
|
|
||||||
|
welcome := okapi.Message{Event: "welcome", Data: okapi.M{
|
||||||
|
"version": h.version,
|
||||||
|
"online": h.broker.Online(),
|
||||||
|
}}
|
||||||
|
select {
|
||||||
|
case msgs <- welcome:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case ev, ok := <-subscription:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case msgs <- okapi.Message{Event: ev.Type, Data: ev}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return c.SSEStreamWithOptions(ctx, msgs, &okapi.StreamOptions{
|
||||||
|
Serializer: &okapi.JSONSerializer{},
|
||||||
|
PingInterval: 25 * time.Second,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Command guestbook is a tiny, self-contained web application used to
|
||||||
|
// demonstrate deploying to Miabi: an Okapi (Go) API + embedded SPA, backed by
|
||||||
|
// a Postgres (or SQLite) database via GORM.
|
||||||
|
//
|
||||||
|
// It is a classic "guestbook" — visitors leave a name and a message, and the
|
||||||
|
// wall shows every signature. Everything a real Miabi deployment exercises is
|
||||||
|
// here in miniature: env-based config (DATABASE_URL), an attached managed
|
||||||
|
// database, a GORM AutoMigrate on startup, a first-run seeder, a /healthz
|
||||||
|
// readiness probe, and a single static binary that ships the whole frontend
|
||||||
|
// embedded — no Node build step.
|
||||||
|
//
|
||||||
|
// The HTTP server is driven by okapicli (a "server" subcommand with graceful
|
||||||
|
// lifecycle hooks), and the embedded UI is served with app.WebFS.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jkaninda/logger"
|
||||||
|
"github.com/jkaninda/okapi"
|
||||||
|
"github.com/jkaninda/okapi/okapicli"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:web
|
||||||
|
var webFS embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("invalid configuration", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
store, err := NewStore(cfg.DBDriver, cfg.DatabaseDSN)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("failed to open database", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrateWithRetry(store, 30, 2*time.Second); err != nil {
|
||||||
|
logger.Fatal("failed to run migrations", "error", err)
|
||||||
|
}
|
||||||
|
logger.Info("schema ready", "app", cfg.AppName)
|
||||||
|
|
||||||
|
// Seed sample data on first run (empty table only).
|
||||||
|
if cfg.Seed {
|
||||||
|
if err := Seed(ctx, store, cfg.DBDriver); err != nil {
|
||||||
|
logger.Fatal("failed to seed database", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broker := NewBroker()
|
||||||
|
h := &Handler{store: store, broker: broker, appName: cfg.AppName, version: cfg.Version}
|
||||||
|
|
||||||
|
app := okapi.New(okapi.WithPort(cfg.Port))
|
||||||
|
app.WithDebug()
|
||||||
|
app.Use(okapi.LoggerMiddleware, okapi.RequestID())
|
||||||
|
|
||||||
|
app.Get("/healthz", h.Health)
|
||||||
|
api := app.Group("/api")
|
||||||
|
api.Get("/info", h.Info)
|
||||||
|
api.Get("/entries", h.ListEntries)
|
||||||
|
api.Post("/entries", h.CreateEntry)
|
||||||
|
api.Delete("/entries/{id:int}", h.DeleteEntry)
|
||||||
|
api.Get("/stream", h.Stream) // Server-Sent Events: live entries + presence
|
||||||
|
|
||||||
|
// Serve the embedded single-page UI. Okapi serves real files directly and
|
||||||
|
// falls back to index.html for any other path so a client-side router can
|
||||||
|
// take over.
|
||||||
|
app.WebFS("/", webFS, okapi.WebConfig{Root: "web"})
|
||||||
|
|
||||||
|
cli := okapicli.New(app, "guestbook")
|
||||||
|
cli.Command("server", "Start the Guestbook HTTP server", func(cmd *okapicli.Command) error {
|
||||||
|
cmd.Okapi().WithPort(cmd.GetInt("port"))
|
||||||
|
return cmd.CLI().RunServer(&okapicli.RunOptions{
|
||||||
|
ShutdownTimeout: 10 * time.Second,
|
||||||
|
OnStarted: func() {
|
||||||
|
logger.Info("server started", "app", cfg.AppName, "version", cfg.Version, "port", cmd.GetInt("port"))
|
||||||
|
},
|
||||||
|
OnShutdown: func() {
|
||||||
|
logger.Info("shutting down")
|
||||||
|
_ = store.Close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}).Int("port", "p", cfg.Port, "HTTP server port")
|
||||||
|
cli.DefaultCommand("server")
|
||||||
|
|
||||||
|
if err := cli.Execute(); err != nil {
|
||||||
|
logger.Fatal("server error", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateWithRetry(store *Store, attempts int, wait time.Duration) error {
|
||||||
|
var err error
|
||||||
|
for i := 0; i < attempts; i++ {
|
||||||
|
if err = store.Migrate(); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger.Warn("waiting for database", "attempt", i+1, "of", attempts, "error", err)
|
||||||
|
time.Sleep(wait)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jkaninda/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dbDisplayName maps a driver to a friendly name for the UI/seed copy.
|
||||||
|
func dbDisplayName(driver string) string {
|
||||||
|
switch driver {
|
||||||
|
case "sqlite", "sqlite3":
|
||||||
|
return "SQLite"
|
||||||
|
case "postgres":
|
||||||
|
return "PostgreSQL"
|
||||||
|
default:
|
||||||
|
return driver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedEntries(driver string) []struct{ Name, Message string } {
|
||||||
|
return []struct{ Name, Message string }{
|
||||||
|
{"Miabi", fmt.Sprintf("Welcome to your guestbook 👋 This app is running on Miabi and stores signatures in %s.", dbDisplayName(driver))},
|
||||||
|
|
||||||
|
{"Ada", "Connected my GitHub repository, pushed code, and Miabi handled the build and deployment automatically."},
|
||||||
|
{"Grace", "Automatic SSL certificates in a few clicks. No reverse proxy configuration required."},
|
||||||
|
{"Linus", "Deployed a Docker image directly from a registry and it was online in under a minute."},
|
||||||
|
{"Jude", "The built-in monitoring dashboard makes it easy to keep an eye on application health."},
|
||||||
|
{"Dennis", "Provisioned a PostgreSQL database and attached it to my application without touching Docker commands."},
|
||||||
|
{"Ken", "I like that every feature is available through the API as well as the web interface."},
|
||||||
|
{"Barbara", "GitOps support means my infrastructure and applications stay reproducible."},
|
||||||
|
{"Tim", "Multi-tenant workspaces make shared hosting on Docker feel safe and organized."},
|
||||||
|
{"James", "Imported an existing container into Miabi and started managing it from the dashboard."},
|
||||||
|
{"Josh", "Backups, databases, deployments, and domains all managed from a single place."},
|
||||||
|
{"Brendan", "The built-in container registry integrates nicely with CI/CD pipelines."},
|
||||||
|
{"Sophie", "Added a custom domain and Miabi configured routing and TLS automatically."},
|
||||||
|
{"Alex", "Deploying from a marketplace template was much faster than setting everything up manually."},
|
||||||
|
{"Nadia", "Workspace isolation makes it easy to host applications for multiple teams on one server."},
|
||||||
|
{"Chris", "Connected a remote node through the agent and expanded capacity without opening inbound ports."},
|
||||||
|
{"Anonymous", "Finally, an open-source self-hosted PaaS that feels simple without sacrificing power."},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Seed(ctx context.Context, store *Store, driver string) error {
|
||||||
|
count, err := store.Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
logger.Info("seed skipped, entries already present", "count", count)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := seedEntries(driver)
|
||||||
|
for _, e := range entries {
|
||||||
|
if _, err := store.Create(ctx, e.Name, e.Message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Info("database seeded", "entries", len(entries))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite" // pure-Go SQLite (no CGO — keeps the static build)
|
||||||
|
"github.com/jkaninda/logger"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is a single signature on the guestbook wall.
|
||||||
|
type Entry struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"type:varchar(60);not null" json:"name"`
|
||||||
|
Message string `gorm:"type:varchar(500);not null" json:"message"`
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"-"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore opens a GORM connection using the given driver ("postgres" or
|
||||||
|
// "sqlite") and DSN. It does not block on the database being reachable — call
|
||||||
|
// Ping (e.g. from the health check) for that.
|
||||||
|
func NewStore(driver, dsn string) (*Store, error) {
|
||||||
|
var dialector gorm.Dialector
|
||||||
|
switch driver {
|
||||||
|
case "sqlite", "sqlite3":
|
||||||
|
// Create the parent directory (e.g. ./data or /data) so the SQLite
|
||||||
|
// file can be written on first run.
|
||||||
|
if err := ensureSQLiteDir(dsn); err != nil {
|
||||||
|
return nil, fmt.Errorf("create sqlite directory: %w", err)
|
||||||
|
}
|
||||||
|
dialector = sqlite.Open(dsn)
|
||||||
|
case "postgres", "":
|
||||||
|
dialector = postgres.Open(dsn)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported DB_DRIVER %q (want postgres or sqlite)", driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(dialector, &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logger.Info("database connected", "driver", driver)
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSQLiteDir creates the directory that will hold the SQLite file. It is a
|
||||||
|
// no-op for in-memory databases and for a bare filename in the current dir.
|
||||||
|
func ensureSQLiteDir(dsn string) error {
|
||||||
|
// Strip a "file:" prefix and any "?query" so we get the plain file path.
|
||||||
|
path := strings.TrimPrefix(dsn, "file:")
|
||||||
|
if i := strings.IndexByte(path, '?'); i >= 0 {
|
||||||
|
path = path[:i]
|
||||||
|
}
|
||||||
|
if path == "" || strings.Contains(path, ":memory:") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if dir == "" || dir == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.MkdirAll(dir, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the underlying connection pool.
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
sqlDB, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping verifies the database is reachable. Used by the health endpoint so
|
||||||
|
// Miabi can gate traffic until the app is actually ready.
|
||||||
|
func (s *Store) Ping(ctx context.Context) error {
|
||||||
|
sqlDB, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.PingContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Migrate() error {
|
||||||
|
return s.db.AutoMigrate(&Entry{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns entries newest first, with limit/offset pagination.
|
||||||
|
func (s *Store) List(ctx context.Context, limit, offset int) ([]Entry, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
var entries []Entry
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Offset(offset).
|
||||||
|
Find(&entries).Error
|
||||||
|
return entries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new entry and returns it with its generated id/timestamp.
|
||||||
|
func (s *Store) Create(ctx context.Context, name, message string) (Entry, error) {
|
||||||
|
e := Entry{Name: name, Message: message}
|
||||||
|
err := s.db.WithContext(ctx).Create(&e).Error
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete soft-deletes an entry by id. Returns true when a row was affected.
|
||||||
|
func (s *Store) Delete(ctx context.Context, id uint64) (bool, error) {
|
||||||
|
res := s.db.WithContext(ctx).Delete(&Entry{}, id)
|
||||||
|
return res.RowsAffected > 0, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the total number of (non-deleted) entries.
|
||||||
|
func (s *Store) Count(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := s.db.WithContext(ctx).Model(&Entry{}).Count(&n).Error
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import goutils "github.com/jkaninda/go-utils"
|
||||||
|
|
||||||
|
// version is the build version. Set it at build time with:
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X main.version=1.0.0"
|
||||||
|
//
|
||||||
|
// It is overridable at runtime with APP_VERSION — handy for the canary demo,
|
||||||
|
// where you run two otherwise-identical builds (e.g. 1.0.0 and 2.0.0) and watch
|
||||||
|
// which one serves each request.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
// resolveVersion returns APP_VERSION when set, otherwise the build version.
|
||||||
|
func resolveVersion() string {
|
||||||
|
return goutils.Env("APP_VERSION", version)
|
||||||
|
}
|
||||||
+427
@@ -0,0 +1,427 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Miabi Guestbook</title>
|
||||||
|
<meta name="description" content="A tiny Okapi + Postgres example app, deployed on Miabi." />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b0a12;
|
||||||
|
--bg-2: #12101d;
|
||||||
|
--card: rgba(255, 255, 255, 0.04);
|
||||||
|
--card-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--text: #ece9f5;
|
||||||
|
--muted: #9b95b3;
|
||||||
|
--accent: #8b5cf6;
|
||||||
|
--accent-2: #a855f7;
|
||||||
|
--danger: #f43f5e;
|
||||||
|
--online: #22c55e;
|
||||||
|
--radius: 16px;
|
||||||
|
--shadow: 0 20px 60px -20px rgba(139, 92, 246, 0.35);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 500px at 15% -10%, rgba(139, 92, 246, 0.18), transparent 60%),
|
||||||
|
radial-gradient(700px 500px at 110% 10%, rgba(168, 85, 247, 0.14), transparent 55%),
|
||||||
|
linear-gradient(180deg, var(--bg), var(--bg-2));
|
||||||
|
background-attachment: fixed;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 780px; margin: 0 auto; padding: 32px 20px 80px; }
|
||||||
|
|
||||||
|
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 26px; }
|
||||||
|
.logo {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 14px; border-radius: 999px;
|
||||||
|
background: var(--card); border: 1px solid var(--card-border);
|
||||||
|
font-size: 12.5px; font-weight: 600; letter-spacing: 0.02em; color: var(--muted);
|
||||||
|
}
|
||||||
|
.logo .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
|
||||||
|
box-shadow: 0 0 12px 2px var(--accent); }
|
||||||
|
.status { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.ver {
|
||||||
|
font-size: 12.5px; font-weight: 700; color: #fff; padding: 5px 12px; border-radius: 999px;
|
||||||
|
background: var(--accent); letter-spacing: 0.02em; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.online {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted);
|
||||||
|
padding: 5px 12px; border-radius: 999px; background: var(--card); border: 1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--online); position: relative; }
|
||||||
|
.pulse::after {
|
||||||
|
content: ""; position: absolute; inset: -4px; border-radius: 50%;
|
||||||
|
background: var(--online); opacity: .5; animation: ping 1.6s cubic-bezier(0,0,.2,1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes ping { 0% { transform: scale(1); opacity: .5; } 75%, 100% { transform: scale(2.4); opacity: 0; } }
|
||||||
|
|
||||||
|
header { text-align: center; margin-bottom: 26px; }
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(30px, 6vw, 46px); line-height: 1.05; margin: 0 0 12px;
|
||||||
|
font-weight: 800; letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
h1 .grad {
|
||||||
|
background: linear-gradient(120deg, var(--accent), var(--accent-2) 60%, #f0abfc);
|
||||||
|
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||||
|
}
|
||||||
|
.sub { color: var(--muted); font-size: 16px; max-width: 520px; margin: 0 auto; line-height: 1.6; }
|
||||||
|
|
||||||
|
.tabs { display: inline-flex; gap: 4px; margin-top: 22px; padding: 4px; border-radius: 12px;
|
||||||
|
background: var(--card); border: 1px solid var(--card-border); }
|
||||||
|
.tabs a {
|
||||||
|
text-decoration: none; color: var(--muted); font-size: 14px; font-weight: 600;
|
||||||
|
padding: 8px 18px; border-radius: 9px; transition: color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.tabs a.active { color: #fff; background: linear-gradient(120deg, var(--accent), var(--accent-2)); }
|
||||||
|
.tabs a:not(.active):hover { color: var(--text); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--card-border);
|
||||||
|
border-radius: var(--radius); backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
form.compose { padding: 20px; box-shadow: var(--shadow); margin-bottom: 14px; }
|
||||||
|
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
input, textarea {
|
||||||
|
width: 100%; font: inherit; color: var(--text);
|
||||||
|
background: rgba(0, 0, 0, 0.25); border: 1px solid var(--card-border);
|
||||||
|
border-radius: 12px; padding: 13px 15px; outline: none; resize: vertical;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22);
|
||||||
|
}
|
||||||
|
.name-field { flex: 1 1 200px; }
|
||||||
|
textarea { margin-top: 12px; min-height: 84px; }
|
||||||
|
.actions { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 12px; }
|
||||||
|
.count { font-size: 12.5px; color: var(--muted); }
|
||||||
|
button.primary {
|
||||||
|
font: inherit; font-weight: 650; color: white; cursor: pointer;
|
||||||
|
border: 0; border-radius: 12px; padding: 12px 22px;
|
||||||
|
background: linear-gradient(120deg, var(--accent), var(--accent-2));
|
||||||
|
box-shadow: 0 8px 20px -6px rgba(139, 92, 246, 0.6);
|
||||||
|
transition: transform .12s, box-shadow .12s, opacity .12s;
|
||||||
|
}
|
||||||
|
button.primary:hover { transform: translateY(-1px); }
|
||||||
|
button.primary:disabled { opacity: .55; cursor: not-allowed; transform: none; }
|
||||||
|
|
||||||
|
.meta { display: flex; align-items: center; justify-content: space-between; margin: 26px 4px 14px; }
|
||||||
|
.meta h2 { font-size: 15px; font-weight: 600; color: var(--muted); margin: 0; letter-spacing: 0.02em; }
|
||||||
|
.meta .total {
|
||||||
|
font-size: 12px; color: var(--muted); background: var(--card);
|
||||||
|
border: 1px solid var(--card-border); padding: 4px 11px; border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.entries { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
|
||||||
|
li.entry {
|
||||||
|
padding: 18px 20px; position: relative;
|
||||||
|
animation: pop .25s ease both;
|
||||||
|
}
|
||||||
|
@keyframes pop { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||||
|
.entry .msg { font-size: 15.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.entry .foot { display: flex; align-items: center; gap: 10px; margin-top: 12px; font-size: 13px; }
|
||||||
|
.avatar {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%; flex: 0 0 auto;
|
||||||
|
display: grid; place-items: center; font-size: 11px; font-weight: 700; color: white;
|
||||||
|
}
|
||||||
|
.entry .who { font-weight: 600; }
|
||||||
|
.entry .when { color: var(--muted); }
|
||||||
|
.del {
|
||||||
|
position: absolute; top: 14px; right: 14px; cursor: pointer;
|
||||||
|
background: transparent; border: 0; color: var(--muted); font-size: 17px; line-height: 1;
|
||||||
|
padding: 4px 7px; border-radius: 8px; opacity: 0; transition: opacity .15s, color .15s, background .15s;
|
||||||
|
}
|
||||||
|
li.entry:hover .del { opacity: 1; }
|
||||||
|
.del:hover { color: var(--danger); background: rgba(244, 63, 94, 0.12); }
|
||||||
|
|
||||||
|
.empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; }
|
||||||
|
|
||||||
|
.viewall { text-align: center; margin-top: 20px; }
|
||||||
|
.viewall a { color: var(--accent-2); text-decoration: none; font-weight: 600; font-size: 14px; }
|
||||||
|
.viewall a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 22px; }
|
||||||
|
.pager button {
|
||||||
|
font: inherit; font-size: 14px; font-weight: 600; color: var(--text); cursor: pointer;
|
||||||
|
background: var(--card); border: 1px solid var(--card-border); border-radius: 10px; padding: 9px 16px;
|
||||||
|
transition: border-color .15s, opacity .15s;
|
||||||
|
}
|
||||||
|
.pager button:hover:not(:disabled) { border-color: var(--accent); }
|
||||||
|
.pager button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.pager .info { font-size: 13px; color: var(--muted); min-width: 96px; text-align: center; }
|
||||||
|
|
||||||
|
footer { text-align: center; margin-top: 46px; color: var(--muted); font-size: 13px; line-height: 1.8; }
|
||||||
|
footer a { color: var(--accent-2); text-decoration: none; }
|
||||||
|
footer a:hover { text-decoration: underline; }
|
||||||
|
.stack { display: inline-flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
|
||||||
|
.pill { background: var(--card); border: 1px solid var(--card-border); padding: 3px 10px; border-radius: 999px; }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%) translateY(20px);
|
||||||
|
background: var(--danger); color: white; padding: 11px 18px; border-radius: 12px;
|
||||||
|
font-size: 14px; font-weight: 500; opacity: 0; pointer-events: none;
|
||||||
|
transition: opacity .2s, transform .2s; box-shadow: 0 12px 30px -8px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="logo"><span class="dot"></span> Deployed on Miabi</div>
|
||||||
|
<div class="status">
|
||||||
|
<span class="ver" id="ver" title="Serving version">v…</span>
|
||||||
|
<span class="online"><span class="pulse"></span> <span id="online">0</span> online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>The <span class="grad">Guestbook</span></h1>
|
||||||
|
<p class="sub">A tiny example app — Okapi (Go) API + database, in a single container. Sign the wall and watch it update live.</p>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/" data-nav>Home</a>
|
||||||
|
<a href="/all" data-nav>All signatures</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Home view -->
|
||||||
|
<section id="view-home">
|
||||||
|
<form class="compose card" id="form">
|
||||||
|
<div class="row">
|
||||||
|
<input class="name-field" id="name" type="text" placeholder="Your name (optional)" maxlength="60" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<textarea id="message" placeholder="Write something nice…" maxlength="500" required></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<span class="count" id="count">0 / 500</span>
|
||||||
|
<button class="primary" id="submit" type="submit">Sign the wall</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<h2>Recent signatures</h2>
|
||||||
|
<span class="total" id="total">—</span>
|
||||||
|
</div>
|
||||||
|
<ul class="entries" id="entries"></ul>
|
||||||
|
<div class="loading" id="loading">Loading…</div>
|
||||||
|
<div class="viewall"><a href="/all" data-nav>View all signatures →</a></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- All signatures view (paginated) -->
|
||||||
|
<section id="view-all" hidden>
|
||||||
|
<div class="meta">
|
||||||
|
<h2>All signatures</h2>
|
||||||
|
<span class="total" id="allTotal">—</span>
|
||||||
|
</div>
|
||||||
|
<ul class="entries" id="allEntries"></ul>
|
||||||
|
<div class="loading" id="allLoading">Loading…</div>
|
||||||
|
<div class="pager">
|
||||||
|
<button id="prev">← Newer</button>
|
||||||
|
<span class="info" id="pageInfo"></span>
|
||||||
|
<button id="next">Older →</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="stack">
|
||||||
|
<span class="pill">Okapi</span>
|
||||||
|
<span class="pill">Go</span>
|
||||||
|
<span class="pill">SSE live</span>
|
||||||
|
<span class="pill">Single container</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px">
|
||||||
|
The open-source PaaS for Docker · <a href="https://github.com/miabi-io/miabi" target="_blank" rel="noopener">github.com/miabi-io/miabi</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const PAGE_SIZE = 20; // "All signatures" page size
|
||||||
|
const HOME_SIZE = 8; // recent signatures shown on the home page
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
info: () => fetch('/api/info').then(r => r.json()),
|
||||||
|
list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()),
|
||||||
|
create: (body) => fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
||||||
|
remove: (id) => fetch('/api/entries/' + id, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = 0; // total signatures (kept in sync via SSE)
|
||||||
|
let allOffset = 0; // current offset on the "All" page
|
||||||
|
|
||||||
|
const COLORS = ['#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#14b8a6', '#a855f7'];
|
||||||
|
const colorFor = (s) => { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) >>> 0; return COLORS[h % COLORS.length]; };
|
||||||
|
const initials = (s) => s.trim().split(/\s+/).slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || '?';
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
|
||||||
|
function timeAgo(iso) {
|
||||||
|
const d = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||||
|
if (d < 60) return 'just now';
|
||||||
|
if (d < 3600) return Math.floor(d / 60) + 'm ago';
|
||||||
|
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
|
||||||
|
if (d < 604800) return Math.floor(d / 86400) + 'd ago';
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastTimer;
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('toast'); t.textContent = msg; t.classList.add('show');
|
||||||
|
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHTML(e) {
|
||||||
|
return `<li class="entry card" data-id="${e.id}">
|
||||||
|
<button class="del" title="Delete" aria-label="Delete">✕</button>
|
||||||
|
<div class="msg">${esc(e.message)}</div>
|
||||||
|
<div class="foot">
|
||||||
|
<span class="avatar" style="background:${colorFor(e.name)}">${esc(initials(e.name))}</span>
|
||||||
|
<span class="who">${esc(e.name)}</span>
|
||||||
|
<span class="when">· ${timeAgo(e.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(ul, loading, entries, emptyMsg) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
if (!entries.length) { ul.innerHTML = `<div class="empty">${emptyMsg}</div>`; return; }
|
||||||
|
ul.innerHTML = entries.map(cardHTML).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTotals(n) {
|
||||||
|
total = n;
|
||||||
|
const label = (x) => x === 1 ? '1 signature' : x + ' signatures';
|
||||||
|
$('total').textContent = label(n);
|
||||||
|
$('allTotal').textContent = label(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- version + presence -------------------------------------------------
|
||||||
|
function setVersion(v) {
|
||||||
|
const el = $('ver'); el.textContent = 'v' + v;
|
||||||
|
// Colour the badge deterministically from the version so two canary
|
||||||
|
// builds (e.g. 1.0.0 vs 2.0.0) are instantly distinguishable.
|
||||||
|
let h = 0; for (const c of String(v)) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||||
|
el.style.background = `hsl(${h % 360} 70% 52%)`;
|
||||||
|
}
|
||||||
|
function setOnline(n) { $('online').textContent = n; }
|
||||||
|
|
||||||
|
// ---- views --------------------------------------------------------------
|
||||||
|
async function loadHome() {
|
||||||
|
try {
|
||||||
|
const data = await api.list(HOME_SIZE, 0);
|
||||||
|
setTotals(data.total || 0);
|
||||||
|
renderList($('entries'), $('loading'), data.entries || [], 'No signatures yet — be the first ✍️');
|
||||||
|
} catch { $('loading').textContent = 'Could not reach the API.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
$('allLoading').style.display = 'block';
|
||||||
|
try {
|
||||||
|
const data = await api.list(PAGE_SIZE, allOffset);
|
||||||
|
setTotals(data.total || 0);
|
||||||
|
renderList($('allEntries'), $('allLoading'), data.entries || [], 'No signatures yet ✍️');
|
||||||
|
const from = total === 0 ? 0 : allOffset + 1;
|
||||||
|
const to = Math.min(allOffset + PAGE_SIZE, total);
|
||||||
|
$('pageInfo').textContent = `${from}–${to} of ${total}`;
|
||||||
|
$('prev').disabled = allOffset === 0;
|
||||||
|
$('next').disabled = allOffset + PAGE_SIZE >= total;
|
||||||
|
} catch { $('allLoading').textContent = 'Could not reach the API.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentView() { return location.pathname.replace(/\/+$/, '') === '/all' ? 'all' : 'home'; }
|
||||||
|
|
||||||
|
function route() {
|
||||||
|
const view = currentView();
|
||||||
|
$('view-home').hidden = view !== 'home';
|
||||||
|
$('view-all').hidden = view !== 'all';
|
||||||
|
document.querySelectorAll('.tabs a').forEach((a) => {
|
||||||
|
const target = a.getAttribute('href') === '/all' ? 'all' : 'home';
|
||||||
|
a.classList.toggle('active', target === view);
|
||||||
|
});
|
||||||
|
if (view === 'all') { allOffset = 0; loadAll(); } else { loadHome(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept in-app nav links for client-side routing (WebFS serves
|
||||||
|
// index.html for /all, so a full navigation would also work).
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const a = e.target.closest('a[data-nav]');
|
||||||
|
if (!a) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const href = a.getAttribute('href');
|
||||||
|
if (href !== location.pathname) history.pushState({}, '', href);
|
||||||
|
route();
|
||||||
|
});
|
||||||
|
window.addEventListener('popstate', route);
|
||||||
|
|
||||||
|
// ---- live SSE events ----------------------------------------------------
|
||||||
|
function onCreated(entry) {
|
||||||
|
setTotals(total + 1);
|
||||||
|
if (currentView() === 'home') {
|
||||||
|
const ul = $('entries');
|
||||||
|
if (ul.querySelector('.empty')) ul.innerHTML = '';
|
||||||
|
ul.insertAdjacentHTML('afterbegin', cardHTML(entry));
|
||||||
|
while (ul.children.length > HOME_SIZE) ul.lastElementChild.remove();
|
||||||
|
} else if (allOffset === 0) {
|
||||||
|
loadAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onDeleted(id) {
|
||||||
|
setTotals(Math.max(0, total - 1));
|
||||||
|
document.querySelectorAll(`.entry[data-id="${id}"]`).forEach((el) => el.remove());
|
||||||
|
if (currentView() === 'all') loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
const es = new EventSource('/api/stream');
|
||||||
|
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); });
|
||||||
|
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); });
|
||||||
|
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry) onCreated(d.entry); });
|
||||||
|
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
|
||||||
|
// EventSource auto-reconnects on error; nothing to do here.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- compose + delete ---------------------------------------------------
|
||||||
|
$('message').addEventListener('input', (e) => { $('count').textContent = e.target.value.length + ' / 500'; });
|
||||||
|
|
||||||
|
$('form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const message = $('message').value.trim();
|
||||||
|
if (!message) return;
|
||||||
|
const btn = $('submit'); btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await api.create({ name: $('name').value, message });
|
||||||
|
if (!res.ok) { const j = await res.json().catch(() => ({})); toast(j.error || 'Something went wrong'); return; }
|
||||||
|
$('message').value = ''; $('count').textContent = '0 / 500';
|
||||||
|
// The new entry arrives via SSE ("created"), so no manual reload.
|
||||||
|
} catch { toast('Network error'); }
|
||||||
|
finally { btn.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('.del');
|
||||||
|
if (!btn) return;
|
||||||
|
const li = btn.closest('.entry');
|
||||||
|
const id = li.dataset.id;
|
||||||
|
li.style.opacity = '.4';
|
||||||
|
const res = await api.remove(id);
|
||||||
|
if (!(res.ok || res.status === 404)) { li.style.opacity = '1'; toast('Could not delete'); }
|
||||||
|
// Removal is reflected via SSE ("deleted").
|
||||||
|
});
|
||||||
|
|
||||||
|
$('prev').addEventListener('click', () => { allOffset = Math.max(0, allOffset - PAGE_SIZE); loadAll(); });
|
||||||
|
$('next').addEventListener('click', () => { allOffset += PAGE_SIZE; loadAll(); });
|
||||||
|
|
||||||
|
// ---- boot ---------------------------------------------------------------
|
||||||
|
api.info().then((d) => { setVersion(d.version); setOnline(d.online); }).catch(() => {});
|
||||||
|
route();
|
||||||
|
connectSSE();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user