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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions app/controllers/admin/devlog_imports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module Admin
class DevlogImportsController < Admin::ApplicationController
def new
authorize :admin, :import_devlogs?
end

def create
authorize :admin, :import_devlogs?

json_input = params[:devlog_json].to_s.strip
if json_input.blank?
flash.now[:alert] = "JSON input is required"
return render :new, status: :unprocessable_entity
end

dry_run = params[:dry_run] == "1"
service = DevlogImportService.new(json_input, dry_run: dry_run)
result = service.call

if result.success?
if dry_run
flash[:notice] = "Dry run passed — JSON is valid and all images are reachable."
else
flash[:notice] = "Created #{result.devlogs.size} devlog(s) successfully."
end
redirect_to new_admin_devlog_import_path
else
flash.now[:alert] = result.errors.join("\n")
@json_input = json_input
render :new, status: :unprocessable_entity
end
end
end
end
4 changes: 4 additions & 0 deletions app/policies/admin_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,8 @@ def manage_shop?
def manage_draft_shop_items?
user.admin? || user.shop_manager?
end

def import_devlogs?
user.admin?
end
end
155 changes: 155 additions & 0 deletions app/services/devlog_import_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
class DevlogImportService
ALLOWED_HOSTS = %w[
raw.githubusercontent.com
github.com
user-images.githubusercontent.com
dl.airtableusercontent.com
v5.airtableusercontent.com
].freeze

ALLOWED_CONTENT_TYPES = %w[image/jpeg image/png image/webp image/gif].freeze
MAX_IMAGE_SIZE = 50.megabytes

MAGIC_BYTES = {
"image/png" => "\x89PNG".b,
"image/jpeg" => "\xFF\xD8\xFF".b,
"image/gif" => "GIF".b,
"image/webp" => "RIFF".b
}.freeze

EXTENSIONS = { "image/png" => ".png", "image/jpeg" => ".jpg", "image/webp" => ".webp", "image/gif" => ".gif" }.freeze

Result = Struct.new(:success?, :devlogs, :errors, keyword_init: true)

def initialize(json_string, dry_run: false)
@entries = JSON.parse(json_string)
@dry_run = dry_run
@errors = []
@created = []
rescue JSON::ParserError => e
@entries = nil
@errors = [ "Invalid JSON: #{e.message}" ]
end

def call
return failure if @entries.nil?
return failure("JSON must be an array of objects") unless @entries.is_a?(Array) && @entries.all?(Hash)

validate_entries
return failure if @errors.any?
return Result.new(success?: true, devlogs: [], errors: []) if @dry_run

import_entries
end

private

def failure(msg = nil)
@errors << msg if msg
Result.new(success?: false, devlogs: [], errors: @errors)
end

def validate_entries
@entries.each_with_index do |entry, i|
label = "Entry ##{i + 1}"
%w[project_id body hours image].each { |f| @errors << "#{label}: missing '#{f}'" if entry[f].blank? }

@errors << "#{label}: hours must be >= 0.25" if entry["hours"].present? && entry["hours"].to_f < 0.25

if entry["image"].present?
uri = URI.parse(entry["image"]) rescue nil
if uri.nil?
@errors << "#{label}: invalid image URL"
elsif !uri.is_a?(URI::HTTPS)
@errors << "#{label}: image URL must be HTTPS"
elsif !ALLOWED_HOSTS.include?(uri.host)
@errors << "#{label}: image host '#{uri.host}' not in allowlist"
end
end

@errors << "#{label}: project #{entry['project_id']} not found" if entry["project_id"].present? && !Project.exists?(id: entry["project_id"])
end
end

def import_entries
ActiveRecord::Base.transaction do
@entries.each_with_index do |entry, i|
result = import_single(entry, "Entry ##{i + 1}")
if result.is_a?(String)
@errors << result
raise ActiveRecord::Rollback
end
@created << result
end
end

return failure if @errors.any?

@created.map { |c| c[:project_id] }.uniq.each { |pid| Project.find(pid).recalculate_duration_seconds! }
Result.new(success?: true, devlogs: @created, errors: [])
end

def import_single(entry, label)
project = Project.find(entry["project_id"])
user = project.memberships.find_by(role: :owner)&.user || project.memberships.first&.user
return "#{label}: no user found for project #{project.id}" unless user

image_data = download_image(entry["image"], label)
return image_data if image_data.is_a?(String)

devlog = Post::Devlog.new(
body: entry["body"],
duration_seconds: (entry["hours"].to_f * 3600).to_i,
phase: project.hardware_stage,
hackatime_projects_key_snapshot: "journal-import"
)
devlog.uploading_attachments = true
devlog.save!
devlog.attachments.attach(image_data)

post = Post.create!(project: project, user: user, postable: devlog)

if entry["created_at"].present?
timestamp = Time.parse(entry["created_at"])
devlog.update_columns(created_at: timestamp, updated_at: timestamp)
post.update_columns(created_at: timestamp, updated_at: timestamp)
end

PaperTrail::Version.create!(
item_type: "Post::Devlog", item_id: devlog.id,
event: "journal_import", whodunnit: user.id,
object_changes: { source: "journal_import", hours: entry["hours"], image_url: entry["image"], created_at: entry["created_at"] }.to_yaml
)

{ devlog_id: devlog.id, post_id: post.id, hours: entry["hours"], project_id: project.id }
end

def download_image(url, label)
uri = URI.parse(url)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) { |http| http.request(Net::HTTP::Get.new(uri)) }

return "#{label}: failed to download image (HTTP #{response.code})" unless response.is_a?(Net::HTTPSuccess)
return "#{label}: image too large (#{response.body.bytesize} bytes)" if response.body.bytesize > MAX_IMAGE_SIZE

content_type = response["content-type"]&.split(";")&.first&.strip
detected_type = MAGIC_BYTES.find { |_, magic| response.body.byteslice(0, magic.length) == magic }&.first
return "#{label}: file magic bytes don't match any supported image format" unless detected_type

if content_type.nil? || content_type == "application/octet-stream"
content_type = detected_type
elsif !ALLOWED_CONTENT_TYPES.include?(content_type)
return "#{label}: disallowed content type '#{content_type}'"
elsif detected_type != content_type
return "#{label}: content type mismatch (header: #{content_type}, magic: #{detected_type})"
end

tempfile = Tempfile.new([ "devlog-import", EXTENSIONS[content_type] || ".bin" ])
tempfile.binmode
tempfile.write(response.body)
tempfile.rewind

{ io: tempfile, filename: File.basename(uri.path).presence || "image#{EXTENSIONS[content_type]}", content_type: content_type }
rescue => e
"#{label}: image download error: #{e.message}"
end
end
1 change: 1 addition & 0 deletions app/views/admin/application/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
<%= admin_page_button "Blazer", admin_blazer_path, enabled: policy(:admin).access_blazer? %>
<%= admin_page_button "Flipper", admin_flipper_path, enabled: policy(:admin).access_flipper? %>
<%= admin_page_button "Jobs", admin_mission_control_jobs_path, enabled: policy(:admin).access_jobs? %>
<%= admin_page_button "Import Devlogs", new_admin_devlog_import_path, enabled: policy(:admin).import_devlogs? %>
</div>
</section>
</div>
25 changes: 25 additions & 0 deletions app/views/admin/devlog_imports/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<% content_for :title, "Import Devlogs" %>

<h1>Import Devlogs from Journal</h1>

<div class="card mb-3">
<h3>How to use</h3>
<p>Paste the JSON array generated by the journal parser. Each entry creates a devlog on the specified project, backdated to the given date.</p>
<details>
<summary>JSON schema & allowed hosts</summary>
<pre><code>[{ "project_id": 12, "body": "...", "hours": 2.5, "image": "https://...", "created_at": "2025-06-30" }]</code></pre>
<p><strong>Allowed image hosts:</strong> <%= DevlogImportService::ALLOWED_HOSTS.join(", ") %></p>
</details>
</div>

<%= form_with url: admin_devlog_imports_path, method: :post, local: true do |f| %>
<div class="card">
<label for="devlog_json">Devlog JSON</label>
<%= text_area_tag :devlog_json, @json_input, rows: 20, class: "form-control font-mono", placeholder: "Paste JSON array here..." %>

<div class="mt-3 d-flex gap-2">
<%= button_tag "Validate (dry run)", type: :submit, name: "dry_run", value: "1", class: "btn-secondary" %>
<%= button_tag "Import devlogs", type: :submit, class: "btn-primary", data: { confirm: "This will create devlogs on the specified projects. Are you sure?" } %>
</div>
</div>
<% end %>
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@
end
resources :messages, only: [ :index, :create ]
resources :email_templates, only: [ :index, :create, :destroy ]
resources :devlog_imports, only: [ :new, :create ]
resources :audit_logs, only: [ :index, :show ]
resources :fulfillment_payouts, only: [ :index, :show ] do
member do
Expand Down
Loading