Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/gofmt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ permissions:

jobs:
gofmt:
if: github.event.action == 'labeled' && contains(github.event.pull_request.labels.*.name, 'style')
strategy:
matrix:
go-version: [1.24.5]
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
test:
if: (github.event_name == 'push') ||
(github.event_name == 'pull_request' && (github.event.pull_request.draft == false ||
(github.event.action == 'labeled' && github.event.label.name == 'testing')))
(github.event.action == 'labeled' && contains(github.event.pull_request.labels.*.name, 'testing'))))
runs-on: ubuntu-latest
strategy:
matrix:
Expand Down
6 changes: 3 additions & 3 deletions caddy/Caddyfile.local
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
header {
Access-Control-Allow-Origin "http://localhost:5173" # allows the Vue app (running on localhost:5173) to make requests.
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" # Specifies which methods are allowed.
Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, Content-Type, User-Agent, If-None-Match" # allows the custom headers needed by the API.
Access-Control-Expose-Headers "ETag"
Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match" # allows the custom headers needed by the API.
Access-Control-Expose-Headers "ETag, X-Request-ID"
}

# This handles the browser's "preflight" OPTIONS request.
Expand All @@ -30,7 +30,7 @@
# Reflect the Origin back so it's always allowed
header Access-Control-Allow-Origin "{http.request.header.Origin}"
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, Content-Type, User-Agent, If-None-Match"
header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match"
header Access-Control-Max-Age "86400"
respond 204
}
Expand Down
12 changes: 9 additions & 3 deletions caddy/Caddyfile.prod
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ oullin.io {
header {
Access-Control-Allow-Origin "https://oullin.io"
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, Content-Type, User-Agent, If-None-Match"
Access-Control-Expose-Headers "ETag"
Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match"
Access-Control-Expose-Headers "ETag, X-Request-ID"
}

@preflight {
Expand All @@ -47,7 +47,7 @@ oullin.io {
# Reflect the Origin back so it's always allowed
header Access-Control-Allow-Origin "{http.request.header.Origin}"
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, Content-Type, User-Agent, If-None-Match"
header Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match"
header Access-Control-Max-Age "86400"
respond 204
}
Expand All @@ -57,6 +57,12 @@ oullin.io {
header_up X-API-Username {http.request.header.X-API-Username}
header_up X-API-Key {http.request.header.X-API-Key}
header_up X-API-Signature {http.request.header.X-API-Signature}
header_up X-API-Timestamp {http.request.header.X-API-Timestamp}
header_up X-API-Nonce {http.request.header.X-API-Nonce}
header_up X-Request-ID {http.request.header.X-Request-ID}
header_up Content-Type {http.request.header.Content-Type}
header_up User-Agent {http.request.header.User-Agent}
header_up If-None-Match {http.request.header.If-None-Match}

transport http {
dial_timeout 10s
Expand Down
5 changes: 3 additions & 2 deletions database/repository/queries/posts_filters.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package queries

import (
"github.com/oullin/pkg"
"github.com/oullin/pkg/portal"

"strings"
)

Expand Down Expand Up @@ -34,7 +35,7 @@ func (f PostFilters) GetTag() string {
}

func (f PostFilters) sanitiseString(seed string) string {
str := pkg.MakeStringable(seed)
str := portal.MakeStringable(seed)

return strings.TrimSpace(str.ToLower())
}
4 changes: 2 additions & 2 deletions database/seeder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import (
"github.com/oullin/database/seeder/seeds"
"github.com/oullin/metal/env"
"github.com/oullin/metal/kernel"
"github.com/oullin/pkg"
"github.com/oullin/pkg/cli"
"github.com/oullin/pkg/portal"
)

var environment *env.Environment

func init() {
secrets := kernel.Ignite("./.env", pkg.GetDefaultValidator())
secrets := kernel.Ignite("./.env", portal.GetDefaultValidator())

environment = secrets
}
Expand Down
9 changes: 7 additions & 2 deletions database/seeder/seeds/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package seeds

import (
"fmt"

"github.com/google/uuid"
"github.com/oullin/database"
"github.com/oullin/pkg"
"github.com/oullin/pkg/gorm"
"github.com/oullin/pkg/portal"

"strings"
"time"
)
Expand All @@ -21,7 +23,10 @@ func MakeUsersSeed(db *database.Connection) *UsersSeed {
}

func (s UsersSeed) Create(attrs database.UsersAttrs) (database.User, error) {
pass, _ := pkg.MakePassword("password")
pass, err := portal.MakePassword("password")
if err != nil {
return database.User{}, fmt.Errorf("failed to generate seed password: %w", err)
}

fake := database.User{
UUID: uuid.NewString(),
Expand Down
97 changes: 97 additions & 0 deletions docs/middleware/postman/token-local.postman_collection.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"info": {
"name": "Oullin API — Token Auth (Local)",
"description": "Postman collection for calling protected endpoints locally via Caddy (http://localhost:8080). It uses a collection-level pre-request script to compute the required X-API-* headers and signature.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_postman_id": "5d8d0a1a-7a1e-4f6e-b2f2-9d2a0a8f0c11"
},
"item": [
{
"name": "List posts (POST /posts)",
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" }
],
"url": {
"raw": "{{baseUrl}}/posts",
"host": ["{{baseUrl}}"],
"path": ["posts"]
},
"body": {
"mode": "raw",
"raw": "{}"
},
"description": "List or filter posts. Requires signed headers generated by the collection pre-request script."
},
"response": []
},
{
"name": "Show post (GET /posts/hello)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/posts/hello",
"host": ["{{baseUrl}}"],
"path": ["posts", "hello"]
},
"description": "Fetch a single post by slug (example: hello)."
},
"response": []
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
"(function() {",
" // CryptoJS available in Postman sandbox",
" const crypto = require('crypto-js');",
" function sha256Hex(str) { return crypto.SHA256(str || '').toString(crypto.enc.Hex); }",
" function sortedQuery(u) {",
" const url = new URL(u);",
" const keys = Array.from(url.searchParams.keys());",
" keys.sort();",
" const parts = [];",
" for (const k of keys) {",
" const vs = url.searchParams.getAll(k).sort();",
" for (const v of vs) parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));",
" }",
" return parts.join('&');",
" }",
" const method = pm.request.method.toUpperCase();",
" const urlStr = pm.environment.get('baseUrl') + pm.request.url.getPathWithQuery();",
" const urlObj = new URL(urlStr);",
" const path = urlObj.pathname;",
" const query = sortedQuery(urlStr);",
" const username = pm.environment.get('username');",
" const publicKey = pm.environment.get('publicKey');",
" const secretKey = pm.environment.get('secretKey');",
" const timestamp = Math.floor(Date.now() / 1000).toString();",
" const nonce = crypto.lib.WordArray.random(16).toString();",
" const body = (method === 'GET' || method === 'DELETE') ? '' : (pm.request.body && pm.request.body.raw || '');",
" const bodyHash = sha256Hex(body);",
" const canonical = [method, path, query, username, publicKey, timestamp, nonce, bodyHash].join('\n');",
" const signature = crypto.HmacSHA256(canonical, secretKey).toString();",
" pm.request.headers.upsert({ key: 'X-Request-ID', value: pm.environment.get('requestId') || nonce });",
" pm.request.headers.upsert({ key: 'X-API-Username', value: username });",
" pm.request.headers.upsert({ key: 'X-API-Key', value: publicKey });",
" pm.request.headers.upsert({ key: 'X-API-Timestamp', value: timestamp });",
" pm.request.headers.upsert({ key: 'X-API-Nonce', value: nonce });",
" pm.request.headers.upsert({ key: 'X-API-Signature', value: signature });",
"})();"
]
}
}
],
"variable": [
{ "key": "baseUrl", "value": "http://localhost:8080", "type": "string" },
{ "key": "username", "value": "", "type": "string" },
{ "key": "publicKey", "value": "", "type": "string" },
{ "key": "secretKey", "value": "", "type": "string" },
{ "key": "requestId", "value": "", "type": "string" }
]
}
97 changes: 97 additions & 0 deletions docs/middleware/postman/token-prod.postman_collection.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"info": {
"name": "Oullin API — Token Auth (Production)",
"description": "Postman collection for calling protected endpoints in production via Caddy (https://oullin.io/api). It uses a collection-level pre-request script to compute the required X-API-* headers and signature.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_postman_id": "9a1b6e3a-2c24-4e24-8a8c-2f1ca5a7cb21"
},
"item": [
{
"name": "List posts (POST /api/posts)",
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" }
],
"url": {
"raw": "{{baseUrl}}/posts",
"host": ["{{baseUrl}}"],
"path": ["posts"]
},
"body": {
"mode": "raw",
"raw": "{}"
},
"description": "List or filter posts. Requires signed headers generated by the collection pre-request script."
},
"response": []
},
{
"name": "Show post (GET /api/posts/hello)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/posts/hello",
"host": ["{{baseUrl}}"],
"path": ["posts", "hello"]
},
"description": "Fetch a single post by slug (example: hello)."
},
"response": []
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
"(function() {",
" // CryptoJS available in Postman sandbox",
" const crypto = require('crypto-js');",
" function sha256Hex(str) { return crypto.SHA256(str || '').toString(crypto.enc.Hex); }",
" function sortedQuery(u) {",
" const url = new URL(u);",
" const keys = Array.from(url.searchParams.keys());",
" keys.sort();",
" const parts = [];",
" for (const k of keys) {",
" const vs = url.searchParams.getAll(k).sort();",
" for (const v of vs) parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));",
" }",
" return parts.join('&');",
" }",
" const method = pm.request.method.toUpperCase();",
" const urlStr = pm.environment.get('baseUrl') + pm.request.url.getPathWithQuery();",
" const urlObj = new URL(urlStr);",
" const path = urlObj.pathname;",
" const query = sortedQuery(urlStr);",
" const username = pm.environment.get('username');",
" const publicKey = pm.environment.get('publicKey');",
" const secretKey = pm.environment.get('secretKey');",
" const timestamp = Math.floor(Date.now() / 1000).toString();",
" const nonce = crypto.lib.WordArray.random(16).toString();",
" const body = (method === 'GET' || method === 'DELETE') ? '' : (pm.request.body && pm.request.body.raw || '');",
" const bodyHash = sha256Hex(body);",
" const canonical = [method, path, query, username, publicKey, timestamp, nonce, bodyHash].join('\n');",
" const signature = crypto.HmacSHA256(canonical, secretKey).toString();",
" pm.request.headers.upsert({ key: 'X-Request-ID', value: pm.environment.get('requestId') || nonce });",
" pm.request.headers.upsert({ key: 'X-API-Username', value: username });",
" pm.request.headers.upsert({ key: 'X-API-Key', value: publicKey });",
" pm.request.headers.upsert({ key: 'X-API-Timestamp', value: timestamp });",
" pm.request.headers.upsert({ key: 'X-API-Nonce', value: nonce });",
" pm.request.headers.upsert({ key: 'X-API-Signature', value: signature });",
"})();"
]
}
}
],
"variable": [
{ "key": "baseUrl", "value": "https://oullin.io/api", "type": "string" },
{ "key": "username", "value": "", "type": "string" },
{ "key": "publicKey", "value": "", "type": "string" },
{ "key": "secretKey", "value": "", "type": "string" },
{ "key": "requestId", "value": "", "type": "string" }
]
}
Loading
Loading