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
4 changes: 2 additions & 2 deletions caddy/Caddyfile.local
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
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, 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-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, X-API-Intended-Origin" # allows the custom headers needed by the API.
Access-Control-Expose-Headers "ETag, X-Request-ID"
}

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, X-API-Timestamp, X-API-Nonce, X-Request-ID, 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, X-API-Intended-Origin"
header Access-Control-Max-Age "86400"
respond 204
}
Expand Down
5 changes: 3 additions & 2 deletions caddy/Caddyfile.prod
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ 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, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match"
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, X-API-Intended-Origin"
Access-Control-Expose-Headers "ETag, X-Request-ID"
}

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, X-API-Timestamp, X-API-Nonce, X-Request-ID, 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, X-API-Intended-Origin"
header Access-Control-Max-Age "86400"
respond 204
}
Expand All @@ -63,6 +63,7 @@ oullin.io {
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}
header_up X-API-Intended-Origin {http.request.header.X-API-Intended-Origin}

transport http {
dial_timeout 10s
Expand Down
6 changes: 3 additions & 3 deletions database/attrs.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ type CommentsAttrs struct {
}

type LikesAttrs struct {
UUID string `gorm:"type:uuid;unique;not null"`
PostID uint64 `gorm:"not null;index;uniqueIndex:idx_likes_post_user"`
UserID uint64 `gorm:"not null;index;uniqueIndex:idx_likes_post_user"`
UUID string
PostID uint64
UserID uint64
}

type NewsletterAttrs struct {
Expand Down
3 changes: 2 additions & 1 deletion database/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ package database
import (
"database/sql"
"fmt"
"log/slog"

"github.com/oullin/metal/env"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"log/slog"
)

type Connection struct {
Expand Down
8 changes: 4 additions & 4 deletions database/infra/migrations/000002_api_keys.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ CREATE TABLE api_keys (
CONSTRAINT uq_account_keys UNIQUE (account_name, public_key, secret_key)
);

CREATE INDEX idx_account_name ON api_keys(account_name);
CREATE INDEX idx_public_key ON api_keys(public_key);
CREATE INDEX idx_secret_key ON api_keys(secret_key);
CREATE INDEX idx_deleted_at ON api_keys(deleted_at);
CREATE INDEX idx_api_keys_account_name ON api_keys(account_name);
CREATE INDEX idx_api_keys_public_key ON api_keys(public_key);
CREATE INDEX idx_api_keys_secret_key ON api_keys(secret_key);
CREATE INDEX idx_api_keys_deleted_at ON api_keys(deleted_at);
25 changes: 25 additions & 0 deletions database/infra/migrations/000003_api_keys_signatures.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
CREATE TABLE api_key_signatures (
id BIGSERIAL PRIMARY KEY,
uuid UUID UNIQUE NOT NULL,
api_key_id BIGINT NOT NULL,
signature BYTEA NOT NULL,
max_tries SMALLINT NOT NULL DEFAULT 1 CHECK (max_tries > 0),
current_tries SMALLINT NOT NULL DEFAULT 1 CHECK (current_tries > 0),
expires_at TIMESTAMP DEFAULT NULL,
expired_at TIMESTAMP DEFAULT NULL,
origin TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP DEFAULT NULL,

CONSTRAINT uq_api_key_signatures_signature UNIQUE (signature),
CONSTRAINT api_key_signatures_fk_api_key_id FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE
);

CREATE INDEX idx_api_key_signatures_api_key_id ON api_key_signatures(api_key_id);
CREATE INDEX idx_api_key_signatures_signature_created_at ON api_key_signatures(signature, created_at);
CREATE INDEX idx_api_key_signatures_origin ON api_key_signatures(origin);
CREATE INDEX idx_api_key_signatures_expires_at ON api_key_signatures(expires_at);
CREATE INDEX idx_api_key_signatures_expired_at ON api_key_signatures(expired_at);
CREATE INDEX idx_api_key_signatures_created_at ON api_key_signatures(created_at);
CREATE INDEX idx_api_key_signatures_deleted_at ON api_key_signatures(deleted_at);
26 changes: 23 additions & 3 deletions database/model.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
package database

import (
"gorm.io/gorm"
"slices"
"time"

"gorm.io/gorm"
)

const DriverName = "postgres"

var schemaTables = []string{
"users", "posts", "categories",
"post_categories", "tags", "post_tags",
"post_views", "comments",
"likes", "newsletters", "api_keys",
"post_views", "comments", "likes",
"newsletters", "api_keys", "api_key_signatures",
}

func GetSchemaTables() []string {
Expand All @@ -32,6 +33,25 @@ type APIKey struct {
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`

//Associations
APIKeySignature []APIKeySignatures `gorm:"foreignKey:APIKeyID"`
}

type APIKeySignatures struct {
ID int64 `gorm:"primaryKey"`
UUID string `gorm:"type:uuid;unique;not null"`
APIKeyID int64 `gorm:"not null;index:idx_api_key_signatures_api_key_id"`
MaxTries int `gorm:"not null"`
CurrentTries int `gorm:"not null"`
APIKey APIKey `gorm:"foreignKey:APIKeyID;references:ID;constraint:OnDelete:CASCADE"`
Signature []byte `gorm:"not null;uniqueIndex:uq_api_key_signatures_signature"`
Origin string `gorm:"type:varchar(255);not null;index:idx_api_key_signatures_origin"`
ExpiresAt time.Time `gorm:"index:idx_api_key_signatures_expires_at"`
ExpiredAt *time.Time `gorm:"index:idx_api_key_signatures_expired_at"`
CreatedAt time.Time `gorm:"index:idx_api_key_signatures_created_at"`
UpdatedAt time.Time `gorm:"index:idx_api_key_signatures_updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_api_key_signatures_deleted_at"`
}

type User struct {
Expand Down
141 changes: 140 additions & 1 deletion database/repository/api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ package repository

import (
"fmt"
"strings"
"time"

"github.com/google/uuid"
"github.com/oullin/database"
"github.com/oullin/database/repository/repoentity"
"github.com/oullin/pkg/gorm"
"strings"
"github.com/oullin/pkg/portal"
baseGorm "gorm.io/gorm"
)

type ApiKeys struct {
Expand Down Expand Up @@ -49,3 +54,137 @@ func (a ApiKeys) FindBy(accountName string) *database.APIKey {

return nil
}

func (a ApiKeys) CreateSignatureFor(entity repoentity.APIKeyCreateSignatureFor) (*database.APIKeySignatures, error) {
var item *database.APIKeySignatures

if item = a.FindActiveSignatureFor(entity.Key, entity.Origin); item != nil {
item.CurrentTries++
a.DB.Sql().Save(&item)

return item, nil
}

now := time.Now()
signature := database.APIKeySignatures{
CreatedAt: now,
UpdatedAt: now,
Signature: entity.Seed,
APIKeyID: entity.Key.ID,
ExpiresAt: entity.ExpiresAt,
UUID: uuid.NewString(),
MaxTries: portal.MaxSignaturesTries,
Origin: entity.Origin,
CurrentTries: 1,
}

err := a.DB.Sql().Transaction(func(tx *baseGorm.DB) error {
username := entity.Key.AccountName
if result := a.DB.Sql().Create(&signature); gorm.HasDbIssues(result.Error) {
return fmt.Errorf("issue creating the given api keys signature [%s, %s]: ", username, result.Error)
}

if result := a.DisablePreviousSignatures(entity.Key, signature.UUID, entity.Origin); result != nil {
return fmt.Errorf("issue disabling previous api keys signature [%s, %s]: ", username, result)
}

return nil
})

if err != nil {
return nil, err
}

return &signature, nil
}

func (a ApiKeys) FindActiveSignatureFor(key *database.APIKey, origin string) *database.APIKeySignatures {
var item database.APIKeySignatures

result := a.DB.Sql().
Model(&database.APIKeySignatures{}).
Where("expired_at IS NULL").
Where("api_key_id = ?", key.ID).
Where("origin = ?", origin).
Where("current_tries <= max_tries").
Where("expires_at > ?", time.Now()).
First(&item)

if gorm.HasDbIssues(result.Error) {
return nil
}

if result.RowsAffected > 0 {
return &item
}

return nil
}

func (a ApiKeys) FindSignatureFrom(entity repoentity.FindSignatureFrom) *database.APIKeySignatures {
var item database.APIKeySignatures

result := a.DB.Sql().
Model(&database.APIKeySignatures{}).
Where("api_key_id = ?", entity.Key.ID).
Where("signature = ?", entity.Signature).
Where("expires_at >= ? ", entity.ServerTime).
Where("origin = ?", entity.Origin).
Where("expired_at IS NULL").
Where("current_tries <= max_tries").
First(&item)

if gorm.HasDbIssues(result.Error) {
return nil
}

if result.RowsAffected > 0 {
return &item
}

return nil
}

func (a ApiKeys) DisablePreviousSignatures(key *database.APIKey, signatureUUID, origin string) error {
query := a.DB.Sql().
Model(&database.APIKeySignatures{}).
Where(
a.DB.Sql().
Where("expired_at IS NULL").Or("current_tries > max_tries"),
).
Where("api_key_id = ?", key.ID).
Where(
a.DB.Sql().
Where("origin = ?", origin).
Or("TRIM(origin) = ''"),
).
Where("uuid NOT IN (?)", []string{signatureUUID}).
Update("expired_at", time.Now())

if gorm.HasDbIssues(query.Error) {
return query.Error
}

return nil
}

func (a ApiKeys) IncreaseSignatureTries(signatureUUID string, currentTries int) error {
if currentTries >= portal.MaxSignaturesTries {
return nil
}

response := a.DB.Sql().
Model(&database.APIKeySignatures{}).
Where("uuid = ? AND current_tries < max_tries", signatureUUID).
UpdateColumn("current_tries", baseGorm.Expr("current_tries + 1"))

if gorm.HasDbIssues(response.Error) {
return response.Error
}

if response.RowsAffected == 0 {
return nil
}

return nil
}
1 change: 1 addition & 0 deletions database/repository/posts.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package repository

import (
"fmt"

"github.com/google/uuid"
"github.com/oullin/database"
"github.com/oullin/database/repository/pagination"
Expand Down
21 changes: 21 additions & 0 deletions database/repository/repoentity/api_keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package repoentity

import (
"time"

"github.com/oullin/database"
)

type APIKeyCreateSignatureFor struct {
Key *database.APIKey
ExpiresAt time.Time
Seed []byte
Origin string
}

type FindSignatureFrom struct {
Key *database.APIKey
Signature []byte
Origin string
ServerTime time.Time
}
5 changes: 3 additions & 2 deletions handler/categories.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ package handler

import (
"encoding/json"
"log/slog"
baseHttp "net/http"

"github.com/oullin/database"
"github.com/oullin/database/repository"
"github.com/oullin/database/repository/pagination"
"github.com/oullin/handler/paginate"
"github.com/oullin/handler/payload"
"github.com/oullin/pkg/http"
"log/slog"
baseHttp "net/http"
)

type CategoriesHandler struct {
Expand Down
21 changes: 21 additions & 0 deletions handler/payload/signatures.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package payload

type SignatureRequest struct {
Nonce string `json:"nonce" validate:"required,lowercase,hexadecimal,len=32"`
PublicKey string `json:"public_key" validate:"required,lowercase,min=64,max=67"`
Username string `json:"username" validate:"required,lowercase,min=5"`
Timestamp int64 `json:"timestamp" validate:"required,number,gte=1000000000,min=10"`
Origin string `json:"origin"`
}

type SignatureResponse struct {
Signature string `json:"signature"`
MaxTries int `json:"max_tries"`
Cadence SignatureCadenceResponse `json:"cadence"`
}

type SignatureCadenceResponse struct {
ReceivedAt string `json:"received_at"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
}
Loading
Loading