eksa-framework 3.3.3 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.eksa.json +13 -0
- data/.env.example +8 -0
- data/Gemfile +5 -1
- data/Gemfile.lock +20 -0
- data/README.md +52 -51
- data/_posts/2026-03-15-welcome-to-eksa-framework.md +132 -129
- data/app/views/about.html.erb +30 -10
- data/app/views/docs.html.erb +58 -80
- data/app/views/edit.html.erb +1 -1
- data/app/views/index.html.erb +2 -2
- data/db/eksa_app.db +0 -0
- data/db/setup.rb +4 -4
- data/exe/eksa +204 -1
- data/lib/eksa/auth_controller.rb +50 -0
- data/lib/eksa/cms_controller.rb +110 -0
- data/lib/eksa/controller.rb +32 -6
- data/lib/eksa/database/mongo_adapter.rb +154 -0
- data/lib/eksa/database/sqlite_adapter.rb +26 -0
- data/lib/eksa/database.rb +47 -0
- data/lib/eksa/markdown_post.rb +11 -4
- data/lib/eksa/model.rb +2 -16
- data/lib/eksa/user.rb +47 -0
- data/lib/eksa/version.rb +1 -1
- data/lib/eksa/views/auth/login.html.erb +44 -0
- data/lib/eksa/views/auth/register.html.erb +53 -0
- data/lib/eksa/views/cms/edit.html.erb +63 -0
- data/lib/eksa/views/cms/index.html.erb +83 -0
- data/lib/eksa.rb +48 -4
- metadata +72 -3
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module Eksa
|
|
2
|
+
class CmsController < Eksa::Controller
|
|
3
|
+
def index
|
|
4
|
+
return unless require_auth
|
|
5
|
+
|
|
6
|
+
@posts = Eksa::MarkdownPost.all(include_unpublished: true)
|
|
7
|
+
render_internal 'cms/index'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def edit
|
|
11
|
+
return unless require_auth
|
|
12
|
+
|
|
13
|
+
slug = params['slug']
|
|
14
|
+
@post = Eksa::MarkdownPost.find(slug)
|
|
15
|
+
|
|
16
|
+
if @post
|
|
17
|
+
render_internal 'cms/edit'
|
|
18
|
+
else
|
|
19
|
+
redirect_to "/cms", notice: "Error: Postingan tidak ditemukan."
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def update_post
|
|
24
|
+
return unless require_auth
|
|
25
|
+
|
|
26
|
+
slug = params['slug']
|
|
27
|
+
post_path = File.join(Eksa::MarkdownPost::POSTS_DIR, "#{slug}.md")
|
|
28
|
+
|
|
29
|
+
unless File.exist?(post_path)
|
|
30
|
+
return redirect_to "/cms", notice: "Error: Postingan tidak ditemukan."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Existing metadata fetching to preserve unmodified keys like 'date' if not handling them
|
|
34
|
+
# We parse form params
|
|
35
|
+
title = params['title']
|
|
36
|
+
category = params['category']
|
|
37
|
+
author = params['author']
|
|
38
|
+
image = params['image']
|
|
39
|
+
content = params['content']
|
|
40
|
+
|
|
41
|
+
# We'll re-read the original to preserve keys we aren't allowing to edit (like published status and date)
|
|
42
|
+
original_content = File.read(post_path, encoding: 'utf-8')
|
|
43
|
+
metadata = {}
|
|
44
|
+
|
|
45
|
+
if original_content =~ /\A(---\s*\r?\n.*?\r?\n)^(---\s*\r?\n?)/m
|
|
46
|
+
metadata = YAML.safe_load($1, permitted_classes: [Time]) || {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Update metadata with form values
|
|
50
|
+
metadata['title'] = title
|
|
51
|
+
metadata['category'] = category
|
|
52
|
+
metadata['author'] = author
|
|
53
|
+
metadata['image'] = image unless image.nil? || image.strip.empty?
|
|
54
|
+
|
|
55
|
+
# Dump new YAML
|
|
56
|
+
new_yaml = YAML.dump(metadata)
|
|
57
|
+
|
|
58
|
+
# Join with content
|
|
59
|
+
new_file_content = "#{new_yaml}---\n\n#{content}"
|
|
60
|
+
|
|
61
|
+
# Write changes
|
|
62
|
+
File.write(post_path, new_file_content, encoding: 'utf-8')
|
|
63
|
+
|
|
64
|
+
redirect_to "/cms", notice: "Postingan '#{title}' berhasil diperbarui."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def toggle_status
|
|
68
|
+
return unless require_auth
|
|
69
|
+
|
|
70
|
+
slug = params['slug']
|
|
71
|
+
post_path = File.join(Eksa::MarkdownPost::POSTS_DIR, "#{slug}.md")
|
|
72
|
+
|
|
73
|
+
if File.exist?(post_path)
|
|
74
|
+
content = File.read(post_path, encoding: 'utf-8')
|
|
75
|
+
|
|
76
|
+
# Simple string replacement for published status in front matter
|
|
77
|
+
if content.match?(/^published:\s*false/m)
|
|
78
|
+
new_content = content.sub(/^published:\s*false/m, "published: true")
|
|
79
|
+
status = "diaktifkan"
|
|
80
|
+
elsif content.match?(/^published:\s*true/m)
|
|
81
|
+
new_content = content.sub(/^published:\s*true/m, "published: false")
|
|
82
|
+
status = "dinonaktifkan"
|
|
83
|
+
else
|
|
84
|
+
# If no published tag exists, it's implicitly true, so we set it to false
|
|
85
|
+
new_content = content.sub(/\A(---\r?\n)/) { "#{$1}published: false\n" }
|
|
86
|
+
status = "dinonaktifkan"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
File.write(post_path, new_content)
|
|
90
|
+
redirect_to "/cms", notice: "Postingan #{File.basename(post_path)} berhasil #{status}."
|
|
91
|
+
else
|
|
92
|
+
redirect_to "/cms", notice: "Error: Postingan tidak ditemukan."
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def delete_post
|
|
97
|
+
return unless require_auth
|
|
98
|
+
|
|
99
|
+
slug = params['slug']
|
|
100
|
+
post_path = File.join(Eksa::MarkdownPost::POSTS_DIR, "#{slug}.md")
|
|
101
|
+
|
|
102
|
+
if File.exist?(post_path)
|
|
103
|
+
File.delete(post_path)
|
|
104
|
+
redirect_to "/cms", notice: "Postingan berhasil dihapus secara permanen."
|
|
105
|
+
else
|
|
106
|
+
redirect_to "/cms", notice: "Error: Postingan tidak ditemukan."
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/eksa/controller.rb
CHANGED
|
@@ -15,6 +15,23 @@ module Eksa
|
|
|
15
15
|
@request.params
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
def session
|
|
19
|
+
@request.session
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def current_user
|
|
23
|
+
return @current_user if defined?(@current_user)
|
|
24
|
+
@current_user = session['user_id'] ? Eksa::User.find(session['user_id']) : nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def require_auth
|
|
28
|
+
unless current_user
|
|
29
|
+
redirect_to "/auth/login", notice: "Anda harus login untuk mengakses halaman ini."
|
|
30
|
+
return false
|
|
31
|
+
end
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
18
35
|
def stylesheet_tag(filename)
|
|
19
36
|
"<link rel='stylesheet' href='/css/#{filename}.css'>"
|
|
20
37
|
end
|
|
@@ -38,22 +55,31 @@ module Eksa
|
|
|
38
55
|
variables.each { |k, v| instance_variable_set("@#{k}", v) }
|
|
39
56
|
|
|
40
57
|
content_path = File.expand_path("./app/views/#{template_name}.html.erb")
|
|
58
|
+
internal_content_path = File.expand_path("../../eksa/views/#{template_name}.html.erb", __FILE__)
|
|
59
|
+
|
|
41
60
|
layout_path = File.expand_path("./app/views/layout.html.erb")
|
|
61
|
+
internal_layout_path = File.expand_path("../../eksa/views/layout.html.erb", __FILE__)
|
|
62
|
+
|
|
63
|
+
actual_content_path = File.exist?(content_path) ? content_path : internal_content_path
|
|
64
|
+
actual_layout_path = File.exist?(layout_path) ? layout_path : internal_layout_path
|
|
42
65
|
|
|
43
|
-
if File.exist?(
|
|
44
|
-
@content = ERB.new(File.read(
|
|
45
|
-
if File.exist?(
|
|
46
|
-
ERB.new(File.read(
|
|
66
|
+
if File.exist?(actual_content_path)
|
|
67
|
+
@content = ERB.new(File.read(actual_content_path)).result(binding)
|
|
68
|
+
if File.exist?(actual_layout_path)
|
|
69
|
+
ERB.new(File.read(actual_layout_path)).result(binding)
|
|
47
70
|
else
|
|
48
71
|
@content
|
|
49
72
|
end
|
|
50
73
|
else
|
|
51
74
|
"<div class='glass' style='padding: 2rem; border-radius: 1rem; color: #ff5555; background: rgba(255,0,0,0.1); backdrop-filter: blur(10px);'>
|
|
52
75
|
<h2 style='margin-top:0;'>⚠️ View Error</h2>
|
|
53
|
-
<p>Template <strong>#{template_name}</strong> tidak ditemukan di
|
|
54
|
-
<code style='display:block; background:rgba(0,0,0,0.2); padding:0.5rem; border-radius:0.5rem;'>#{content_path}</code>
|
|
76
|
+
<p>Template <strong>#{template_name}</strong> tidak ditemukan di app/views atau internal eksa/views.</p>
|
|
55
77
|
</div>"
|
|
56
78
|
end
|
|
57
79
|
end
|
|
80
|
+
|
|
81
|
+
def render_internal(template_name, variables = {})
|
|
82
|
+
render(template_name, variables)
|
|
83
|
+
end
|
|
58
84
|
end
|
|
59
85
|
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
require 'mongo'
|
|
2
|
+
|
|
3
|
+
module Eksa
|
|
4
|
+
class MongoAdapter
|
|
5
|
+
def initialize(config)
|
|
6
|
+
@uri = config&.[]('uri')
|
|
7
|
+
@client = nil
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def connection
|
|
11
|
+
return @client if @client
|
|
12
|
+
raise "MongoDB URI not configured in .eksa.json" if @uri.nil? || @uri.empty?
|
|
13
|
+
|
|
14
|
+
# Atlas URIs usually contain the database name, if not, we use 'eksa_db'
|
|
15
|
+
@client = Mongo::Client.new(@uri)
|
|
16
|
+
@client
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def execute(sql, params = [])
|
|
20
|
+
# Minimal SQL-to-Mongo translator for Eksa models
|
|
21
|
+
|
|
22
|
+
# 1. CREATE TABLE IF NOT EXISTS [table]
|
|
23
|
+
if sql =~ /CREATE TABLE IF NOT EXISTS (\w+)/i
|
|
24
|
+
# Mongo creates collections on the fly
|
|
25
|
+
return []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# 2. SELECT * FROM [table] ...
|
|
29
|
+
if sql =~ /SELECT (.*) FROM (\w+)/i
|
|
30
|
+
table_name = $2
|
|
31
|
+
query = {}
|
|
32
|
+
|
|
33
|
+
# Handle WHERE clauses
|
|
34
|
+
if sql =~ /WHERE (.*) ORDER BY/i || sql =~ /WHERE (.*) LIMIT/i || sql =~ /WHERE (.*)$/i
|
|
35
|
+
where_clause = $1
|
|
36
|
+
|
|
37
|
+
# Handle OR + LIKE (specific for Search)
|
|
38
|
+
if where_clause =~ /(\w+) LIKE \? OR (\w+) LIKE \?/i
|
|
39
|
+
field1 = $1
|
|
40
|
+
field2 = $2
|
|
41
|
+
# Extract keyword from params (strip % if present)
|
|
42
|
+
term1 = params[0].to_s.gsub('%', '')
|
|
43
|
+
term2 = params[1].to_s.gsub('%', '')
|
|
44
|
+
|
|
45
|
+
query = {
|
|
46
|
+
'$or' => [
|
|
47
|
+
{ field1.to_sym => /#{Regexp.escape(term1)}/i },
|
|
48
|
+
{ field2.to_sym => /#{Regexp.escape(term2)}/i }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
# Handle single LIKE
|
|
52
|
+
elsif where_clause =~ /(\w+) LIKE \?/i
|
|
53
|
+
field = $1
|
|
54
|
+
term = params[0].to_s.gsub('%', '')
|
|
55
|
+
query = { field.to_sym => /#{Regexp.escape(term)}/i }
|
|
56
|
+
# Handle simple field = ?
|
|
57
|
+
elsif where_clause =~ /id = \?/i || where_clause =~ /_id = \?/i
|
|
58
|
+
val = params[0]
|
|
59
|
+
query = { _id: val.is_a?(String) ? BSON::ObjectId.from_string(val) : val }
|
|
60
|
+
elsif where_clause =~ /(\w+) = \?/i
|
|
61
|
+
query = { $1.to_sym => params[0] }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
view = connection[table_name.to_sym].find(query)
|
|
66
|
+
|
|
67
|
+
# Handle ORDER BY
|
|
68
|
+
if sql =~ /ORDER BY (\w+)(?: (ASC|DESC))?/i
|
|
69
|
+
sort_field = $1
|
|
70
|
+
direction = ($2 || 'ASC').upcase == 'DESC' ? -1 : 1
|
|
71
|
+
# Mongo id maps to _id
|
|
72
|
+
sort_field = '_id' if sort_field == 'id'
|
|
73
|
+
view = view.sort({ sort_field => direction })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
view = view.limit(1) if sql =~ /LIMIT 1/i
|
|
77
|
+
results = view.to_a
|
|
78
|
+
|
|
79
|
+
return results.map do |doc|
|
|
80
|
+
# Try to make it look like a flat array similar to SQLite row
|
|
81
|
+
# We don't know the exact schema, but we can return the values
|
|
82
|
+
# For Eksa::User we specifically need [id, username, password_hash]
|
|
83
|
+
if table_name == 'eksa_users'
|
|
84
|
+
[doc['_id'].to_s, doc['username'], doc['password_hash'], doc['created_at']]
|
|
85
|
+
else
|
|
86
|
+
# For other models, return a hash-like object if possible,
|
|
87
|
+
# or just the values in order. Since User specifically uses indices,
|
|
88
|
+
# and generic models might use indices or keys, this is tricky.
|
|
89
|
+
# Most Eksa models result in array-of-arrays from SQLite gem.
|
|
90
|
+
doc.values.map { |v| v.is_a?(BSON::ObjectId) ? v.to_s : v }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 3. INSERT INTO [table] ([cols]) VALUES ([vals])
|
|
96
|
+
if sql =~ /INSERT INTO (\w+) \((.*)\) VALUES/i
|
|
97
|
+
table_name = $1
|
|
98
|
+
cols = $2.split(',').map(&:strip)
|
|
99
|
+
doc = {}
|
|
100
|
+
cols.each_with_index do |col, i|
|
|
101
|
+
doc[col.to_sym] = params[i]
|
|
102
|
+
end
|
|
103
|
+
doc[:created_at] ||= Time.now
|
|
104
|
+
|
|
105
|
+
connection[table_name.to_sym].insert_one(doc)
|
|
106
|
+
return []
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# 4. UPDATE [table] SET [col1] = ?, [col2] = ? WHERE [col] = ?
|
|
110
|
+
if sql =~ /UPDATE (\w+) SET (.*) WHERE (\w+) = \?/i
|
|
111
|
+
table_name = $1
|
|
112
|
+
set_clause = $2
|
|
113
|
+
where_col = $3
|
|
114
|
+
|
|
115
|
+
# Parse set assignments (e.g., "col1 = ?, col2 = ?")
|
|
116
|
+
# We assume the order matches params[0...n-1]
|
|
117
|
+
cols = set_clause.split(',').map { |s| s.split('=').first.strip }
|
|
118
|
+
|
|
119
|
+
set_data = {}
|
|
120
|
+
cols.each_with_index do |col, i|
|
|
121
|
+
set_data[col.to_sym] = params[i]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The last parameter is the WHERE value
|
|
125
|
+
where_val = params.last
|
|
126
|
+
selector = if where_col == 'id'
|
|
127
|
+
{ _id: where_val.is_a?(String) ? BSON::ObjectId.from_string(where_val) : where_val }
|
|
128
|
+
else
|
|
129
|
+
{ where_col.to_sym => where_val }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
connection[table_name.to_sym].update_one(selector, { '$set' => set_data })
|
|
133
|
+
return []
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# 5. DELETE FROM [table] WHERE (\w+) = ?
|
|
137
|
+
if sql =~ /DELETE FROM (\w+) WHERE (\w+) = \?/i
|
|
138
|
+
table_name = $1
|
|
139
|
+
where_col = $2
|
|
140
|
+
|
|
141
|
+
selector = if where_col == 'id'
|
|
142
|
+
{ _id: params[0].is_a?(String) ? BSON::ObjectId.from_string(params[0]) : params[0] }
|
|
143
|
+
else
|
|
144
|
+
{ where_col.to_sym => params[0] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
connection[table_name.to_sym].delete_one(selector)
|
|
148
|
+
return []
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
raise "Unsupported SQL query for MongoAdapter: #{sql}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require 'sqlite3'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
4
|
+
module Eksa
|
|
5
|
+
class SqliteAdapter
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@path = config&.[]('path') || File.expand_path("../../../db/eksa_app.db", __FILE__)
|
|
8
|
+
ensure_db_dir
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def connection
|
|
12
|
+
@connection ||= SQLite3::Database.new(@path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def execute(sql, params = [])
|
|
16
|
+
connection.execute(sql, params)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def ensure_db_dir
|
|
22
|
+
db_dir = File.dirname(@path)
|
|
23
|
+
FileUtils.mkdir_p(db_dir) unless Dir.exist?(db_dir)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
begin
|
|
3
|
+
require 'dotenv'
|
|
4
|
+
Dotenv.load if File.exist?('.env')
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# Dotenv not available, rely on existing ENV
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module Eksa
|
|
10
|
+
module Database
|
|
11
|
+
def self.adapter
|
|
12
|
+
@adapter ||= create_adapter
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.reset_adapter
|
|
16
|
+
@adapter = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.create_adapter
|
|
20
|
+
config = load_config
|
|
21
|
+
type = config.dig('database', 'type') || 'sqlite'
|
|
22
|
+
|
|
23
|
+
case type
|
|
24
|
+
when 'mongo', 'mongodb'
|
|
25
|
+
require_relative 'database/mongo_adapter'
|
|
26
|
+
mongo_config = config.dig('database', 'mongodb') || {}
|
|
27
|
+
|
|
28
|
+
# Prioritize Environment Variable for Security
|
|
29
|
+
env_uri = ENV['EKSA_MONGODB_URI'] || ENV['MONGODB_URI']
|
|
30
|
+
mongo_config['uri'] = env_uri if env_uri
|
|
31
|
+
|
|
32
|
+
MongoAdapter.new(mongo_config)
|
|
33
|
+
else
|
|
34
|
+
require_relative 'database/sqlite_adapter'
|
|
35
|
+
SqliteAdapter.new(config.dig('database', 'sqlite'))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.load_config
|
|
40
|
+
config_path = File.join(Dir.pwd, '.eksa.json')
|
|
41
|
+
return {} unless File.exist?(config_path)
|
|
42
|
+
JSON.parse(File.read(config_path))
|
|
43
|
+
rescue
|
|
44
|
+
{}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/eksa/markdown_post.rb
CHANGED
|
@@ -10,16 +10,19 @@ module Eksa
|
|
|
10
10
|
|
|
11
11
|
POSTS_DIR = "_posts"
|
|
12
12
|
|
|
13
|
-
def self.all
|
|
13
|
+
def self.all(include_unpublished: false)
|
|
14
14
|
return [] unless Dir.exist?(POSTS_DIR)
|
|
15
15
|
|
|
16
|
-
Dir.glob(File.join(POSTS_DIR, "*.md")).map do |file|
|
|
16
|
+
posts = Dir.glob(File.join(POSTS_DIR, "*.md")).map do |file|
|
|
17
17
|
new(file)
|
|
18
|
-
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
posts.reject! { |p| !p.published? } unless include_unpublished
|
|
21
|
+
posts.sort_by { |p| p.date }.reverse
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
def self.find(slug)
|
|
22
|
-
all.find { |p| p.slug == slug }
|
|
25
|
+
all(include_unpublished: true).find { |p| p.slug == slug && p.published? }
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def initialize(file_path)
|
|
@@ -48,6 +51,10 @@ module Eksa
|
|
|
48
51
|
@metadata['image'] || ""
|
|
49
52
|
end
|
|
50
53
|
|
|
54
|
+
def published?
|
|
55
|
+
@metadata.key?('published') ? @metadata['published'] : true
|
|
56
|
+
end
|
|
57
|
+
|
|
51
58
|
def body_html
|
|
52
59
|
Kramdown::Document.new(@content, input: 'GFM').to_html
|
|
53
60
|
end
|
data/lib/eksa/model.rb
CHANGED
|
@@ -1,24 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
require 'fileutils'
|
|
1
|
+
require_relative 'database'
|
|
3
2
|
|
|
4
3
|
module Eksa
|
|
5
4
|
class Model
|
|
6
|
-
class << self
|
|
7
|
-
attr_accessor :database_path
|
|
8
|
-
end
|
|
9
|
-
|
|
10
5
|
def self.db
|
|
11
|
-
path = database_path || default_db_path
|
|
12
|
-
db_dir = File.dirname(path)
|
|
13
|
-
FileUtils.mkdir_p(db_dir) unless Dir.exist?(db_dir)
|
|
14
|
-
|
|
15
|
-
@db ||= SQLite3::Database.new(path)
|
|
16
6
|
ensure_schema
|
|
17
|
-
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def self.default_db_path
|
|
21
|
-
File.expand_path("../../db/eksa_app.db", __dir__)
|
|
7
|
+
Eksa::Database.adapter
|
|
22
8
|
end
|
|
23
9
|
|
|
24
10
|
def self.ensure_schema
|
data/lib/eksa/user.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Eksa
|
|
2
|
+
class User < Eksa::Model
|
|
3
|
+
require 'bcrypt'
|
|
4
|
+
|
|
5
|
+
def self.setup_schema
|
|
6
|
+
db.execute <<~SQL
|
|
7
|
+
CREATE TABLE IF NOT EXISTS eksa_users (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
username TEXT UNIQUE,
|
|
10
|
+
password_hash TEXT,
|
|
11
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
12
|
+
)
|
|
13
|
+
SQL
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.all
|
|
17
|
+
db.execute("SELECT id, username FROM eksa_users")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.create(username, password)
|
|
21
|
+
hash = BCrypt::Password.create(password)
|
|
22
|
+
db.execute("INSERT INTO eksa_users (username, password_hash) VALUES (?, ?)", [username, hash])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.update_password(username, new_password)
|
|
26
|
+
hash = BCrypt::Password.create(new_password)
|
|
27
|
+
db.execute("UPDATE eksa_users SET password_hash = ? WHERE username = ?", [hash, username])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.authenticate(username, password)
|
|
31
|
+
user_data = db.execute("SELECT id, username, password_hash FROM eksa_users WHERE username = ? LIMIT 1", [username]).first
|
|
32
|
+
return nil unless user_data
|
|
33
|
+
|
|
34
|
+
stored_hash = BCrypt::Password.new(user_data[2])
|
|
35
|
+
if stored_hash == password
|
|
36
|
+
{ id: user_data[0], username: user_data[1] }
|
|
37
|
+
else
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.find(id)
|
|
43
|
+
user_data = db.execute("SELECT id, username FROM eksa_users WHERE id = ? LIMIT 1", [id]).first
|
|
44
|
+
user_data ? { id: user_data[0], username: user_data[1] } : nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/eksa/version.rb
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<div class="glass max-w-md mx-auto rounded-3xl p-8 animate__animated animate__fadeIn relative overflow-hidden">
|
|
2
|
+
<div class="absolute inset-0 bg-indigo-500/10 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div>
|
|
3
|
+
|
|
4
|
+
<div class="relative z-10 text-center mb-8">
|
|
5
|
+
<div class="w-16 h-16 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-white/20 shadow-xl">
|
|
6
|
+
<i data-lucide="lock" class="w-8 h-8 text-indigo-300"></i>
|
|
7
|
+
</div>
|
|
8
|
+
<h1 class="text-3xl font-extrabold text-white tracking-tight mb-2">Masuk CMS</h1>
|
|
9
|
+
<p class="text-white/50 text-sm">Masuk untuk mengelola postingan blog Anda.</p>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<% if @flash && @flash[:notice] %>
|
|
13
|
+
<div class="relative z-10 mb-6 p-4 rounded-xl bg-red-500/20 border border-red-500/30 text-red-200 text-sm text-center font-medium animate__animated animate__shakeX">
|
|
14
|
+
<i data-lucide="alert-circle" class="w-4 h-4 inline-block mr-1 -mt-0.5"></i>
|
|
15
|
+
<%= @flash[:notice] %>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<form action="/auth/process_login" method="post" class="relative z-10 space-y-4">
|
|
20
|
+
<div>
|
|
21
|
+
<label class="block text-xs font-bold text-indigo-300 uppercase tracking-widest mb-2">Username</label>
|
|
22
|
+
<input type="text" name="username" required
|
|
23
|
+
class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition placeholder-white/20"
|
|
24
|
+
placeholder="Masukkan username...">
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div>
|
|
28
|
+
<label class="block text-xs font-bold text-indigo-300 uppercase tracking-widest mb-2">Password</label>
|
|
29
|
+
<input type="password" name="password" required
|
|
30
|
+
class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition placeholder-white/20"
|
|
31
|
+
placeholder="••••••••">
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<button type="submit" class="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition duration-300 shadow-lg shadow-indigo-600/30 flex items-center justify-center gap-2">
|
|
35
|
+
<i data-lucide="log-in" class="w-4 h-4"></i> Sign In
|
|
36
|
+
</button>
|
|
37
|
+
</form>
|
|
38
|
+
|
|
39
|
+
<div class="relative z-10 mt-6 text-center">
|
|
40
|
+
<p class="text-white/40 text-xs">Belum punya akun admin?
|
|
41
|
+
<a href="/auth/register" class="text-indigo-400 hover:text-white transition font-medium">Register di sini</a>
|
|
42
|
+
</p>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<div class="glass max-w-md mx-auto rounded-3xl p-8 animate__animated animate__fadeIn relative overflow-hidden">
|
|
2
|
+
<div class="absolute inset-0 bg-emerald-500/10 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div>
|
|
3
|
+
|
|
4
|
+
<div class="relative z-10 text-center mb-8">
|
|
5
|
+
<div class="w-16 h-16 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-white/20 shadow-xl">
|
|
6
|
+
<i data-lucide="user-plus" class="w-8 h-8 text-emerald-300"></i>
|
|
7
|
+
</div>
|
|
8
|
+
<h1 class="text-3xl font-extrabold text-white tracking-tight mb-2">Registrasi Admin</h1>
|
|
9
|
+
<p class="text-white/50 text-sm">Buat akun untuk mengelola Eksa Framework.</p>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<% if @admin_exists %>
|
|
13
|
+
<div class="relative z-10 text-center bg-emerald-500/20 border border-emerald-500/30 rounded-xl p-6 text-emerald-200">
|
|
14
|
+
<i data-lucide="badge-check" class="w-12 h-12 mx-auto mb-3 text-emerald-400"></i>
|
|
15
|
+
<h2 class="text-lg font-bold mb-2 text-white">Akun Admin Sudah Dibuat</h2>
|
|
16
|
+
<p class="text-sm opacity-80 text-white/70">Hanya satu akun admin yang diizinkan untuk keamanan CMS.</p>
|
|
17
|
+
</div>
|
|
18
|
+
<% else %>
|
|
19
|
+
|
|
20
|
+
<% if @flash && @flash[:notice] %>
|
|
21
|
+
<div class="relative z-10 mb-6 p-4 rounded-xl bg-red-500/20 border border-red-500/30 text-red-200 text-sm text-center font-medium animate__animated animate__shakeX">
|
|
22
|
+
<i data-lucide="alert-circle" class="w-4 h-4 inline-block mr-1 -mt-0.5"></i>
|
|
23
|
+
<%= @flash[:notice] %>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<form action="/auth/process_register" method="post" class="relative z-10 space-y-4">
|
|
28
|
+
<div>
|
|
29
|
+
<label class="block text-xs font-bold text-emerald-300 uppercase tracking-widest mb-2">Username</label>
|
|
30
|
+
<input type="text" name="username" required
|
|
31
|
+
class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition placeholder-white/20"
|
|
32
|
+
placeholder="Pilih username...">
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div>
|
|
36
|
+
<label class="block text-xs font-bold text-emerald-300 uppercase tracking-widest mb-2">Password</label>
|
|
37
|
+
<input type="password" name="password" required minlength="6"
|
|
38
|
+
class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition placeholder-white/20"
|
|
39
|
+
placeholder="Minimal 6 karakter">
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<button type="submit" class="w-full mt-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3 px-4 rounded-xl transition duration-300 shadow-lg shadow-emerald-600/30 flex items-center justify-center gap-2">
|
|
43
|
+
<i data-lucide="shield-check" class="w-4 h-4"></i> Buat Akun
|
|
44
|
+
</button>
|
|
45
|
+
</form>
|
|
46
|
+
<% end %>
|
|
47
|
+
|
|
48
|
+
<div class="relative z-10 mt-6 text-center">
|
|
49
|
+
<p class="text-white/40 text-xs">Sudah punya akun?
|
|
50
|
+
<a href="/auth/login" class="text-emerald-400 hover:text-white transition font-medium">Login sekarang</a>
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|