Initial commit

This commit is contained in:
2023-01-27 22:55:09 +11:00
commit 833f3ea8b2
130 changed files with 5637 additions and 0 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
# flyctl launch added from .gitignore
**/.env
**/public/key.pub
**/.idea/*
**/log/*
# flyctl launch added from .idea/.gitignore
# Default ignored files
.idea/shelf
.idea/workspace.xml
# Editor-based HTTP Client requests
.idea/httpRequests

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
.env
public/key.pub
public/media/**/*
.idea/*
log/*
fly.toml
*.sock
publish.rb
publish_bookmark.rb
node_modules/*
.DS_Store
.bundle/
.env.*
.vimrc.local

1
.rspec Normal file
View File

@@ -0,0 +1 @@
--require spec_helper

2
.tool-versions Normal file
View File

@@ -0,0 +1,2 @@
ruby 3.2.0
postgres 15.1

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM ruby:3.2-alpine
RUN apk update && apk add build-base git sqlite-dev postgresql-dev
WORKDIR /app
COPY Gemfile* ./
RUN gem install bundler:2.2.16 && bundle install --jobs 4 --retry 5
COPY . .

51
Gemfile Normal file
View File

@@ -0,0 +1,51 @@
# frozen_string_literal: true
source "https://rubygems.org"
gem "hanami", "~> 2.0.0"
gem "hanami-router", "~> 2.0.0"
gem "hanami-controller", "~> 2.0.0"
gem "hanami-validations", "~> 2.0.0"
# gem "hanami-assets", github: "hanami/view", branch: "main"
gem "hanami-view", github: "hanami/view", branch: "main"
gem "rom-sql"
gem "pg"
gem "dry-types"
gem "dry-matcher"
gem "dry-monads"
gem "puma"
gem "rake"
gem "slim"
gem "builder"
gem "httparty"
gem "redcarpet"
gem "rexml"
gem "babosa"
group :development, :test do
gem "dotenv"
end
group :cli, :development do
gem "hanami-reloader"
end
group :cli, :development, :test do
gem "hanami-rspec"
end
group :development do
gem "guard-puma", "~> 0.8"
gem "standardrb"
gem "capistrano", "~> 3.17", require: false
end
group :test do
gem "rack-test"
gem "rom-factory"
gem "database_cleaner-sequel"
gem "timecop"
end

326
Gemfile.lock Normal file
View File

@@ -0,0 +1,326 @@
GIT
remote: https://github.com/hanami/view.git
revision: c1a6f60a989f1face809a6d8d61652748aee19a7
branch: main
specs:
hanami-view (2.0.0.alpha8)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0, < 2)
tilt (~> 2.0, >= 2.0.6)
GEM
remote: https://rubygems.org/
specs:
airbrussh (1.4.1)
sshkit (>= 1.6.1, != 1.7.0)
ast (2.4.2)
babosa (2.0.0)
builder (3.2.4)
capistrano (3.17.1)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
sshkit (>= 1.9.0)
coderay (1.1.3)
concurrent-ruby (1.1.10)
database_cleaner-core (2.0.1)
database_cleaner-sequel (2.0.2)
database_cleaner-core (~> 2.0.0)
sequel
diff-lcs (1.5.0)
dotenv (2.8.1)
dry-auto_inject (1.0.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-cli (1.0.0)
dry-configurable (1.0.1)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-core (1.0.0)
concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6)
dry-events (1.0.1)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
dry-files (1.0.1)
dry-inflector (1.0.0)
dry-initializer (3.1.1)
dry-logger (1.0.3)
dry-logic (1.5.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-matcher (1.0.0)
dry-core (~> 1.0, < 2)
dry-monads (1.6.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-monitor (1.0.1)
dry-configurable (~> 1.0, < 2)
dry-core (~> 1.0, < 2)
dry-events (~> 1.0, < 2)
dry-schema (1.13.0)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0, >= 1.0.1)
dry-core (~> 1.0, < 2)
dry-initializer (~> 3.0)
dry-logic (>= 1.5, < 2)
dry-types (>= 1.7, < 2)
zeitwerk (~> 2.6)
dry-struct (1.6.0)
dry-core (~> 1.0, < 2)
dry-types (>= 1.7, < 2)
ice_nine (~> 0.11)
zeitwerk (~> 2.6)
dry-system (1.0.1)
dry-auto_inject (~> 1.0, < 2)
dry-configurable (~> 1.0, < 2)
dry-core (~> 1.0, < 2)
dry-inflector (~> 1.0, < 2)
dry-transformer (1.0.1)
zeitwerk (~> 2.6)
dry-types (1.7.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
dry-inflector (~> 1.0, < 2)
dry-logic (>= 1.4, < 2)
zeitwerk (~> 2.6)
dry-validation (1.10.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
dry-initializer (~> 3.0)
dry-schema (>= 1.12, < 2)
zeitwerk (~> 2.6)
faker (2.23.0)
i18n (>= 1.8.11, < 2)
ffi (1.15.5)
formatador (1.1.0)
guard (2.18.0)
formatador (>= 0.2.4)
listen (>= 2.7, < 4.0)
lumberjack (>= 1.0.12, < 2.0)
nenv (~> 0.1)
notiffany (~> 0.0)
pry (>= 0.13.0)
shellany (~> 0.0)
thor (>= 0.18.1)
guard-compat (1.2.1)
guard-puma (0.8.0)
guard (~> 2.14)
guard-compat (~> 1.2)
puma (>= 4.0, < 7)
hanami (2.0.2)
bundler (>= 1.16, < 3)
dry-configurable (~> 1.0, < 2)
dry-core (~> 1.0, < 2)
dry-inflector (~> 1.0, < 2)
dry-logger (~> 1.0, < 2)
dry-monitor (~> 1.0, >= 1.0.1, < 2)
dry-system (~> 1.0, < 2)
hanami-cli (~> 2.0)
hanami-utils (~> 2.0)
zeitwerk (~> 2.6)
hanami-cli (2.0.2)
bundler (~> 2.1)
dry-cli (~> 1.0, < 2)
dry-files (~> 1.0, >= 1.0.1, < 2)
dry-inflector (~> 1.0, < 2)
rake (~> 13.0)
zeitwerk (~> 2.6)
hanami-controller (2.0.1)
dry-configurable (~> 1.0, < 2)
dry-core (~> 1.0)
hanami-utils (~> 2.0)
rack (~> 2.0)
zeitwerk (~> 2.6)
hanami-reloader (2.0.2)
hanami-cli (~> 2.0)
zeitwerk (~> 2.6)
hanami-router (2.0.2)
mustermann (~> 3.0)
mustermann-contrib (~> 3.0)
rack (~> 2.0)
hanami-rspec (2.0.1)
hanami-cli (~> 2.0)
rake (~> 13.0)
rspec (~> 3.12)
zeitwerk (~> 2.6)
hanami-utils (2.0.2)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
dry-transformer (~> 1.0, < 2)
hanami-validations (2.0.1)
dry-validation (>= 1.10, < 2)
zeitwerk (~> 2.6.0)
hansi (0.2.1)
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
json (2.6.3)
language_server-protocol (3.17.0.2)
listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
lumberjack (1.2.8)
method_source (1.0.0)
mini_mime (1.1.2)
multi_xml (0.6.0)
mustermann (3.0.0)
ruby2_keywords (~> 0.0.1)
mustermann-contrib (3.0.0)
hansi (~> 0.2.0)
mustermann (= 3.0.0)
nenv (0.3.0)
net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-ssh (7.0.1)
nio4r (2.5.8)
notiffany (0.1.3)
nenv (~> 0.1)
shellany (~> 0.0)
parallel (1.22.1)
parser (3.2.0.0)
ast (~> 2.4.1)
pg (1.4.5)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
puma (6.0.1)
nio4r (~> 2.0)
rack (2.2.5)
rack-test (2.0.2)
rack (>= 1.3)
rainbow (3.1.1)
rake (13.0.6)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
redcarpet (3.5.1)
regexp_parser (2.6.1)
rexml (3.2.5)
rom (5.3.0)
rom-changeset (~> 5.3, >= 5.3.0)
rom-core (~> 5.3, >= 5.3.0)
rom-repository (~> 5.3, >= 5.3.0)
rom-changeset (5.3.0)
dry-core (~> 1.0)
rom-core (~> 5.3)
transproc (~> 1.0, >= 1.1.0)
rom-core (5.3.0)
concurrent-ruby (~> 1.1)
dry-configurable (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-initializer (~> 3.0, >= 3.0.1)
dry-struct (~> 1.0)
dry-types (~> 1.6)
transproc (~> 1.0, >= 1.1.0)
rom-factory (0.11.0)
dry-configurable (~> 1.0)
dry-core (~> 1.0)
dry-struct (~> 1.6)
faker (>= 2.0, < 3.0)
rom-core (~> 5.3)
rom-repository (5.3.0)
dry-core (~> 1.0)
dry-initializer (~> 3.0, >= 3.0.1)
rom-core (~> 5.3, >= 5.3.0)
rom-sql (3.6.1)
dry-core (~> 1.0)
dry-types (~> 1.0)
rom (~> 5.2, >= 5.2.1)
sequel (>= 4.49)
rspec (3.12.0)
rspec-core (~> 3.12.0)
rspec-expectations (~> 3.12.0)
rspec-mocks (~> 3.12.0)
rspec-core (3.12.0)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-support (3.12.0)
rubocop (1.42.0)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.2.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.24.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.24.1)
parser (>= 3.1.1.0)
rubocop-performance (1.15.2)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
ruby-progressbar (1.11.0)
ruby2_keywords (0.0.5)
sequel (5.63.0)
shellany (0.0.1)
slim (4.1.0)
temple (>= 0.7.6, < 0.9)
tilt (>= 2.0.6, < 2.1)
sshkit (1.21.3)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
standard (1.21.1)
language_server-protocol (~> 3.17.0.2)
rubocop (= 1.42.0)
rubocop-performance (= 1.15.2)
standardrb (1.0.1)
standard
temple (0.8.2)
thor (1.2.1)
tilt (2.0.11)
timecop (0.9.6)
transproc (1.1.1)
unicode-display_width (2.4.2)
zeitwerk (2.6.6)
PLATFORMS
x86_64-darwin-20
x86_64-darwin-22
DEPENDENCIES
babosa
builder
capistrano (~> 3.17)
database_cleaner-sequel
dotenv
dry-matcher
dry-monads
dry-types
guard-puma (~> 0.8)
hanami (~> 2.0.0)
hanami-controller (~> 2.0.0)
hanami-reloader
hanami-router (~> 2.0.0)
hanami-rspec
hanami-validations (~> 2.0.0)
hanami-view!
httparty
pg
puma
rack-test
rake
redcarpet
rexml
rom-factory
rom-sql
slim
standardrb
timecop
BUNDLED WITH
2.3.22

10
Guardfile Normal file
View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
group :server do
guard "puma", port: ENV.fetch("HANAMI_PORT", 2300) do
watch(%r{config/*})
watch(%r{lib/*})
watch(%r{app/*})
watch(%r{slices/*})
end
end

2
Procfile.dev Normal file
View File

@@ -0,0 +1,2 @@
web: bundle exec hanami server
css: npx tailwindcss -i ./app/assets/index.css -o ./public/assets/index.css --watch

1
README.md Normal file
View File

@@ -0,0 +1 @@
# Adamantium

3
Rakefile Normal file
View File

@@ -0,0 +1,3 @@
# frozen_string_literal: true
require "hanami/rake_tasks"

62
app/action.rb Normal file
View File

@@ -0,0 +1,62 @@
# auto_register: false
# frozen_string_literal: true
require "hanami/action"
require "httparty"
module Adamantium
class Action < Hanami::Action
include Deps["logger", "settings", not_found_view: "views.not_found"]
handle_exception ROM::TupleCountMismatchError => :not_found
def authenticate!(req, res)
if Hanami.env == :development || Hanami.env == :test
req.env[:scopes] = verify_token(nil)
return true
end
# Pull out and verify the authorization header or access_token
if req.env["HTTP_AUTHORIZATION"]
header = req.env["HTTP_AUTHORIZATION"].match(/Bearer (.*)$/)
access_token = header[1] unless header.nil?
elsif req.params["access_token"]
access_token = req.params["access_token"]
else
logger.error "Received request without a token"
halt 401
end
req.env[:access_token] = access_token
# Verify the token and extract scopes
req.env[:scopes] = verify_token(access_token)
end
def not_found(_req, res, _exception)
res.render not_found_view
end
def verify_scope(req:, scope:)
req.env[:scopes].include? scope
end
private
def verify_token(access_token)
return %i[create update delete undelete media] if Hanami.env == :development || Hanami.env == :test
resp = HTTParty.get(settings.micropub_token_endpoint, {
headers: {
"Accept" => "application/x-www-form-urlencoded",
"Authorization" => "Bearer #{access_token}"
}
})
decoded_response = URI.decode_www_form(resp.body).to_h.transform_keys(&:to_sym)
halt 401 unless (decoded_response.include? :scope) && (decoded_response.include? :me)
decoded_response[:scope].gsub(/post/, "create").split.map(&:to_sym)
end
end
end

0
app/actions/.keep Normal file
View File

View File

@@ -0,0 +1,13 @@
module Adamantium
module Actions
module Bookmarks
class Index < Action
include Deps["views.bookmarks.index"]
def handle(req, res)
res.render index, query: req.params[:q]
end
end
end
end
end

View File

@@ -0,0 +1,12 @@
module Adamantium
module Actions
module Bookmarks
class Show < Action
include Deps["views.bookmarks.show"]
def handle(req, res)
res.render show, slug: req.params[:slug]
end
end
end
end
end

14
app/actions/feeds/rss.rb Normal file
View File

@@ -0,0 +1,14 @@
module Adamantium
module Actions
module Feeds
class Rss < Action
include Deps["views.feeds.rss"]
def handle(req, res)
res.content_type = "application/rss+xml"
res.render rss, format: :xml
end
end
end
end
end

13
app/actions/key/show.rb Normal file
View File

@@ -0,0 +1,13 @@
module Adamantium
module Actions
module Key
class Show < Action
include Deps["settings"]
def handle(req, res)
res.content_type = "text/plain"
res.body = settings.micropub_pub_key
end
end
end
end
end

View File

@@ -0,0 +1,28 @@
module Adamantium
module Actions
module Media
class Create < Action
before :authenticate
include Deps["commands.media.upload"]
def handle(req, res)
data = req.params[:file]
halt 401 if verify_scope(req: req, scope: :media)
upload(file: data) do |m|
m.failure do |v|
res.status = 422
end
m.success do |v|
res.status = 201
res.body = v
end
end
end
end
end
end
end

15
app/actions/pages/show.rb Normal file
View File

@@ -0,0 +1,15 @@
module Adamantium
module Actions
module Pages
class Show < Action
include Deps["views.pages.show"]
def handle(req, res)
slug = req.params[:slug]
res.render show, slug: slug
end
end
end
end
end

View File

@@ -0,0 +1,66 @@
require "pry"
module Adamantium
module Actions
module Posts
class Handle < Action
before :authenticate!
include Deps[
"settings",
post_param_parser: "param_parser.micropub_post",
create_resolver: "commands.posts.creation_resolver",
delete_post: "commands.posts.delete",
undelete_post: "commands.posts.undelete",
update_post: "commands.posts.update"
]
def handle(req, res)
req_entity = post_param_parser.call(params: req.params.to_h)
action = req.params[:action]
if action
operation, permission_check = resolve_operation(action)
if permission_check.call(req)
operation.call(params: req.params.to_h)
res.status = 200
else
res.status = 401
end
elsif req_entity
halt 401 unless verify_scope(req: req, scope: :create)
command, contract = create_resolver.call(entry_type: req_entity).values_at(:command, :validation)
validation = contract.call(req_entity.to_h)
if validation.success?
post = command.call(validation.to_h)
res.status = 201
res.headers.merge!(
"Location" => "#{settings.micropub_site_url}/#{post.post_type}/#{post.slug}"
)
else
res.body = {error: validation.errors.to_h}.to_json
res.status = 422
end
end
end
private
def resolve_operation(action)
case action
when "delete"
[delete_post, ->(req) { verify_scope(req: req, scope: :delete) }]
when "undelete"
[undelete_post, ->(req) { verify_scope(req: req, scope: :undelete) }]
when "update"
[update_post, ->(req) { verify_scope(req: req, scope: :update) }]
end
end
end
end
end
end

View File

@@ -0,0 +1,12 @@
module Adamantium
module Actions
module Posts
class Index < Action
include Deps["views.posts.index"]
def handle(req, res)
res.render index
end
end
end
end
end

15
app/actions/posts/show.rb Normal file
View File

@@ -0,0 +1,15 @@
module Adamantium
module Actions
module Posts
class Show < Action
include Deps["views.posts.show"]
def handle(req, res)
slug = req.params[:slug]
res.render show, slug: slug
end
end
end
end
end

View File

@@ -0,0 +1,31 @@
module Adamantium
module Actions
module Site
class Config < Action
include Deps["settings", "views.site.home"]
def handle(req, res)
if req.params[:q] == "config"
res.status = 200
res.content_type = "Application/JSON"
res.body = {
"media-endpoint" => settings.micropub_media_endpoint,
"destination" => [
{uid: settings.micropub_site_id, name: settings.micropub_site_name}
],
"post-types" => [
{type: "note", name: "Note", properties: %w[content category]},
{type: "article", name: "Article", properties: %w[name content category]},
{type: "photo", name: "Photo", properties: %w[name content category]},
{type: "bookmark", name: "Bookmark", properties: %w[name content category]}
],
"syndicate-to" => []
}.to_json
else
res.render home
end
end
end
end
end
end

12
app/actions/site/home.rb Normal file
View File

@@ -0,0 +1,12 @@
module Adamantium
module Actions
module Site
class Home < Action
include Deps["views.site.home"]
def handle(req, res)
res.render home
end
end
end
end
end

15
app/actions/tags/show.rb Normal file
View File

@@ -0,0 +1,15 @@
module Adamantium
module Actions
module Tags
class Show < Action
include Deps["views.tags.show"]
def handle(req, res)
slug = req.params[:slug]
res.render show, slug: slug
end
end
end
end
end

27
app/assets/index.css Normal file
View File

@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind typography;
@font-face {
font-family: "Rubik";
src: url("/assets/Rubik-VariableFont_wght.ttf") format("truetype");
}
@font-face {
font-family: "JetBrainsMono";
src: url("/assets/JetBrainsMono-VariableFont_wght.ttf") format("truetype");
}
* {
font-family: "Rubik", Helvetica, Arial, sans-serif;
}
.gist tr {
border-bottom: none;
}
.gist span {
font-family: "JetBrainsMono", Monaco, monospace;
}

View File

@@ -0,0 +1,32 @@
# frozen_string_literal: true
module Adamantium
module Commands
module Media
class Upload < Command
include Deps["settings"]
def call(file:)
pathname = Time.now.strftime("%m-%Y")
filename = file[:filename].split("/").last
dirname = File.join("public", "media", pathname)
unless File.directory?(dirname)
FileUtils.mkdir_p(dirname)
end
begin
File.write(File.join(dirname, filename), file[:tempfile].read)
rescue Errno::ENOENT, NoMethodError => e
return Failure(e.message)
end
upload_path = File.join(settings.micropub_site_url, "/media/", "/#{pathname}/", filename).to_s
Success(upload_path)
end
end
end
end
end

View File

@@ -0,0 +1,12 @@
module Adamantium
module Commands
module Posts
class CreateBookmark < Command
include Deps["repos.post_repo"]
def call(bookmark)
post_repo.create(bookmark)
end
end
end
end
end

View File

@@ -0,0 +1,18 @@
module Adamantium
module Commands
module Posts
class CreateEntry < Command
include Deps["repos.post_repo",
"post_utilities.slugify",
renderer: "renderers.markdown"
]
def call(post)
attrs = post.to_h
attrs[:content] = renderer.call(content: attrs[:content])
post_repo.create(attrs)
end
end
end
end
end

View File

@@ -0,0 +1,23 @@
module Adamantium
module Commands
module Posts
class CreationResolver
include Deps[
"validation.posts.post_contract",
"validation.posts.bookmark_contract",
"commands.posts.create_entry",
"commands.posts.create_bookmark"
]
def call(entry_type:)
case entry_type
in Entities::BookmarkRequest
{command: create_bookmark, validation: bookmark_contract}
else
{command: create_entry, validation: post_contract}
end
end
end
end
end
end

View File

@@ -0,0 +1,14 @@
module Adamantium
module Commands
module Posts
class Delete < Command
include Deps["repos.post_repo"]
def call(params:)
slug = URI(params[:url]).path.split("/").last
post_repo.delete!(slug)
end
end
end
end
end

View File

@@ -0,0 +1,14 @@
module Adamantium
module Commands
module Posts
class Undelete < Command
include Deps["repos.post_repo"]
def call(params:)
slug = URI(params[:url]).path.split("/").last
post_repo.restore!(slug)
end
end
end
end
end

View File

@@ -0,0 +1,13 @@
module Adamantium
module Commands
module Posts
class Update < Command
def call(params)
slug = URI(params[:url]).path.split("/").last
post_repo.update(slug, params)
end
end
end
end
end

2
app/content/home.md Normal file
View File

@@ -0,0 +1,2 @@
Hi! 👋 I'm Daniel, a software engineer living in Canberra, Australia.

View File

@@ -0,0 +1,17 @@
# About
Hi, I'm _Daniel_.
I've been in the software / web industry for around 10 years. I'm currently a technical lead at [Culture Amp](https://cultureamp.com), where I lead a small team working in a Rails monolith and across many microservices. Previously I worked with the wonderful humans of [Icelab](https://icelab.com.au) on a wide range of interesting and valuable projects.
I currently live in Canberra with my partner and [our dogs](https://instagram.com/barkly_and_crumpet).
In my spare time I like to tinker on various Ruby projects (including the software that powers this blog), make things with [Processing](https://processing.org), explore the various walking tracks around our house and potter around in the garden.
### Contact me
- [Email](mailto:hello@dnitza.com)
- [Mastodon](https://social.dnitza.com/@daniel)
- [Github](https://github.com/dnitza)

View File

@@ -0,0 +1 @@
# Art things

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: false
# auto_register: false
module Adamantium
module Decorators
module Bookmarks
class Decorator < SimpleDelegator
def display_published_at
published_at.strftime("%e %B, %Y")
end
end
end
end
end

View File

@@ -0,0 +1,73 @@
# frozen_string_literal: false
# auto_register: false
require "rexml/parsers/pullparser"
module Adamantium
module Decorators
module Posts
class Decorator < SimpleDelegator
def prefix_emoji
name ? "📝" : "📯"
end
def display_title
title = name || published_at.strftime("%D")
"#{prefix_emoji} #{title}"
end
def display_published_at
published_at.strftime("%e %B, %Y")
end
def machine_published_at
published_at.rfc2822
end
def excerpt
truncate_html(content, 140, true)
end
private
def truncate_html(content, len = 30, at_end = nil)
p = REXML::Parsers::PullParser.new(content)
tags = []
new_len = len
results = ""
while p.has_next? && new_len > 0
p_e = p.pull
case p_e.event_type
when :start_element
tags.push p_e[0]
results << "<#{tags.last}#{attrs_to_s(p_e[1])}>"
when :end_element
results << "</#{tags.pop}>"
when :text
results << p_e[0][0..new_len]
new_len -= p_e[0].length
else
results << "<!-- #{p_e.inspect} -->"
end
end
if at_end
results << "..."
end
tags.reverse_each do |tag|
results << "</#{tag}>"
end
results
end
def attrs_to_s(attrs)
if attrs.empty?
""
else
" " + attrs.to_a.map { |attr| %(#{attr[0]}="#{attr[1]}") }.join(" ")
end
end
end
end
end
end

View File

@@ -0,0 +1,15 @@
module Adamantium
module Entities
class BookmarkRequest < Dry::Struct
attribute :h, Types::Coercible::String
attribute :action, Types::Coercible::String.optional
attribute :name, Types::Coercible::String
attribute :content, Types::Coercible::String.optional
attribute :url, Types::Coercible::String
attribute :slug, Types::Coercible::String
attribute :category, Types::Array.of(Types::Coercible::String)
attribute :published_at, Types::Nominal::DateTime.optional
attribute :post_type, Types::Coercible::String
end
end
end

View File

@@ -0,0 +1,14 @@
module Adamantium
module Entities
class PostRequest < Dry::Struct
attribute :h, Types::Coercible::String
attribute :action, Types::Coercible::String.optional
attribute :name, Types::Coercible::String.optional
attribute :content, Types::Coercible::String
attribute :slug, Types::Coercible::String
attribute :category, Types::Array.of(Types::Coercible::String)
attribute :published_at, Types::Nominal::DateTime.optional
attribute :post_type, Types::Coercible::String
end
end
end

84
app/repos/post_repo.rb Normal file
View File

@@ -0,0 +1,84 @@
module Adamantium
module Repos
class PostRepo < Adamantium::Repo[:posts]
commands :update
def create(post_attrs)
posts.transaction do
new_post = posts.changeset(:create, post_attrs).commit
post_attrs[:category].each do |tag_name|
next if tag_name == ""
tag = posts.tags.where(label: tag_name).one ||
posts
.tags
.changeset(:create, {label: tag_name, slug: tag_name.downcase.strip.tr(" ", "-").gsub(/[^\w-]/, "")})
.commit
posts.post_tags.changeset(:create, {
post_id: new_post.id,
tag_id: tag[:id]
})
.commit
end
new_post
end
end
def post_listing(limit: nil)
posts
.where(post_type: "post")
.published
.combine(:tags)
.order(Sequel.desc(:published_at))
.limit(limit)
.to_a
end
def bookmark_listing(query: nil)
base = posts
.where(post_type: "bookmark")
.published
.combine(:tags)
.order(Sequel.desc(:published_at))
query ? base.where(Sequel.ilike(:name, "%#{query}%")).to_a : base.to_a
end
def for_rss
posts
.where(post_type: "post")
.published
.combine(:tags)
.order(Sequel.desc(:published_at))
.to_a
end
def fetch!(slug)
posts
.published
.combine(:tags)
.where(slug: slug)
.one!
end
def slug_exists?(slug)
!!posts
.where(slug: slug)
.one
end
def delete!(slug)
delete_post = posts.where(slug: slug).command(:update)
delete_post.call(published_at: nil)
end
def restore!(slug)
delete_post = posts.where(slug: slug).command(:update)
delete_post.call(published_at: Time.now)
end
end
end
end

View File

@@ -0,0 +1,26 @@
module Adamantium
module Repos
class PostTagRepo < Adamantium::Repo[:post_tags]
def posts_tagged(tag:)
tag_id = post_tags
.tags
.where(slug: tag)
.one!
.id
post_ids = post_tags
.where(tag_id: tag_id)
.to_a
.map(&:post_id)
post_tags
.posts
.where(id: post_ids)
.published
.combine(:tags)
.order(Sequel.desc(:published_at))
.to_a
end
end
end
end

9
app/repos/tag_repo.rb Normal file
View File

@@ -0,0 +1,9 @@
module Adamantium
module Repos
class TagRepo < Adamantium::Repo[:tags]
def fetch!(slug)
tags.where(slug: slug).one!
end
end
end
end

View File

@@ -0,0 +1,13 @@
div class="flex justify-between mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
div class="basis-1/5"
h1 Bookmarks
form class="basis-2/5" method="GET" action="/bookmarks"
input class="w-48 border-blue-400 border-2 rounded mr-2 px-2" id="seach" type="text" name="q" value=q
button class="w-16 border-blue-400 border-2 rounded bg-blue-400 hover:bg-blue-800 hover:border-blue-800 hover:text-blue-100 px-1 text-gray-200" type="submit" Search
div class="mb-12 max-w-prose mx-auto"
- bookmarks.each do |bookmark|
== render :bookmark, bookmark: bookmark
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"

View File

@@ -0,0 +1,17 @@
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
h1 = bookmark.name
div class="mb-12 prose max-w-prose mx-auto text-gray-800 dark:text-gray-200"
a class="text-blue-600 no-underline hover:underline" href=bookmark.url
p class="text-xl"
= bookmark.url
== bookmark.content
div class="mb-8 max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
div class="mb-2 max-w-prose mx-auto text-gray-800 dark:text-gray-200 flex"
= "Published #{bookmark.display_published_at}"
span class="text-right flex-1"
== render :tags, tags: bookmark.tags

View File

@@ -0,0 +1,20 @@
xml.instruct!(:xml, version: "2.0", encoding: "utf-8")
xml.channel do |channel|
channel.title "Daniel Nitsikopoulos"
channel.description "The RSS feed for https://dnitza.com"
channel.lastBuildDate Time.now.rfc2822
channel.pubDate Time.now.rfc2822
channel.ttl 1800
posts.each do |post|
channel.item do |item|
item.title post.display_title
item.description do |desc|
desc.cdata! post.content
end
item.guid(post.slug, isPermaLink: true)
item.pubDate post.machine_published_at
end
end
end

View File

@@ -0,0 +1,46 @@
html
head
meta charest="utf-8"
meta name="viewport" content="width=device-width, initial-scale=1.0"
title Daniel Nitsikopoulos
link rel="authorization_endpoint" href=Hanami.app.settings.micropub_authorization_endpoint
link rel="token_endpoint" href=Hanami.app.settings.micropub_token_endpoint
link rel="micropub" href="#{URI.join(Hanami.app.settings.micropub_site_url, "micropub")}"
link rel="stylesheet" href="/assets/index.css"
script data-domain="dnitza.com" src="https://stats.dnitza.com/js/script.js" defer="true"
/ script src="https://unpkg.com/htmx.org@1.8.4" integrity="sha384-wg5Y/JwF7VxGk4zLsJEcAojRtlVp1FKKdGy1qN+OMtdq72WRvX/EdRdqg/LOhYeV" crossorigin="anonymous"
- if Hanami.app.settings.micropub_pub_key
link rel="pgpkey" href="/key"
body class="bg-white dark:bg-black selection:bg-blue-100 selection:text-blue-900 dark:selection:bg-blue-600 dark:selection:text-blue-100"
main class="pb-8 px-4 pt-4 md:pt-8"
header class="mb-12 max-w-screen-md mx-auto"
div class="flex items-center mb-8 md:mb-12 text-lg md:text-xl text-gray-400 dark:text-gray-600"
div class="flex-none mx-auto md:flex-auto md:mx-0"
div class="h-card flex items-center"
img class="u-photo w-6 h6 md:w-10 md:h-10 rounded-full mr-1.5" src="/assets/memoji.png"
a href="/"
h1 class="p-bane uppercase text-sm md:text-sm text-gray-400 dark:text-gray-400" = Hanami.app.settings.site_name
nav class="space-x-1 text-sm md:text-sm uppercase md:block"
a class="p-1 rounded text-gray-400 hover:bg-red-100 hover:text-red-400 dark:hover:bg-red-200 #{link_active?('about') ? 'text-red-600 dark:text-red-400' : ''}" href="/about" About
span class="text-gray-400 dark:text-gray-600"
= "/"
a class="p-1 rounded text-gray-400 hover:bg-blue-100 hover:text-blue-400 dark:hover:bg-blue-200" href="/posts" Writing
span class="text-gray-400 dark:text-gray-600"
= "/"
a class="p-1 rounded text-gray-400 hover:bg-yellow-100 hover:text-yellow-600 dark:hover:bg-yellow-200 #{link_active?('bookmarks') ? 'text-yellow-600 dark:text-yellow-600' : ''}" href="/bookmarks" Bookmarks
span class="text-gray-400 dark:text-gray-600"
= "/"
a class="p-1 rounded text-gray-400 hover:bg-orange-100 hover:text-orange-400 dark:hover:bg-orange-200" href="#{Hanami.app.settings.micropub_site_url}/feeds/rss" RSS
// span class="text-gray-400 dark:text-gray-600"
= "/"
// a class="text-green-600 hover:text-gray-800 dark:hover:text-gray-200" href="/art-things" Art things
== yield
div class="px-4 max-w-screen-md mx-auto pb-10"
p class="text-gray-200 dark:text-gray-600" © 2023 Daniel Nitsikopoulos. All rights reserved.

View File

View File

@@ -0,0 +1,3 @@
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
h1 Not Found!

View File

@@ -0,0 +1,4 @@
article class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200 prose-em:font-bold prose-em:not-italic prose-em:bg-blue-600 prose-em:px-1 prose-a:text-blue-600 prose-a:p-0.5 prose-a:rounded-sm prose-a:no-underline hover:prose-a:underline prose-em:text-blue-100"
== page_content
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"

View File

@@ -0,0 +1,8 @@
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
h1 Writing
div class="mb-12 max-w-prose mx-auto"
- posts.each do |post|
== render :post, post: post
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"

View File

@@ -0,0 +1,12 @@
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
h1 = post.display_title
article class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200 prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline"
== post.content
div class="mb-4 max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
div class="mb-2 max-w-prose mx-auto text-gray-600 dark:text-gray-200 flex"
= "Published #{post.display_published_at}"
span class="text-right flex-1"
== render :tags, tags: post.tags

View File

@@ -0,0 +1,12 @@
div class="mb-8"
h3 class="text-xl font-bold text-blue-600 hover:underline"
a href="#{bookmark.url}"
= "🔖 #{bookmark.name} "
== render("link_arrow")
p class="e-content leading-relaxed md:text-lg text-gray-800 dark:text-gray-200"
= bookmark.content
== render :tags, tags: bookmark.tags
p class="text-sm u-url text-blue-400 hover:text-blue-600 dark:hover:text-blue-200"
a href="/bookmark/#{bookmark.slug}"
= bookmark.display_published_at

View File

@@ -0,0 +1,4 @@
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="inline w-2 h2 md:w-4 md:h-4 mt-0.5"
g
rect height="14.4434" opacity="0" width="14.4238" x="0" y="0"
path d="M14.4238 10.8008L14.4141 0.976562C14.4141 0.419922 14.0527 0.0292969 13.4668 0.0292969L3.64258 0.0292969C3.0957 0.0292969 2.72461 0.449219 2.72461 0.917969C2.72461 1.38672 3.14453 1.78711 3.60352 1.78711L7.00195 1.78711L11.7676 1.63086L9.95117 3.22266L0.273438 12.9199C0.0976562 13.0957 0 13.3203 0 13.5352C0 14.0039 0.419922 14.4434 0.908203 14.4434C1.13281 14.4434 1.34766 14.3652 1.52344 14.1797L11.2207 4.49219L12.832 2.66602L12.6562 7.22656L12.6562 10.8398C12.6562 11.2988 13.0566 11.7285 13.5352 11.7285C14.0039 11.7285 14.4238 11.3281 14.4238 10.8008Z"

View File

@@ -0,0 +1,10 @@
div class="mb-8"
h3 class="text-xl font-bold text-blue-600 hover:underline"
a href="/post/#{post.slug}"
= post.display_title
div class="text-base text-gray-800 dark:text-gray-200"
== post.excerpt
== render :tags, tags: post.tags
p class="text-sm text-blue-400"
= post.display_published_at

View File

@@ -0,0 +1,8 @@
- if tags.count > 0
div class="mb-2"
span class="text-sm text-gray-600 dark:text-gray-200"
= "Tagged: "
- tags.each do |tag|
span
a class="rounded p-1 mr-1 text-xsm u-url bg-yellow-200/60 hover:bg-yellow-200 dark:bg-yellow-400 dark:hover:bg-yellow-400/80 dark:text-yellow-800 dark:hover:text-yellow-100 text-gray-600" href="/tagged/#{tag.slug}"
= tag.label

View File

@@ -0,0 +1,14 @@
div class="mb-12 max-w-prose mx-auto text-gray-800 dark:text-gray-200"
== home_content
div class="mb-8 max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
div class="mb-4 flex max-w-prose mx-auto"
h2 class="text-xl text-gray-600 dark:text-gray-200" Recent
a class="text-right flex-1 text-blue-400" href="/posts" See all &rarr;
div class="mb-12 max-w-prose mx-auto"
- posts.each do |post|
== render :post, post: post
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"

View File

@@ -0,0 +1,8 @@
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
h1 = "Tagged: \"#{tag.label}\""
div class="mb-12 max-w-prose mx-auto"
- posts.each do |post|
== render post.post_type.to_sym, post.post_type.to_sym => post
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"

View File

@@ -0,0 +1,17 @@
module Adamantium
module Validation
module Posts
class BookmarkContract < Dry::Validation::Contract
params do
required(:name).filled(:string)
required(:content).maybe(:string)
required(:category).array(:string)
required(:published_at).maybe(:time)
required(:url).filled(:string)
required(:slug).filled(:string)
required(:post_type).value(included_in?: %w[bookmark])
end
end
end
end
end

View File

@@ -0,0 +1,16 @@
module Adamantium
module Validation
module Posts
class PostContract < Dry::Validation::Contract
params do
required(:name).maybe(:string)
required(:content).filled(:string)
required(:category).array(:string)
required(:published_at).maybe(:time)
required(:slug).filled(:string)
required(:post_type).value(included_in?: %w[post])
end
end
end
end
end

View File

@@ -0,0 +1,19 @@
module Adamantium
module Views
module Bookmarks
class Index < Adamantium::View
include Deps["repos.post_repo"]
expose :bookmarks do |query:|
post_repo.bookmark_listing(query: query).map do |bookmark|
Decorators::Bookmarks::Decorator.new bookmark
end
end
expose :q do |query:|
query
end
end
end
end
end

View File

@@ -0,0 +1,13 @@
module Adamantium
module Views
module Bookmarks
class Show < Adamantium::View
include Deps["repos.post_repo"]
expose :bookmark do |slug:|
Decorators::Bookmarks::Decorator.new(post_repo.fetch!(slug))
end
end
end
end
end

23
app/views/feeds/rss.rb Normal file
View File

@@ -0,0 +1,23 @@
require "builder"
module Adamantium
module Views
module Feeds
class Rss < Adamantium::View
include Deps["repos.post_repo"]
expose :posts do
post_repo.for_rss.map do |post|
Decorators::Posts::Decorator.new post
end
end
expose :xml, decorate: false, layout: true
def xml
Builder::XmlMarkup.new(indent: 2)
end
end
end
end
end

6
app/views/not_found.rb Normal file
View File

@@ -0,0 +1,6 @@
module Adamantium
module Views
class NotFound < View
end
end
end

17
app/views/pages/show.rb Normal file
View File

@@ -0,0 +1,17 @@
module Adamantium
module Views
module Pages
class Show < Adamantium::View
include Deps[renderer: "renderers.markdown"]
expose :page_content do |slug:|
markdown_content = File.read("app/content/pages/#{slug}.md")
renderer.call(content: markdown_content)
rescue Errno::ENOENT
renderer.call(content: "## Page not found")
end
end
end
end
end

15
app/views/posts/index.rb Normal file
View File

@@ -0,0 +1,15 @@
module Adamantium
module Views
module Posts
class Index < Adamantium::View
include Deps["repos.post_repo"]
expose :posts do
post_repo.post_listing.map do |post|
Decorators::Posts::Decorator.new(post)
end
end
end
end
end
end

13
app/views/posts/show.rb Normal file
View File

@@ -0,0 +1,13 @@
module Adamantium
module Views
module Posts
class Show < Adamantium::View
include Deps["repos.post_repo"]
expose :post do |slug:|
Decorators::Posts::Decorator.new(post_repo.fetch!(slug))
end
end
end
end
end

21
app/views/site/home.rb Normal file
View File

@@ -0,0 +1,21 @@
module Adamantium
module Views
module Site
class Home < Adamantium::View
include Deps["repos.post_repo", renderer: "renderers.markdown"]
expose :home_content do
markdown_content = File.read("app/content/home.md")
renderer.call(content: markdown_content)
end
expose :posts do
post_repo.post_listing(limit: 10).map do |post|
Decorators::Posts::Decorator.new(post)
end
end
end
end
end
end

22
app/views/tags/show.rb Normal file
View File

@@ -0,0 +1,22 @@
module Adamantium
module Views
module Tags
class Show < Adamantium::View
include Deps[
"repos.post_tag_repo",
"repos.tag_repo"
]
expose :posts do |slug:|
post_tag_repo.posts_tagged(tag: slug).map do |post|
Decorators::Posts::Decorator.new(post)
end
end
expose :tag do |slug:|
tag_repo.fetch!(slug)
end
end
end
end
end

29
bin/hanami Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "bundler/setup"
require "hanami/cli"
# Hanami 2.0 does does not officially include database integration. However, much of the required
# work is already done and included in the gem.
#
# This CLI shim activates the database commands so we can manage our app database.
Hanami::CLI.tap do |cli|
cli.register "db create", Hanami::CLI::Commands::App::DB::Create
cli.register "db create_migration", Hanami::CLI::Commands::App::DB::CreateMigration
cli.register "db drop", Hanami::CLI::Commands::App::DB::Drop
cli.register "db migrate", Hanami::CLI::Commands::App::DB::Migrate
cli.register "db setup", Hanami::CLI::Commands::App::DB::Setup
cli.register "db reset", Hanami::CLI::Commands::App::DB::Reset
cli.register "db rollback", Hanami::CLI::Commands::App::DB::Rollback
cli.register "db sample_data", Hanami::CLI::Commands::App::DB::SampleData
cli.register "db seed", Hanami::CLI::Commands::App::DB::Seed
cli.register "db structure dump", Hanami::CLI::Commands::App::DB::Structure::Dump
cli.register "db version", Hanami::CLI::Commands::App::DB::Version
end
Hanami::CLI::Bundler.require(:cli)
cli = Dry::CLI.new(Hanami::CLI)
cli.call

7
config.ru Normal file
View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
require "hanami/boot"
use Rack::Static, urls: ["/assets", "/media"], root: "public"
run Hanami.app

11
config/app.rb Normal file
View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
require "hanami"
module Adamantium
class App < Hanami::App
config.actions.content_security_policy[:script_src] += " https://gist.github.com"
config.actions.content_security_policy[:script_src] += " *.dnitza.com"
config.actions.content_security_policy[:connect_src] += " https://stats.dnitza.com/api/event"
end
end

0
config/nginx.conf Normal file
View File

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
Hanami.app.register_provider :param_parser, namespace: true do
start do
register "micropub_post", Adamantium::MicropubRequestParser.new
end
end

View File

@@ -0,0 +1,41 @@
# frozen_string_literal: true
Hanami.app.register_provider :persistence, namespace: true do
prepare do
require "rom-changeset"
require "rom/core"
require "rom/sql"
# TODO(Hanami): As part of built-in rom setup, configure ROM with app inflector
silence_warnings { ROM::Inflector = Hanami.app["inflector"] }
rom_config = ROM::Configuration.new(:sql, target["settings"].database_url)
rom_config.plugin(:sql, relations: :instrumentation) do |plugin_config|
plugin_config.notifications = target["notifications"]
end
rom_config.plugin(:sql, relations: :auto_restrictions)
register "config", rom_config
register "db", rom_config.gateways[:default].connection
end
start do
rom_config = target["persistence.config"]
rom_config.auto_registration(
target.root.join("lib/adamantium/persistence"),
namespace: "Adamantium::Persistence"
)
register "rom", ROM.container(rom_config)
end
define_method(:silence_warnings) do |&block|
orig_verbose = $VERBOSE
$VERBOSE = nil
result = block.call
$VERBOSE = orig_verbose
result
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
Hanami.app.register_provider :post_utilities, namespace: true do
start do
register "slugify", Adamantium::SlugCreator.new
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
Hanami.app.register_provider :renderers, namespace: true do
start do
register "markdown", Adamantium::Renderer::Markdown.new
end
end

15
config/puma.rb Normal file
View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
max_threads_count = ENV.fetch("HANAMI_MAX_THREADS", 5)
min_threads_count = ENV.fetch("HANAMI_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count
port ENV.fetch("HANAMI_PORT", 2300)
environment ENV.fetch("HANAMI_ENV", "development")
workers ENV.fetch("HANAMI_WEB_CONCURRENCY", 2)
on_worker_boot do
Hanami.shutdown
end
preload_app!

35
config/routes.rb Normal file
View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
require "hanami/middleware/body_parser"
require "adamantium/middleware/process_params"
module Adamantium
class Routes < Hanami::Routes
use Hanami::Middleware::BodyParser, [:form, :json]
use Adamantium::Middleware::ProcessParams
scope "micropub" do
get "/", to: "site.config"
post "/", to: "posts.handle"
post "/media", to: "media.create"
end
get "/", to: "site.home"
get "/post/:slug", to: "posts.show"
get "/posts", to: "posts.index"
get "/bookmarks", to: "bookmarks.index"
get "/bookmark/:slug", to: "bookmarks.show"
get "/tagged/:slug", to: "tags.show"
get "/key", to: "key.show" if Hanami.app.settings.micropub_pub_key
get "/feeds/rss", to: "feeds.rss"
get "/:slug", to: "pages.show"
redirect "deploying-a-hanami-app-to-fly-io", to: "/post/deploying-a-hanami-20-app-to-flyio"
redirect "deploying-a-hanami-app-to-fly-io/", to: "/post/deploying-a-hanami-20-app-to-flyio"
end
end

29
config/settings.rb Normal file
View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
require "adamantium/types"
module Adamantium
class Settings < Hanami::Settings
# Infrastructure
setting :database_url
# Site details
setting :site_name
## ---- Micropub ----
# Site details
setting :micropub_site_id
setting :micropub_site_name
setting :micropub_site_url
# Auth
setting :micropub_pub_key, default: nil
# TODO: add other auth methods here
# Micropub endpoints
setting :micropub_media_endpoint, default: "", constructor: Types::Params::String
setting :micropub_authorization_endpoint
setting :micropub_token_endpoint
end
end

View File

@@ -0,0 +1,12 @@
# frozen_string_literal: true
ROM::SQL.migration do
change do
create_table :posts do
primary_key :id
column :name, :text, null: false, default: ""
column :content, :text, null: false
column :published_at, :timestamp
end
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
ROM::SQL.migration do
change do
alter_table :posts do
add_column :slug, :text, null: false
end
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
ROM::SQL.migration do
change do
alter_table :posts do
add_unique_constraint :slug
end
end
end

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
ROM::SQL.migration do
change do
create_table :bookmarks do
primary_key :id
column :url, :text, null: false
column :name, :text, null: false
column :content, :text, null: false, default: ""
column :published_at, :timestamp
end
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
ROM::SQL.migration do
change do
alter_table :bookmarks do
add_column :slug, :text, null: false, unique: true
end
end
end

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
ROM::SQL.migration do
change do
create_table :tags do
primary_key :id
column :label, :text, null: false, unique: true
column :slug, :text, null: false, unique: true
end
create_table :post_tags do
foreign_key :post_id, :posts, null: false
foreign_key :tag_id, :tags, null: false
end
end
end

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
ROM::SQL.migration do
change do
drop_table :bookmarks
alter_table :posts do
add_column :url, :text
add_column :post_type, :text
end
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
ROM::SQL.migration do
change do
alter_table :posts do
set_column_allow_null :content
end
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
ROM::SQL.migration do
change do
alter_table :posts do
set_column_allow_null :name
end
end
end

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
version: "3"
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/app
- bundle_path:/usr/local/bundle
command: script/puma
env_file: ".env"
ports:
- 2300:2300
volumes:
bundle_path:
node_modules:

13
lib/adamantium/command.rb Normal file
View File

@@ -0,0 +1,13 @@
# auto_register: false
# frozen_string_literal: true
require "dry-matcher"
require "dry/matcher/result_matcher"
require "dry-monads"
module Adamantium
class Command
include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
include Dry::Monads[:result]
end
end

14
lib/adamantium/context.rb Normal file
View File

@@ -0,0 +1,14 @@
module Adamantium
class Context < Hanami::View::Context
def initialize(**options)
@options = options
super(**options)
end
def link_active? path
# TODO: waiting for Hanami View to be released
# to access current_path
false
end
end
end

View File

@@ -0,0 +1,85 @@
require "securerandom"
module Adamantium
class MicropubRequestParser
include Deps["post_utilities.slugify", "repos.post_repo"]
def call(params:)
return nil if params.key?(:action)
cont_type = content_type(params)
req_type = request_type(params)
req_params = parse_params(req_type, cont_type, params)
if cont_type == :bookmark
return Entities::BookmarkRequest.new(req_params)
end
Entities::PostRequest.new(req_params)
end
private
def slug(name:, default_slug:)
return default_slug if default_slug
slugify.call(
text: name,
checker: post_repo.method(:slug_exists?)
)
end
def content_type(params)
return :bookmark if params[:"bookmark-of"]
:post
end
def request_type(params)
if params[:h] == "entry"
return :form
end
if params[:type]&.include?("h-entry")
return :json
end
nil
end
def parse_params(req_type, post_type, params)
new_params = {}
new_params[:h] = "entry"
new_params[:post_type] = post_type
new_params[:action] = params[:action]
publish_time = params[:published_at] || Time.now
if req_type == :json
new_params[:published_at] = (params[:"post-status"] == "draft") ? nil : publish_time
new_params[:category] = params[:properties][:category] || []
new_params[:name] = params[:properties][:name] && params[:properties][:name].first
new_params[:content] = params[:properties][:content]&.first&.tr("\n", " ")
new_params[:slug] = params[:slug]
else
new_params[:name] = params[:name]
new_params[:published_at] = (params[:"post-status"] == "draft") ? nil : publish_time
new_params[:category] = params[:category] || []
content = if params[:content]
if params[:content].is_a?(Hash) && params[:content][:html]
params[:content][:html]
else
params[:content]
end
end
new_params[:content] = content
end
new_params[:url] = params[:"bookmark-of"]
new_params[:slug] = slug(name: new_params[:name], default_slug: params[:slug])
new_params
end
end
end

View File

@@ -0,0 +1,14 @@
module Adamantium
module Middleware
class ProcessParams
def initialize(app)
@app = app
end
def call(env)
# NOOP for now.
@app.call(env)
end
end
end
end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Adamantium
module Persistence
module Relations
class PostTags < ROM::Relation[:sql]
schema :post_tags, infer: true do
associations do
belongs_to :post
belongs_to :tag
end
end
auto_struct(true)
end
end
end
end

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
module Adamantium
module Persistence
module Relations
class Posts < ROM::Relation[:sql]
schema :posts, infer: true do
associations do
has_many :post_tags
has_many :tags, through: :post_tags
end
end
auto_struct(true)
def published
where(self[:published_at] <= Time.now)
end
end
end
end
end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Adamantium
module Persistence
module Relations
class Tags < ROM::Relation[:sql]
schema :tags, infer: true do
associations do
belongs_to :post_tag
belongs_to :post, through: :post_tag
end
end
auto_struct(true)
end
end
end
end

View File

@@ -0,0 +1,21 @@
require "redcarpet"
module Adamantium
module Renderer
class Markdown
attr_accessor :markdown
def initialize
renderer = Redcarpet::Render::HTML.new({})
extensions = {
fenced_code_blocks: true
}
@markdown = Redcarpet::Markdown.new(renderer, extensions)
end
def call(content:)
markdown.render(content)
end
end
end
end

10
lib/adamantium/repo.rb Normal file
View File

@@ -0,0 +1,10 @@
# auto_register: false
# frozen_string_literal: true
require "rom-repository"
module Adamantium
class Repo < ROM::Repository::Root
include Deps[container: "persistence.rom"]
end
end

View File

@@ -0,0 +1,20 @@
require "babosa"
require "securerandom"
module Adamantium
class SlugCreator
def call(text:, checker:)
input_slug = (text != "" && !text.nil?) ? text.to_slug.normalize.to_s : SecureRandom.uuid
slug = input_slug
suffix = 1
while checker.call(slug)
slug = "#{input_slug}-#{suffix}"
suffix += 1
end
slug
end
end
end

Some files were not shown because too many files have changed in this diff Show More