global state model
This commit is contained in:
1
TODO.md
1
TODO.md
@@ -2,3 +2,4 @@
|
||||
|
||||
- [ ] Add bookmarking feature for posts across different domains
|
||||
- [ ] Add search feature to search FA descriptions, tags, E621 descriptions, tags
|
||||
- [ ] Get inkbunny index scan job working
|
||||
|
||||
56
app/controllers/global_states_controller.rb
Normal file
56
app/controllers/global_states_controller.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class GlobalStatesController < ApplicationController
|
||||
before_action :set_global_state, only: %i[edit update destroy]
|
||||
after_action :verify_authorized
|
||||
|
||||
def index
|
||||
authorize GlobalState
|
||||
@global_states = policy_scope(GlobalState).order(:key)
|
||||
end
|
||||
|
||||
def new
|
||||
@global_state = GlobalState.new
|
||||
authorize @global_state
|
||||
end
|
||||
|
||||
def create
|
||||
@global_state = GlobalState.new(global_state_params)
|
||||
authorize @global_state
|
||||
if @global_state.save
|
||||
redirect_to global_states_path,
|
||||
notice: "Global state was successfully created."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @global_state
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @global_state
|
||||
if @global_state.update(global_state_params)
|
||||
redirect_to global_states_path,
|
||||
notice: "Global state was successfully updated."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @global_state
|
||||
@global_state.destroy
|
||||
redirect_to global_states_path,
|
||||
notice: "Global state was successfully deleted."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_global_state
|
||||
@global_state = GlobalState.find(params[:id])
|
||||
end
|
||||
|
||||
def global_state_params
|
||||
params.require(:global_state).permit(:key, :value, :value_type)
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@ interface UserMenuProps {
|
||||
editProfilePath: string;
|
||||
signOutPath: string;
|
||||
csrfToken: string;
|
||||
globalStatesPath: string;
|
||||
}
|
||||
|
||||
export const UserMenu: React.FC<UserMenuProps> = ({
|
||||
@@ -15,6 +16,7 @@ export const UserMenu: React.FC<UserMenuProps> = ({
|
||||
editProfilePath,
|
||||
signOutPath,
|
||||
csrfToken,
|
||||
globalStatesPath,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -82,6 +84,16 @@ export const UserMenu: React.FC<UserMenuProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userRole === 'admin' && (
|
||||
<a
|
||||
href={globalStatesPath}
|
||||
className="flex w-full items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-100"
|
||||
>
|
||||
<i className="fas fa-cogs mr-2 w-5" />
|
||||
<span>Global State</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={editProfilePath}
|
||||
className="flex w-full items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-100"
|
||||
|
||||
@@ -84,8 +84,7 @@ class Scraper::ClientFactory
|
||||
end
|
||||
|
||||
def self._get_ib_client_sid(client)
|
||||
sid_key = "sid-#{Rails.application.config.x.proxy}"
|
||||
sid = Domain::Inkbunny::GlobalState.find_or_create_by!(key: sid_key)
|
||||
sid = GlobalState.find_or_create_by!(key: "inkbunny-sid")
|
||||
sid.with_lock("FOR UPDATE") do
|
||||
if sid.value.blank?
|
||||
sid.value = _ib_login(client)
|
||||
|
||||
@@ -38,7 +38,7 @@ class Scraper::HttpClient
|
||||
|
||||
unless allowed_domain?(uri.host)
|
||||
raise InvalidURLError.new(
|
||||
"domain not permitted: #{uri.host} - allowed: #{config.allowed_domains}"
|
||||
"domain not permitted: #{uri.host} - allowed: #{config.allowed_domains}",
|
||||
)
|
||||
end
|
||||
|
||||
@@ -63,8 +63,8 @@ class Scraper::HttpClient
|
||||
request_headers = {
|
||||
"cookie" =>
|
||||
HTTP::Cookie.cookie_value(
|
||||
@cookie_jar.cookies(Addressable::URI.encode url)
|
||||
)
|
||||
@cookie_jar.cookies(Addressable::URI.encode url),
|
||||
),
|
||||
}
|
||||
requested_at = Time.now
|
||||
response = @http_performer.do_request(method, url, request_headers)
|
||||
@@ -85,7 +85,7 @@ class Scraper::HttpClient
|
||||
response_blob_entry =
|
||||
BlobEntryP.find_or_build(
|
||||
content_type: content_type,
|
||||
contents: response_body
|
||||
contents: response_body,
|
||||
)
|
||||
|
||||
scrubbed_uri = @config.scrub_stored_uri(uri)
|
||||
@@ -104,8 +104,8 @@ class Scraper::HttpClient
|
||||
response_time_ms: response_time_ms,
|
||||
requested_at: requested_at,
|
||||
caused_by_entry: caused_by_entry,
|
||||
performed_by: @http_performer.name
|
||||
}
|
||||
performed_by: "direct",
|
||||
},
|
||||
)
|
||||
|
||||
# double write blob_file while migrating
|
||||
@@ -140,8 +140,8 @@ class Scraper::HttpClient
|
||||
"GET #{response_code_colorized} /",
|
||||
"#{HexUtil.humansize(response_blob_entry.bytes_stored).bold} / #{HexUtil.humansize(response_blob_entry.size).bold}]",
|
||||
"[#{response_time_ms.to_s.bold} ms / #{total_time_ms.to_s.bold} ms]",
|
||||
scrubbed_uri.to_s.black
|
||||
].reject(&:nil?).join(" ")
|
||||
scrubbed_uri.to_s.black,
|
||||
].reject(&:nil?).join(" "),
|
||||
)
|
||||
|
||||
if response_code == 524 || response_code == 502 || response_code == 503 ||
|
||||
@@ -177,7 +177,7 @@ class Scraper::HttpClient
|
||||
name: cookie_key_conf[:name],
|
||||
value: cookie_key_conf[:value],
|
||||
path: cookie_key_conf[:path] || "",
|
||||
for_domain: true
|
||||
for_domain: true,
|
||||
}
|
||||
@cookie_jar.add(HTTP::Cookie.new(conf))
|
||||
end
|
||||
|
||||
@@ -3,9 +3,9 @@ class Scraper::InkbunnyHttpClientConfig < Scraper::HttpClientConfig
|
||||
|
||||
def do_login(performer)
|
||||
sid_model =
|
||||
Domain::Inkbunny::GlobalState.find_or_create_by(
|
||||
key: "#{performer.name}-sid"
|
||||
)
|
||||
GlobalState.find_or_create_by(key: "inkbunny-sid") do |gs|
|
||||
gs.value_type = :string
|
||||
end
|
||||
sid_model.with_lock do
|
||||
if sid_model.value.blank?
|
||||
sid_model.value = do_ib_login(performer)
|
||||
@@ -56,13 +56,11 @@ class Scraper::InkbunnyHttpClientConfig < Scraper::HttpClientConfig
|
||||
|
||||
def do_ib_login(performer)
|
||||
username =
|
||||
Domain::Inkbunny::GlobalState.find_by(
|
||||
key: "#{performer.name}-username"
|
||||
)&.value || raise("missing inkbunny username in global state")
|
||||
GlobalState.find_by(key: "inkbunny-username")&.value ||
|
||||
raise("missing inkbunny username in global state")
|
||||
password =
|
||||
Domain::Inkbunny::GlobalState.find_by(
|
||||
key: "#{performer.name}-password"
|
||||
)&.value || raise("missing inkbunny password in global state")
|
||||
GlobalState.find_by(key: "inkbunny-password")&.value ||
|
||||
raise("missing inkbunny password in global state")
|
||||
uri =
|
||||
"https://inkbunny.net/api_login.php?username=#{username}&password=#{password}"
|
||||
logger.info("logging in to inkbunny as #{username}...")
|
||||
@@ -76,7 +74,7 @@ class Scraper::InkbunnyHttpClientConfig < Scraper::HttpClientConfig
|
||||
sid =
|
||||
JSON.parse(response.body)["sid"] ||
|
||||
raise(
|
||||
"inkbunny login failed: no sid in response (#{response.body[0..1000]})"
|
||||
"inkbunny login failed: no sid in response (#{response.body[0..1000]})",
|
||||
)
|
||||
logger.info("logged in to inkbunny as #{username}: #{sid}")
|
||||
sid
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class Domain::Inkbunny::GlobalState < ReduxApplicationRecord
|
||||
self.table_name = "domain_inkbunny_global_states"
|
||||
# key: string (unique)
|
||||
# value: string
|
||||
end
|
||||
51
app/models/global_state.rb
Normal file
51
app/models/global_state.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
class GlobalState < ReduxApplicationRecord
|
||||
validates :key, presence: true, uniqueness: true
|
||||
validates :value, presence: true
|
||||
validates :value_type, presence: true
|
||||
|
||||
enum :value_type,
|
||||
{ string: 0, counter: 1, duration: 2, password: 3 },
|
||||
prefix: :value_type
|
||||
|
||||
validate :validate_value_format
|
||||
|
||||
def self.get(key)
|
||||
find_by(key: key)&.value
|
||||
end
|
||||
|
||||
def self.set(key, value)
|
||||
record = find_or_initialize_by(key: key)
|
||||
record.value = value
|
||||
record.save!
|
||||
value
|
||||
end
|
||||
|
||||
def display_value
|
||||
case value_type.to_sym
|
||||
when :password
|
||||
"••••••••"
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_value_format
|
||||
return unless value_type.present?
|
||||
|
||||
case value_type.to_sym
|
||||
when :counter
|
||||
unless value.match?(/\A-?\d+\z/)
|
||||
errors.add(:value, "must be a valid integer for counter type")
|
||||
end
|
||||
when :duration
|
||||
unless value.match?(/\A\d+[smhd]\z/)
|
||||
errors.add(
|
||||
:value,
|
||||
"must be a valid duration (e.g., '30s', '5m', '2h', '1d')",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
35
app/policies/global_state_policy.rb
Normal file
35
app/policies/global_state_policy.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class GlobalStatePolicy < ApplicationPolicy
|
||||
def index?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def show?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def create?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def update?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def new?
|
||||
create?
|
||||
end
|
||||
|
||||
def edit?
|
||||
update?
|
||||
end
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
user.admin? ? scope.all : scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
67
app/views/global_states/_form.html.erb
Normal file
67
app/views/global_states/_form.html.erb
Normal file
@@ -0,0 +1,67 @@
|
||||
<%= form_with(model: global_state, class: "space-y-4") do |form| %>
|
||||
<% if global_state.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-circle text-red-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<%= pluralize(global_state.errors.count, "error") %> prohibited this global state from being saved:
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
<% global_state.errors.each do |error| %>
|
||||
<li><%= error.full_message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= form.label :key, class: "block text-sm font-medium text-slate-700" %>
|
||||
<div class="mt-1">
|
||||
<%= form.text_field :key, class: "block w-full rounded-md border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm" %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-500">A unique identifier for this global state.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :value_type, class: "block text-sm font-medium text-slate-700" %>
|
||||
<div class="mt-1">
|
||||
<%= form.select(
|
||||
:value_type,
|
||||
GlobalState.value_types.keys.map { |t| [t.titleize, t] },
|
||||
{},
|
||||
class: "block w-full rounded-md border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm",
|
||||
) %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
The type of value:
|
||||
<ul class="list-disc pl-5">
|
||||
<li><strong>String:</strong> Any text value</li>
|
||||
<li><strong>Count:</strong> A whole number (e.g., "42")</li>
|
||||
<li><strong>Duration:</strong> Time duration with unit (e.g., "30s", "5m", "2h", "1d")</li>
|
||||
<li><strong>Password:</strong> Sensitive data that will be masked in the UI</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :value, class: "block text-sm font-medium text-slate-700" %>
|
||||
<div class="mt-1">
|
||||
<%= form.text_field :value, class: "block w-full rounded-md border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm" %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-500">The value to store, format depends on the selected type.</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-5">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<%= link_to "Cancel", global_states_path, class: "rounded-md border border-slate-300 bg-white py-2 px-4 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2" %>
|
||||
<%= form.submit class: "inline-flex justify-center rounded-md border border-transparent bg-sky-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
25
app/views/global_states/edit.html.erb
Normal file
25
app/views/global_states/edit.html.erb
Normal file
@@ -0,0 +1,25 @@
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-6 md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2
|
||||
class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl"
|
||||
>
|
||||
Edit Global State: <%= @global_state.key %>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-4 flex md:ml-4 md:mt-0">
|
||||
<%= link_to "Back to Global States",
|
||||
global_states_path,
|
||||
class:
|
||||
"inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= render "form", global_state: @global_state %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
113
app/views/global_states/index.html.erb
Normal file
113
app/views/global_states/index.html.erb
Normal file
@@ -0,0 +1,113 @@
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Global States</h1>
|
||||
<p class="mt-2 text-sm text-slate-700">
|
||||
A list of all global states in the application.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<%= link_to new_global_state_path,
|
||||
class:
|
||||
"inline-flex items-center justify-center rounded-md border border-transparent bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 sm:w-auto" do %>
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
New Global State
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div
|
||||
class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-slate-300">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-slate-900 sm:pl-6"
|
||||
>
|
||||
Key
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-slate-900"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-slate-900"
|
||||
>
|
||||
Value
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-slate-900"
|
||||
>
|
||||
Last Updated
|
||||
</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 bg-white">
|
||||
<% @global_states.each do |global_state| %>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-slate-900 sm:pl-6"
|
||||
>
|
||||
<%= global_state.key %>
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-slate-500"
|
||||
>
|
||||
<span
|
||||
class="<%= if global_state.value_type_password?
|
||||
"bg-red-100 text-red-800"
|
||||
else
|
||||
"bg-sky-100 text-sky-800"
|
||||
end %> inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
>
|
||||
<%= global_state.value_type.titleize %>
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-slate-500"
|
||||
>
|
||||
<%= global_state.display_value %>
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-slate-500"
|
||||
>
|
||||
<%= time_ago_in_words(global_state.updated_at) %> ago
|
||||
</td>
|
||||
<td
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
|
||||
>
|
||||
<%= link_to "Edit",
|
||||
edit_global_state_path(global_state),
|
||||
class: "text-sky-600 hover:text-sky-900 mr-3" %>
|
||||
<%= button_to "Delete",
|
||||
global_state_path(global_state),
|
||||
method: :delete,
|
||||
class: "text-red-600 hover:text-red-900 inline",
|
||||
form: {
|
||||
class: "inline",
|
||||
data: {
|
||||
turbo_confirm: "Are you sure?",
|
||||
},
|
||||
} %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
25
app/views/global_states/new.html.erb
Normal file
25
app/views/global_states/new.html.erb
Normal file
@@ -0,0 +1,25 @@
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-6 md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2
|
||||
class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl"
|
||||
>
|
||||
New Global State
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-4 flex md:ml-4 md:mt-0">
|
||||
<%= link_to "Back to Global States",
|
||||
global_states_path,
|
||||
class:
|
||||
"inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= render "form", global_state: @global_state %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,7 +33,8 @@
|
||||
userRole: current_user.role,
|
||||
editProfilePath: edit_user_registration_path,
|
||||
signOutPath: destroy_user_session_path,
|
||||
csrfToken: form_authenticity_token
|
||||
csrfToken: form_authenticity_token,
|
||||
globalStatesPath: global_states_path
|
||||
}) %>
|
||||
<% else %>
|
||||
<%= link_to new_user_session_path, class: "text-slate-600 hover:text-slate-900" do %>
|
||||
|
||||
@@ -88,4 +88,6 @@ Rails.application.routes.draw do
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
resources :global_states, path: "state"
|
||||
end
|
||||
|
||||
14
db/migrate/20241230004356_create_global_states.rb
Normal file
14
db/migrate/20241230004356_create_global_states.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class CreateGlobalStates < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
drop_table :domain_inkbunny_global_states
|
||||
|
||||
create_table :global_states do |t|
|
||||
t.string :key, null: false
|
||||
t.string :value
|
||||
|
||||
t.index :key, unique: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddValueTypeToGlobalStates < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :global_states, :value_type, :integer, null: false, default: 0
|
||||
end
|
||||
end
|
||||
18
db/schema.rb
generated
18
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_12_29_001358) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_12_30_005956) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "hstore"
|
||||
enable_extension "intarray"
|
||||
@@ -1591,13 +1591,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_29_001358) do
|
||||
t.index ["follower_id"], name: "index_domain_inkbunny_follows_on_follower_id"
|
||||
end
|
||||
|
||||
create_table "domain_inkbunny_global_states", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.string "value"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "domain_inkbunny_pool_joins", force: :cascade do |t|
|
||||
t.bigint "post_id", null: false
|
||||
t.bigint "pool_id", null: false
|
||||
@@ -1841,6 +1834,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_29_001358) do
|
||||
t.index ["key"], name: "index_flat_sst_entries_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "global_states", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.string "value"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "value_type", default: 0, null: false
|
||||
t.index ["key"], name: "index_global_states_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
||||
12
rake/ib.rake
12
rake/ib.rake
@@ -19,12 +19,12 @@ namespace :ib do
|
||||
password = $stdin.gets.chomp
|
||||
end
|
||||
|
||||
Domain::Inkbunny::GlobalState.find_or_create_by(
|
||||
key: "direct-username"
|
||||
).update!(value: username)
|
||||
Domain::Inkbunny::GlobalState.find_or_create_by(
|
||||
key: "direct-password"
|
||||
).update!(value: password)
|
||||
GlobalState
|
||||
.find_or_create_by(key: "inkbunny-username")
|
||||
.update!(value: username) { |gs| gs.value_type = :string }
|
||||
GlobalState
|
||||
.find_or_create_by(key: "inkbunny-password")
|
||||
.update!(value: password) { |gs| gs.value_type = :password }
|
||||
|
||||
puts "auth credentials set to #{username} / #{password}"
|
||||
end
|
||||
|
||||
106
spec/controllers/global_states_controller_spec.rb
Normal file
106
spec/controllers/global_states_controller_spec.rb
Normal file
@@ -0,0 +1,106 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe GlobalStatesController, type: :controller do
|
||||
let(:admin_user) { create(:user, role: :admin) }
|
||||
let(:valid_attributes) do
|
||||
{ key: "test_key", value: "test_value", value_type: "string" }
|
||||
end
|
||||
let(:invalid_attributes) { { key: "", value: "", value_type: "" } }
|
||||
let(:global_state) { create(:global_state, valid_attributes) }
|
||||
|
||||
before { sign_in admin_user }
|
||||
|
||||
describe "GET #index" do
|
||||
it "returns a success response" do
|
||||
get :index
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET #new" do
|
||||
it "returns a success response" do
|
||||
get :new
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST #create" do
|
||||
context "with valid params" do
|
||||
it "creates a new GlobalState" do
|
||||
expect {
|
||||
post :create, params: { global_state: valid_attributes }
|
||||
}.to change(GlobalState, :count).by(1)
|
||||
end
|
||||
|
||||
it "redirects to the global states list" do
|
||||
post :create, params: { global_state: valid_attributes }
|
||||
expect(response).to redirect_to(global_states_path)
|
||||
end
|
||||
end
|
||||
|
||||
context "with invalid params" do
|
||||
it "returns unprocessable entity status" do
|
||||
post :create, params: { global_state: invalid_attributes }
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET #edit" do
|
||||
it "returns a success response" do
|
||||
get :edit, params: { id: global_state.to_param }
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT #update" do
|
||||
context "with valid params" do
|
||||
let(:new_attributes) { { value: "new_value" } }
|
||||
|
||||
it "updates the requested global_state" do
|
||||
put :update,
|
||||
params: {
|
||||
id: global_state.to_param,
|
||||
global_state: new_attributes,
|
||||
}
|
||||
global_state.reload
|
||||
expect(global_state.value).to eq("new_value")
|
||||
end
|
||||
|
||||
it "redirects to the global states list" do
|
||||
put :update,
|
||||
params: {
|
||||
id: global_state.to_param,
|
||||
global_state: new_attributes,
|
||||
}
|
||||
expect(response).to redirect_to(global_states_path)
|
||||
end
|
||||
end
|
||||
|
||||
context "with invalid params" do
|
||||
it "returns unprocessable entity status" do
|
||||
put :update,
|
||||
params: {
|
||||
id: global_state.to_param,
|
||||
global_state: invalid_attributes,
|
||||
}
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE #destroy" do
|
||||
let!(:global_state) { create(:global_state) }
|
||||
|
||||
it "destroys the requested global_state" do
|
||||
expect {
|
||||
delete :destroy, params: { id: global_state.to_param }
|
||||
}.to change(GlobalState, :count).by(-1)
|
||||
end
|
||||
|
||||
it "redirects to the global_states list" do
|
||||
delete :destroy, params: { id: global_state.to_param }
|
||||
expect(response).to redirect_to(global_states_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
7
spec/factories/global_states.rb
Normal file
7
spec/factories/global_states.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
FactoryBot.define do
|
||||
factory :global_state do
|
||||
sequence(:key) { |n| "key_#{n}" }
|
||||
value { "test_value" }
|
||||
value_type { :string }
|
||||
end
|
||||
end
|
||||
@@ -4,42 +4,45 @@ describe Scraper::InkbunnyHttpClientConfig do
|
||||
subject { described_class.new }
|
||||
|
||||
let!(:username_model) do
|
||||
Domain::Inkbunny::GlobalState.create!(
|
||||
key: "test-username",
|
||||
value: "the-user"
|
||||
create(
|
||||
:global_state,
|
||||
key: "inkbunny-username",
|
||||
value: "the-user",
|
||||
value_type: :string,
|
||||
)
|
||||
end
|
||||
let!(:password_model) do
|
||||
Domain::Inkbunny::GlobalState.create!(
|
||||
key: "test-password",
|
||||
value: "the-password"
|
||||
create(
|
||||
:global_state,
|
||||
key: "inkbunny-password",
|
||||
value: "the-password",
|
||||
value_type: :password,
|
||||
)
|
||||
end
|
||||
let(:performer) do
|
||||
SpecUtil.mock_http_performer(
|
||||
:post,
|
||||
"https://inkbunny.net/api_login.php?username=the-user&password=the-password",
|
||||
performer_name: "test",
|
||||
response_headers: {
|
||||
"content-type" => "application/json"
|
||||
"content-type" => "application/json",
|
||||
},
|
||||
response_body: '{"sid": "the-sid"}'
|
||||
response_body: '{"sid": "the-sid"}',
|
||||
)
|
||||
end
|
||||
|
||||
describe "#do_login" do
|
||||
context "sid not stored" do
|
||||
it "logs in the user and stores sid" do
|
||||
expect(Domain::Inkbunny::GlobalState.find_by(key: "test-sid")).to be_nil
|
||||
expect(GlobalState.find_by(key: "inkbunny-sid")).to be_nil
|
||||
subject.do_login(performer)
|
||||
sid = Domain::Inkbunny::GlobalState.find_by(key: "test-sid")
|
||||
sid = GlobalState.find_by(key: "inkbunny-sid")
|
||||
expect(sid.value).to eq("the-sid")
|
||||
end
|
||||
end
|
||||
|
||||
context "sid stored" do
|
||||
it "does not log in the user" do
|
||||
Domain::Inkbunny::GlobalState.create!(key: "test-sid", value: "the-sid")
|
||||
create(:global_state, key: "inkbunny-sid", value: "the-sid")
|
||||
subject.do_login(performer)
|
||||
expect(performer).to_not have_received(:do_request)
|
||||
end
|
||||
@@ -47,16 +50,14 @@ describe Scraper::InkbunnyHttpClientConfig do
|
||||
end
|
||||
|
||||
describe "#map_uri" do
|
||||
before do
|
||||
Domain::Inkbunny::GlobalState.create!(key: "test-sid", value: "the-sid")
|
||||
end
|
||||
before { create(:global_state, key: "inkbunny-sid", value: "the-sid") }
|
||||
|
||||
it "raises if not logged in yet" do
|
||||
expect do
|
||||
subject.map_uri(
|
||||
URI.parse(
|
||||
"https://inkbunny.net/api_search.php?orderby=create_datetime"
|
||||
)
|
||||
"https://inkbunny.net/api_search.php?orderby=create_datetime",
|
||||
),
|
||||
)
|
||||
end.to raise_error("no sid")
|
||||
end
|
||||
@@ -66,13 +67,13 @@ describe Scraper::InkbunnyHttpClientConfig do
|
||||
uri =
|
||||
subject.map_uri(
|
||||
URI.parse(
|
||||
"https://inkbunny.net/api_search.php?orderby=create_datetime"
|
||||
)
|
||||
"https://inkbunny.net/api_search.php?orderby=create_datetime",
|
||||
),
|
||||
)
|
||||
expect(uri).to eq(
|
||||
URI.parse(
|
||||
"https://inkbunny.net/api_search.php?orderby=create_datetime&sid=the-sid"
|
||||
)
|
||||
"https://inkbunny.net/api_search.php?orderby=create_datetime&sid=the-sid",
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -82,13 +83,13 @@ describe Scraper::InkbunnyHttpClientConfig do
|
||||
uri =
|
||||
subject.scrub_stored_uri(
|
||||
URI.parse(
|
||||
"https://inkbunny.net/api_login.php?username=foo&password=bar"
|
||||
)
|
||||
"https://inkbunny.net/api_login.php?username=foo&password=bar",
|
||||
),
|
||||
)
|
||||
expect(uri).to eq(
|
||||
URI.parse(
|
||||
"https://inkbunny.net/api_login.php?username=*****&password=*****"
|
||||
)
|
||||
"https://inkbunny.net/api_login.php?username=*****&password=*****",
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
139
spec/models/global_state_spec.rb
Normal file
139
spec/models/global_state_spec.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe GlobalState, type: :model do
|
||||
describe "validations" do
|
||||
subject do
|
||||
build(
|
||||
:global_state,
|
||||
key: "test_key",
|
||||
value: "test_value",
|
||||
value_type: "string",
|
||||
)
|
||||
end
|
||||
|
||||
it { should validate_presence_of(:key) }
|
||||
it { should validate_uniqueness_of(:key) }
|
||||
it { should validate_presence_of(:value) }
|
||||
it { should validate_presence_of(:value_type) }
|
||||
|
||||
it do
|
||||
should define_enum_for(:value_type).with_values(
|
||||
string: 0,
|
||||
counter: 1,
|
||||
duration: 2,
|
||||
password: 3,
|
||||
).with_prefix(:value_type)
|
||||
end
|
||||
|
||||
context "when value_type is counter" do
|
||||
subject { build(:global_state, value_type: :counter) }
|
||||
|
||||
it "is valid with a positive numeric value" do
|
||||
subject.value = "123"
|
||||
expect(subject).to be_valid
|
||||
end
|
||||
|
||||
it "is valid with a negative numeric value" do
|
||||
subject.value = "-42"
|
||||
expect(subject).to be_valid
|
||||
end
|
||||
|
||||
it "is valid with zero" do
|
||||
subject.value = "0"
|
||||
expect(subject).to be_valid
|
||||
end
|
||||
|
||||
it "is invalid with a non-numeric value" do
|
||||
subject.value = "abc"
|
||||
expect(subject).not_to be_valid
|
||||
expect(subject.errors[:value]).to include(
|
||||
"must be a valid integer for counter type",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "when value_type is duration" do
|
||||
subject { build(:global_state, value_type: :duration) }
|
||||
|
||||
it "is valid with a proper duration format" do
|
||||
%w[30s 5m 2h 1d].each do |duration|
|
||||
subject.value = duration
|
||||
expect(subject).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
it "is invalid with an improper duration format" do
|
||||
subject.value = "invalid"
|
||||
expect(subject).not_to be_valid
|
||||
expect(subject.errors[:value]).to include(
|
||||
"must be a valid duration (e.g., '30s', '5m', '2h', '1d')",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "enum methods" do
|
||||
it "generates predicate methods with prefix" do
|
||||
state = build(:global_state, value_type: :password)
|
||||
expect(state.value_type_password?).to be true
|
||||
expect(state.value_type_string?).to be false
|
||||
expect(state.value_type_counter?).to be false
|
||||
expect(state.value_type_duration?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe "#display_value" do
|
||||
it "masks password values" do
|
||||
state =
|
||||
build(
|
||||
:global_state,
|
||||
key: "key_3",
|
||||
value: "secret123",
|
||||
value_type: :password,
|
||||
)
|
||||
expect(state.display_value).to eq("••••••••")
|
||||
end
|
||||
|
||||
it "shows regular values for non-password types" do
|
||||
%i[string counter duration].each do |type|
|
||||
state =
|
||||
build(:global_state, key: "key_4", value: "test123", value_type: type)
|
||||
expect(state.display_value).to eq("test123")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".get" do
|
||||
it "returns the value for the given key" do
|
||||
create(:global_state, key: "test_key", value: "test_value")
|
||||
expect(GlobalState.get("test_key")).to eq("test_value")
|
||||
end
|
||||
|
||||
it "returns nil for non-existent key" do
|
||||
expect(GlobalState.get("non_existent")).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe ".set" do
|
||||
it "creates a new record if key does not exist" do
|
||||
expect { GlobalState.set("new_key", "new_value") }.to change(
|
||||
GlobalState,
|
||||
:count,
|
||||
).by(1)
|
||||
|
||||
state = GlobalState.find_by(key: "new_key")
|
||||
expect(state.value).to eq("new_value")
|
||||
end
|
||||
|
||||
it "updates existing record if key exists" do
|
||||
state = create(:global_state, key: "test_key", value: "old_value")
|
||||
|
||||
expect { GlobalState.set("test_key", "new_value") }.not_to change(
|
||||
GlobalState,
|
||||
:count,
|
||||
)
|
||||
|
||||
expect(state.reload.value).to eq("new_value")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -14,12 +14,10 @@ class SpecUtil
|
||||
response_code: 200,
|
||||
response_time_ms: 15,
|
||||
response_headers: { "content-type" => "text/plain" },
|
||||
response_body: "http body",
|
||||
performer_name: "direct"
|
||||
response_body: "http body"
|
||||
)
|
||||
mock = instance_double("Scraper::CurlHttpPerformer")
|
||||
allow(mock).to receive(:is_a?).with(String).and_return(false)
|
||||
allow(mock).to receive(:name).and_return(performer_name)
|
||||
allow(mock).to receive(:do_request).with(
|
||||
expected_method,
|
||||
expected_url,
|
||||
|
||||
Reference in New Issue
Block a user