Merge branch 'master' into logs_search

pull/1356/head
Rick Carlino 2019-08-07 08:22:49 -05:00 committed by GitHub
commit 4c44a3c9e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1114 additions and 646 deletions

4
.gitignore vendored
View File

@ -13,7 +13,6 @@ package-lock.json
public/assets/
public/direct_upload/temp/*.jpg
public/dist
public/system
tmp
api_docs.md
erd_diagram.png
@ -22,8 +21,11 @@ erd.pdf
scratchpad.rb
/config/master.key
config/credentials.yml.enc
# ActiveStorage blobs:
storage/*
# === Legacy
public/system
config/application.yml
coverage
mqtt/

18
Gemfile
View File

@ -7,47 +7,39 @@ gem "delayed_job_active_record" # TODO: Get off of SQL backed jobs. Use Redis
gem "delayed_job"
gem "devise"
gem "discard"
gem "figaro"
gem "fog-google"
gem "font-awesome-rails"
gem "foreman"
gem "google-cloud-storage", "~> 1.11", require: false
gem "jwt"
gem "mutations"
gem "paperclip"
gem "pg"
gem "polymorphic_constraints"
gem "rabbitmq_http_api_client"
gem "rack-attack"
gem "rack-cors"
gem "rails_12factor"
gem "rails"
gem "redis", "~> 4.0"
gem "request_store"
gem "rollbar"
gem "scenic"
gem "secure_headers"
gem "tzinfo" # For validation of user selected timezone names
gem "valid_url"
gem "rabbitmq_http_api_client"
gem "zero_downtime_migrations"
gem "redis", "~> 4.0"
group :development, :test do
gem "thin"
gem "climate_control"
gem "codecov", require: false
gem "simplecov"
gem "database_cleaner"
gem "factory_bot_rails"
gem "faker"
gem "hashdiff"
gem "letter_opener"
gem "lol_dba"
gem "pry-rails"
gem "pry"
gem "rails-erd"
gem "rspec-rails"
gem "rspec"
gem "selenium-webdriver"
gem "simplecov"
gem "smarf_doc", git: "https://github.com/RickCarlino/smarf_doc.git"
gem "climate_control"
end
group :production do

View File

@ -65,7 +65,6 @@ GEM
activesupport
childprocess (1.0.1)
rake (< 13.0)
choice (0.2.0)
climate_control (0.2.0)
codecov (0.1.14)
json
@ -74,7 +73,6 @@ GEM
coderay (1.1.2)
concurrent-ruby (1.1.5)
crass (1.0.4)
daemons (1.3.1)
database_cleaner (1.7.0)
declarative (0.0.10)
declarative-option (0.1.0)
@ -90,12 +88,11 @@ GEM
responders
warden (~> 1.2.3)
diff-lcs (1.3)
digest-crc (0.4.1)
discard (1.1.0)
activerecord (>= 4.2, < 7)
docile (1.3.2)
erubi (1.8.0)
eventmachine (1.2.7)
excon (0.64.0)
factory_bot (5.0.2)
activesupport (>= 4.2.0)
factory_bot_rails (5.0.2)
@ -107,47 +104,37 @@ GEM
multipart-post (>= 1.2, < 3)
faraday_middleware (0.13.1)
faraday (>= 0.7.4, < 1.0)
figaro (1.1.1)
thor (~> 0.14)
fog-core (2.1.0)
builder
excon (~> 0.58)
formatador (~> 0.2)
mime-types
fog-google (1.9.1)
fog-core (<= 2.1.0)
fog-json (~> 1.2)
fog-xml (~> 0.1.0)
google-api-client (~> 0.23.0)
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
fog-xml (0.1.3)
fog-core
nokogiri (>= 1.5.11, < 2.0.0)
font-awesome-rails (4.7.0.5)
railties (>= 3.2, < 6.1)
foreman (0.85.0)
thor (~> 0.19.1)
formatador (0.2.5)
globalid (0.4.2)
activesupport (>= 4.2.0)
google-api-client (0.23.9)
google-api-client (0.30.7)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.5, < 0.7.0)
googleauth (>= 0.5, < 0.10.0)
httpclient (>= 2.8.1, < 3.0)
mime-types (~> 3.0)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
signet (~> 0.9)
googleauth (0.6.7)
signet (~> 0.10)
google-cloud-core (1.3.0)
google-cloud-env (~> 1.0)
google-cloud-env (1.2.0)
faraday (~> 0.11)
google-cloud-storage (1.19.0)
addressable (~> 2.5)
digest-crc (~> 0.4)
google-api-client (~> 0.26)
google-cloud-core (~> 1.2)
googleauth (>= 0.6.2, < 0.10.0)
mime-types (~> 3.0)
googleauth (0.8.1)
faraday (~> 0.12)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (~> 0.7)
hashdiff (0.4.0)
hashdiff (1.0.0)
hashie (3.6.0)
httpclient (2.8.3)
i18n (1.6.0)
@ -155,14 +142,6 @@ GEM
json (2.2.0)
jsonapi-renderer (0.2.2)
jwt (2.2.1)
launchy (2.4.3)
addressable (~> 2.3)
letter_opener (1.7.0)
launchy (~> 2.2)
lol_dba (2.1.8)
actionpack (>= 3.0, < 6.0)
activerecord (>= 3.0, < 6.0)
railties (>= 3.0, < 6.0)
loofah (2.2.3)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
@ -188,18 +167,10 @@ GEM
mini_portile2 (~> 2.4.0)
orm_adapter (0.5.0)
os (1.0.1)
paperclip (6.1.0)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
mime-types
mimemagic (~> 0.3.0)
terrapin (~> 0.6.0)
passenger (6.0.2)
rack
rake (>= 0.8.1)
pg (1.1.4)
polymorphic_constraints (1.0.0)
rails
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
@ -233,11 +204,6 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-erd (1.6.0)
activerecord (>= 4.2)
activesupport (>= 4.2)
choice (~> 0.2.0)
ruby-graphviz (~> 1.2)
rails-html-sanitizer (1.0.4)
loofah (~> 2.2, >= 2.2.2)
rails_12factor (0.0.3)
@ -251,7 +217,7 @@ GEM
method_source
rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0)
rake (12.3.2)
rake (12.3.3)
redis (4.1.2)
representable (3.0.4)
declarative (< 0.1.0)
@ -285,7 +251,6 @@ GEM
rspec-mocks (~> 3.8.0)
rspec-support (~> 3.8.0)
rspec-support (3.8.2)
ruby-graphviz (1.2.4)
rubyzip (1.2.3)
scenic (1.5.1)
activerecord (>= 4.0.0)
@ -311,13 +276,7 @@ GEM
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0)
thin (1.7.2)
daemons (~> 1.0, >= 1.0.9)
eventmachine (~> 1.0, >= 1.0.4)
rack (>= 1, < 3)
thor (0.19.4)
thor (0.20.3)
thread_safe (0.3.6)
tzinfo (1.2.5)
thread_safe (~> 0.1)
@ -349,26 +308,19 @@ DEPENDENCIES
discard
factory_bot_rails
faker
figaro
fog-google
font-awesome-rails
foreman
google-cloud-storage (~> 1.11)
hashdiff
jwt
letter_opener
lol_dba
mutations
paperclip
passenger
pg
polymorphic_constraints
pry
pry-rails
rabbitmq_http_api_client
rack-attack
rack-cors
rails
rails-erd
rails_12factor
redis (~> 4.0)
request_store
@ -380,7 +332,6 @@ DEPENDENCIES
selenium-webdriver
simplecov
smarf_doc!
thin
tzinfo
valid_url
zero_downtime_migrations

View File

@ -2,4 +2,4 @@ worker: bundle exec rake jobs:work
rabbit_workers: bin/rails r lib/rabbit_workers.rb
web: bundle exec passenger start -p $PORT -e $RAILS_ENV --max-pool-size 2
# This will perform a hard refresh on all connected browsers.
release: rails r "User.delay(run_at: 5.minutes.from_now).refresh_everyones_ui"
release: rails r "User.refresh_everyones_ui"

9
SECURITY.md 100644
View File

@ -0,0 +1,9 @@
# Security Policy
## Supported Versions
Please see our [official support policy](http://support-policy.farm.bot).
## Reporting a Vulnerability
Please see our [guidelines for responsibly disclosing security vulnerabilities](http://vulnerabilities.farm.bot/).

View File

@ -24,7 +24,7 @@ module Api
after_action :skip_set_cookies_header
rescue_from(CeleryScript::TypeCheckError) do |err|
sorry err.message, 422
sorry err.message
end
rescue_from(ActionController::RoutingError) { sorry "Not found", 404 }
@ -32,25 +32,25 @@ module Api
rescue_from(JWT::VerificationError) { |e| auth_err }
rescue_from(ActionDispatch::Http::Parameters::ParseError) { sorry NOT_JSON, 422 }
rescue_from(ActionDispatch::Http::Parameters::ParseError) { sorry NOT_JSON }
rescue_from(ActiveRecord::ValueTooLong) do
sorry "Please use reasonable lengths on string inputs", 422
sorry "Please use reasonable lengths on string inputs"
end
rescue_from Errors::Forbidden do |exc|
sorry "You can't perform that action. #{exc.message}", 403
end
ONLY_JSON = "This is a JSON API. "\
ONLY_JSON = "This is a JSON API. " \
"Please use a _valid_ JSON object or array. " \
"Validate JSON objects at https://jsonlint.com/"
rescue_from OnlyJson do |e|
sorry ONLY_JSON, 422
sorry ONLY_JSON
end
rescue_from Errors::NoBot do |exc|
sorry "You need to register a device first.", 422
sorry "You need to register a device first."
end
rescue_from ActiveRecord::RecordNotFound do |exc|
@ -67,9 +67,14 @@ module Api
rescue_from ActiveModel::RangeError do |_|
sorry "One of those numbers was too big/small. " +
"If you need larger numbers, let us know.", 422
"If you need larger numbers, let us know."
end
TOO_MUCH_DATA = "The resource exceeds database limits. " \
"Please reduce the amount of data stored in a single resource"
rescue_from(PG::ProgramLimitExceeded) { sorry TOO_MUCH_DATA }
def default_serializer_options
{ root: false, user: current_user }
end
@ -147,7 +152,7 @@ module Api
" provide a JSON Web Token in the `Authorization:` header.", 401)
end
def sorry(msg, status)
def sorry(msg, status = 422)
render json: { error: msg }, status: status
end

View File

@ -9,8 +9,8 @@ module Api
attr_reader :cache
CACHE_KEY_TPL = "mqtt_limiter:%s"
TTL = 60 * 10 # Ten Minutes
PER_DEVICE_MAX = 10
TTL = 60 * 5 # Five Minutes
PER_DEVICE_MAX = 20
MAX_GUEST_COUNT = 256
WARNING = "'%s' was rate limited."

View File

@ -37,6 +37,7 @@ class ApplicationRecord < ActiveRecord::Base
def self.auto_sync_debounce
@auto_sync_paused = true
result = yield
result.update_attributes!(updated_at: Time.now)
@auto_sync_paused = false
result.broadcast!
result

View File

@ -74,7 +74,10 @@ module CeleryScriptSettingsBag
RESOURCE_UPDATE_ARGS = [:resource_type, :resource_id, :label, :value]
SCOPE_DECLARATIONS = [:variable_declaration, :parameter_declaration]
MISC_ENUM_ERR = '"%s" is not valid. Allowed values: %s'
MAX_WAIT_MS = 1000 * 60 * 3 # Three Minutes
MAX_WAIT_MS_EXCEEDED =
"A single wait node cannot exceed #{MAX_WAIT_MS / 1000 / 60} minutes. " +
"Consider lowering the wait time or using multiple WAIT blocks."
Corpus = CeleryScript::Corpus.new
CORPUS_VALUES = {
@ -453,6 +456,11 @@ module CeleryScriptSettingsBag
wait: {
args: [:milliseconds],
tags: [:function],
blk: ->(node) do
ms_arg = node.args[:milliseconds]
ms = (ms_arg && ms_arg.value) || 0
node.invalidate!(MAX_WAIT_MS_EXCEEDED) if ms > MAX_WAIT_MS
end,
},
zero: {
args: [:axis],

View File

@ -2,8 +2,8 @@ require "open-uri"
# A set of image URLs (thumbs) + Associated meta data.
class Image < ApplicationRecord
belongs_to :device
validates :device, presence: true
serialize :meta
validates :device, presence: true
serialize :meta
# http://stackoverflow.com/a/5127684/1064917
after_initialize :set_defaults
@ -12,37 +12,45 @@ class Image < ApplicationRecord
self.meta ||= {}
end
PROTO = ENV["FORCE_SSL"] ? "https:" : "http:"
PLACEHOLDER = "/placeholder_farmbot.jpg\?text=Processing..."
CONFIG = {
default_url: "#{PROTO}#{$API_URL}#{PLACEHOLDER}",
styles: { x1280: "1280x1280>",
x640: "640x640>",
x320: "320x320>",
x160: "160x160>",
x80: "80x80>" },
size: { in: 0..7.megabytes } # Worst case scenario for 1280x1280 BMP.
PROTO = ENV["FORCE_SSL"] ? "https:" : "http:"
DEFAULT_URL = "#{PROTO}#{$API_URL}#{PLACEHOLDER}"
RMAGICK_STYLES = {
x1280: "1280x1280>",
x640: "640x640>",
x320: "320x320>",
x160: "160x160>",
x80: "80x80>",
}
MAX_IMAGE_SIZE = 7.megabytes
CONFIG = { default_url: DEFAULT_URL,
styles: RMAGICK_STYLES,
size: { in: 0..MAX_IMAGE_SIZE } }
BUCKET = ENV["GCS_BUCKET"]
bucket = ENV["GCS_BUCKET"]
CONFIG.merge!({
storage: :fog,
fog_host: "http://#{bucket}.storage.googleapis.com",
fog_directory: bucket,
fog_credentials: { provider: "Google",
google_storage_access_key_id: ENV.fetch("GCS_KEY"),
google_storage_secret_access_key: ENV.fetch("GCS_ID")}
}) if bucket
ROOT_PATH = BUCKET ?
"https://#{BUCKET}.storage.googleapis.com" : "/system"
IMAGE_URL_TPL =
ROOT_PATH + "/images/attachments/%{chunks}/%{size}/%{filename}?%{timestamp}"
has_attached_file :attachment, CONFIG
CONTENT_TYPES = ["image/jpg", "image/jpeg", "image/png", "image/gif"]
GCS_ACCESS_KEY_ID = ENV.fetch("GCS_KEY") { puts "Not using Google Cloud" }
GCS_HOST = "http://#{BUCKET}.storage.googleapis.com"
GCS_SECRET_ACCESS_KEY = ENV.fetch("GCS_ID") { puts "Not using Google Cloud" }
# Worst case scenario for 1280x1280 BMP.
GCS_BUCKET_NAME = ENV["GCS_BUCKET"]
validates_attachment_content_type :attachment,
content_type: ["image/jpg", "image/jpeg", "image/png", "image/gif"]
# ========= DEPRECATED PAPERCLIP STUFF =========
# has_attached_file :attachment, CONFIG
# validates_attachment_content_type :attachment,
# content_type: CONTENT_TYPES
# ========= /DEPRECATED PAPERCLIP STUFF ========
has_one_attached :attachment
def set_attachment_by_url(url)
# File
# URI::HTTPS
self.attachment = open(url)
attachment.attach(io: open(url), filename: "image_#{self.id}")
self.attachment_processed_at = Time.now
self
end
@ -59,4 +67,47 @@ class Image < ApplicationRecord
image = find_by(id: id)
image.destroy! if image
end
def legacy_image?
!!self.attachment_file_size # This is a now-unused legacy field.
end
def regular_image?
attachment && attachment.attached?
end
def regular_url
if BUCKET
# Not sure why. TODO: Investigate why Rails URL helpers don't work here.
"https://storage.googleapis.com/#{BUCKET}/#{attachment.key}"
else
Rails
.application
.routes
.url_helpers
.rails_blob_url(attachment)
end
end
def legacy_url(size)
url = IMAGE_URL_TPL % {
chunks: id.to_s.rjust(9, "0").scan(/.{3}/).join("/"),
filename: attachment_file_name,
size: size,
timestamp: attachment_updated_at.to_i,
}
return ENV["GCS_KEY"].present? ? url.gsub("http://", "https://") : url
end
def attachment_url(size = "x640")
# Detect legacy attachments by way of
# superceded PaperClip-related field.
# If it has an `attachment_file_size`,
# it was made with paperclip.
return regular_url if regular_image?
return legacy_url(size) if legacy_image?
return DEFAULT_URL
end
end

View File

@ -1,50 +1,57 @@
require "google/cloud/storage"
require "google/cloud/storage/file"
module Images
class GeneratePolicy < Mutations::Command
BUCKET = ENV.fetch("GCS_BUCKET") { "YOU_MUST_CONFIG_GOOGLE_CLOUD_STORAGE" }
KEY = ENV.fetch("GCS_KEY") { "YOU_MUST_CONFIG_GCS_KEY" }
SECRET = ENV.fetch("GCS_ID") { "YOU_MUST_CONFIG_GCS_ID" }
BUCKET_NAME = ENV.fetch("GCS_BUCKET") { "YOU_MUST_CONFIG_GOOGLE_CLOUD_STORAGE" }
JSON_KEY = ENV["GOOGLE_CLOUD_KEYFILE_JSON"]
BUCKET = JSON_KEY && Google::Cloud::Storage.new.bucket(BUCKET_NAME)
HMM = "GCS NOT SETUP!"
# # Is there a better way to reach in and grab the ActiveStorage configs?
# CONFIG = YAML.load(ERB.new(File.read("config/storage.yml")).result(binding)).fetch("google")
def execute
{
verb: "POST",
url: "//storage.googleapis.com/#{BUCKET}/",
verb: "POST",
url: "//storage.googleapis.com/#{BUCKET_NAME || HMM}/",
form_data: {
"key" => random_filename,
"acl" => "public-read",
"Content-Type" => "image/jpeg",
"policy" => policy,
"signature" => policy_signature,
"GoogleAccessId" => KEY,
"file" => "REPLACE_THIS_WITH_A_BINARY_JPEG_FILE"
"key" => file_path,
"acl" => "public-read",
"Content-Type" => "image/jpeg",
"policy" => post_object[:policy] || HMM,
"signature" => post_object[:signature] || HMM,
"GoogleAccessId" => post_object[:GoogleAccessId] || HMM,
"file" => "REPLACE_THIS_WITH_A_BINARY_JPEG_FILE",
},
instructions: "Send a 'from-data' request to the URL provided."\
"Then POST the resulting URL as an 'attachment_url' "\
"(json) to api/images/."
instructions: "Send a 'from-data' request to the URL provided." \
"Then POST the resulting URL as an 'attachment_url' " \
"(json) to api/images/.",
}
end
private
# The image URL in the "untrusted bucket" in Google Cloud Storage
def random_filename
@range ||= "temp1/#{SecureRandom.uuid}.jpg"
private
def post_object
@post_object ||= BUCKET ?
BUCKET.post_object(file_path, policy: policy).fields : {}
end
def policy
@policy ||= Base64.encode64(
{ 'expiration' => 1.hour.from_now.utc.xmlschema,
'conditions' => [
{ 'bucket' => BUCKET },
{ 'key' => random_filename},
{ 'acl' => 'public-read' },
{ 'Content-Type' => "image/jpeg"},
['content-length-range', 1, 7.megabytes]
]}.to_json).gsub(/\n/, '')
@policy ||= {
expiration: (Time.now + 1.hour).utc.xmlschema,
conditions: [
{ bucket: BUCKET_NAME },
{ key: file_path },
{ acl: "public-read" },
[:eq, "$Content-Type", "image/jpeg"],
["content-length-range", 1, 7.megabytes],
],
}
end
def policy_signature
@policy_signature ||= Base64.encode64(
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'),
SECRET,
policy)).gsub("\n",'')
# The image URL in the "untrusted bucket" in Google Cloud Storage
def file_path
@range ||= "temp1/#{SecureRandom.uuid}.jpg"
end
end
end

View File

@ -4,7 +4,7 @@ module Sequences
using CanonicalCeleryHelpers
required do
model :device, class: Device
model :device, class: Device
string :name
body
end
@ -19,18 +19,21 @@ module Sequences
end
def execute
ActiveRecord::Base.transaction do
p = inputs
.merge(migrated_nodes: true)
.without(:body, :args, "body", "args")
seq = Sequence.create!(p)
x = CeleryScript::FirstPass.run!(sequence: seq,
scope_hoist = {}
Sequence.auto_sync_debounce do
ActiveRecord::Base.transaction do
p = inputs
.merge(migrated_nodes: true)
.without(:body, :args, "body", "args")
seq = Sequence.create!(p)
x = CeleryScript::FirstPass.run!(sequence: seq,
args: args || {},
body: body || [])
result = CeleryScript::FetchCelery.run!(sequence: seq)
seq.manually_sync! # We must manually sync this resource.
result
scope_hoist[:result] = CeleryScript::FetchCelery.run!(sequence: seq)
seq
end
end
scope_hoist[:result]
end
end
end

View File

@ -37,22 +37,23 @@ module Sequences
end
def execute
ActiveRecord::Base.transaction do
sequence.migrated_nodes = true
sequence.update_attributes!(inputs.except(*BLACKLIST))
CeleryScript::StoreCelery.run!(sequence: sequence,
args: args,
body: body)
Sequence.auto_sync_debounce do
ActiveRecord::Base.transaction do
sequence.migrated_nodes = true
sequence.update_attributes!(inputs.except(*BLACKLIST))
CeleryScript::StoreCelery.run!(sequence: sequence,
args: args,
body: body)
end
sequence
end
sequence.manually_sync! # We must manually sync this resource.
CeleryScript::FetchCelery
.run!(sequence: sequence, args: args, body: body)
CeleryScript::FetchCelery.run!(sequence: sequence, args: args, body: body)
end
BASE = "Can't add 'parent' to sequence because "
EXPL = {
FarmEvent => BASE + "it is in use by FarmEvents on these dates: %{items}",
Regimen => BASE + "the following Regimen(s) are using it: %{items}",
Regimen => BASE + "the following Regimen(s) are using it: %{items}",
}
end
end

View File

@ -1,9 +1,3 @@
class ImageSerializer < ApplicationSerializer
attributes :device_id, :attachment_processed_at, :attachment_url, :meta
def attachment_url
url_ = object.attachment.url("x640")
# Force google cloud users to use HTTPS://
return ENV["GCS_KEY"].present? ? url_.gsub("http://", "https://") : url_
end
end

View File

@ -12,6 +12,10 @@ module FarmBot
Delayed::Worker.max_attempts = 4
REDIS_ENV_KEY = ENV.fetch("WHERE_IS_REDIS_URL", "REDIS_URL")
REDIS_URL = ENV.fetch(REDIS_ENV_KEY, "redis://redis:6379/0")
gcs_enabled =
%w[ GOOGLE_CLOUD_KEYFILE_JSON GCS_PROJECT GCS_BUCKET ].all? { |s| ENV.key? s }
config.active_storage.service = gcs_enabled ?
:google : :local
config.cache_store = :redis_cache_store, { url: REDIS_URL }
config.middleware.use Rack::Attack
config.active_record.schema_format = :sql

View File

@ -1 +0,0 @@
Paperclip.io_adapters.register(Paperclip::StringioAdapter)

View File

@ -13,6 +13,10 @@ class Rack::Attack
throttle("demo_accounts/ip", limit: 10, period: 10.minutes) do |req|
req.ip if req.path.downcase == "/demo"
end
throttle("password_resets/ip", limit: 3, period: 1.hour) do |req|
req.ip if req.path.downcase == "api/password_resets"
end
end
# Always allow requests from localhost

10
config/storage.yml 100644
View File

@ -0,0 +1,10 @@
# Self-hosters, CI servers, etc:
local:
service: Disk
root: <%= Rails.root.join("storage") %>
google:
service: GCS
credentials: <%= ENV["GOOGLE_CLOUD_KEYFILE_JSON"] %>
project: <%= ENV["GCS_PROJECT"] %>
bucket: <%= ENV["GCS_BUCKET"] %>

View File

@ -0,0 +1,27 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
def change
create_table :active_storage_blobs do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.bigint :byte_size, null: false
t.string :checksum, null: false
t.datetime :created_at, null: false
t.index [ :key ], unique: true
end
create_table :active_storage_attachments do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false
t.references :blob, null: false
t.datetime :created_at, null: false
t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
end

View File

@ -43,6 +43,74 @@ SET default_tablespace = '';
SET default_with_oids = false;
--
-- Name: active_storage_attachments; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.active_storage_attachments (
id bigint NOT NULL,
name character varying NOT NULL,
record_type character varying NOT NULL,
record_id bigint NOT NULL,
blob_id bigint NOT NULL,
created_at timestamp without time zone NOT NULL
);
--
-- Name: active_storage_attachments_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.active_storage_attachments_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: active_storage_attachments_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.active_storage_attachments_id_seq OWNED BY public.active_storage_attachments.id;
--
-- Name: active_storage_blobs; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.active_storage_blobs (
id bigint NOT NULL,
key character varying NOT NULL,
filename character varying NOT NULL,
content_type character varying,
metadata text,
byte_size bigint NOT NULL,
checksum character varying NOT NULL,
created_at timestamp without time zone NOT NULL
);
--
-- Name: active_storage_blobs_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.active_storage_blobs_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: active_storage_blobs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.active_storage_blobs_id_seq OWNED BY public.active_storage_blobs.id;
--
-- Name: alerts; Type: TABLE; Schema: public; Owner: -
--
@ -1651,6 +1719,20 @@ CREATE SEQUENCE public.webcam_feeds_id_seq
ALTER SEQUENCE public.webcam_feeds_id_seq OWNED BY public.webcam_feeds.id;
--
-- Name: active_storage_attachments id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.active_storage_attachments ALTER COLUMN id SET DEFAULT nextval('public.active_storage_attachments_id_seq'::regclass);
--
-- Name: active_storage_blobs id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.active_storage_blobs ALTER COLUMN id SET DEFAULT nextval('public.active_storage_blobs_id_seq'::regclass);
--
-- Name: alerts id; Type: DEFAULT; Schema: public; Owner: -
--
@ -1917,6 +1999,22 @@ ALTER TABLE ONLY public.web_app_configs ALTER COLUMN id SET DEFAULT nextval('pub
ALTER TABLE ONLY public.webcam_feeds ALTER COLUMN id SET DEFAULT nextval('public.webcam_feeds_id_seq'::regclass);
--
-- Name: active_storage_attachments active_storage_attachments_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.active_storage_attachments
ADD CONSTRAINT active_storage_attachments_pkey PRIMARY KEY (id);
--
-- Name: active_storage_blobs active_storage_blobs_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.active_storage_blobs
ADD CONSTRAINT active_storage_blobs_pkey PRIMARY KEY (id);
--
-- Name: alerts alerts_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -2244,6 +2342,27 @@ ALTER TABLE ONLY public.webcam_feeds
CREATE INDEX delayed_jobs_priority ON public.delayed_jobs USING btree (priority, run_at);
--
-- Name: index_active_storage_attachments_on_blob_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_active_storage_attachments_on_blob_id ON public.active_storage_attachments USING btree (blob_id);
--
-- Name: index_active_storage_attachments_uniqueness; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX index_active_storage_attachments_uniqueness ON public.active_storage_attachments USING btree (record_type, record_id, name, blob_id);
--
-- Name: index_active_storage_blobs_on_key; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX index_active_storage_blobs_on_key ON public.active_storage_blobs USING btree (key);
--
-- Name: index_alerts_on_device_id; Type: INDEX; Schema: public; Owner: -
--
@ -2818,6 +2937,14 @@ ALTER TABLE ONLY public.alerts
ADD CONSTRAINT fk_rails_c0132c78be FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: active_storage_attachments fk_rails_c3b3935057; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.active_storage_attachments
ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id);
--
-- Name: diagnostic_dumps fk_rails_c5df7fdc83; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -3021,6 +3148,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20190621202204'),
('20190701155706'),
('20190709194037'),
('20190715214412');
('20190715214412'),
('20190722160305');

View File

@ -187,6 +187,15 @@ fieldset {
}
}
.throttle-display {
.throttle-row {
display: flex;
.saucer {
margin-right: 1rem;
}
}
}
.wifi-strength-display {
position: relative;
.percent-bar {
@ -1020,6 +1029,14 @@ ul {
line-height: 1.75rem;
margin-bottom: 1rem;
}
p {
display: block;
color: $dark_gray;
text-overflow: inherit;
overflow: inherit;
width: inherit;
white-space: inherit;
}
}
.tools-widget,

View File

@ -69,6 +69,8 @@
}
.first-ticker {
height: 3rem;
overflow: hidden;
padding-bottom: 0.5rem;
background: $black;
.status-ticker-created-at {

View File

@ -264,8 +264,11 @@ describe("isLog()", function () {
it("filters sensitive logs", () => {
const log = { message: "NERVESPSKWPASSWORD" };
console.error = jest.fn();
const result = actions.isLog(log);
expect(result).toBe(false);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("Refusing to display log"));
});
});

View File

@ -33,8 +33,7 @@ export const FEATURE_MIN_VERSIONS_URL =
"https://raw.githubusercontent.com/FarmBot/farmbot_os/staging/" +
"FEATURE_MIN_VERSIONS.json";
// Already filtering messages in FarmBot OS and the API- this is just for
// an additional layer of safety. If sensitive data ever hits a client, it will
// be reported to Rollbar for investigation.
// an additional layer of safety.
const BAD_WORDS = ["WPA", "PSK", "PASSWORD", "NERVES"];
export function isLog(x: unknown): x is Log {

View File

@ -9,6 +9,7 @@ import { SourceFbosConfig, ShouldDisplay, Feature } from "../../interfaces";
import { ConfigurationName } from "farmbot";
import { t } from "../../../i18next_wrapper";
import { LastSeen } from "./last_seen_row";
import { Popover } from "@blueprintjs/core";
/** Return an indicator color for the given temperature (C). */
export const colorFromTemp = (temp: number | undefined): string => {
@ -28,10 +29,15 @@ export const colorFromTemp = (temp: number | undefined): string => {
}
};
interface ChipTemperatureDisplayProps {
chip?: string;
temperature: number | undefined;
}
/** RPI CPU temperature display row: label, temperature, indicator. */
export function ChipTemperatureDisplay({ chip, temperature }: {
chip?: string, temperature: number | undefined
}): JSX.Element {
export function ChipTemperatureDisplay(
{ chip, temperature }: ChipTemperatureDisplayProps
): JSX.Element {
return <div className="chip-temp-display">
<p>
<b>{chip && chip.toUpperCase()} {t("CPU temperature")}: </b>
@ -41,15 +47,20 @@ export function ChipTemperatureDisplay({ chip, temperature }: {
</div>;
}
interface WiFiStrengthDisplayProps {
wifiStrength: number | undefined;
wifiStrengthPercent?: number | undefined;
}
/** WiFi signal strength display row: label, strength, indicator. */
export function WiFiStrengthDisplay({ wifiStrength }: {
wifiStrength: number | undefined
}): JSX.Element {
export function WiFiStrengthDisplay(
{ wifiStrength, wifiStrengthPercent }: WiFiStrengthDisplayProps
): JSX.Element {
const percent = wifiStrength
? Math.round(-0.0154 * wifiStrength ** 2 - 0.4 * wifiStrength + 98)
: 0;
const dbString = `${wifiStrength || 0}dBm`;
const percentString = `${percent}%`;
const percentString = `${wifiStrengthPercent || percent}%`;
return <div className="wifi-strength-display">
<p>
<b>{t("WiFi Strength")}: </b>
@ -100,6 +111,36 @@ export const colorFromThrottle =
}
};
const THROTTLE_COLOR_KEY = () => ({
red: t("active"),
yellow: t("occurred"),
green: t("clear")
});
interface ThrottleIndicatorProps {
throttleDataString: string;
throttleType: ThrottleType;
}
/** Saucer with color and title indicating throttle state. */
const ThrottleIndicator = (props: ThrottleIndicatorProps) => {
const { throttleDataString, throttleType } = props;
const throttleColor = colorFromThrottle(throttleDataString, throttleType);
return <Saucer className={"small-inline"}
title={THROTTLE_COLOR_KEY()[throttleColor]}
color={throttleColor} />;
};
/** Visual representation of throttle state. */
const ThrottleDisplay = (dataString: string) =>
<div className="throttle-display">
{Object.keys(THROTTLE_BIT_LOOKUP).map((key: ThrottleType) =>
<div className="throttle-row" key={key}>
<ThrottleIndicator throttleDataString={dataString} throttleType={key} />
<p>{key}</p>
</div>)}
</div>;
interface VoltageDisplayProps {
chip?: string;
throttled: string | undefined;
@ -112,17 +153,27 @@ export const VoltageDisplay = ({ chip, throttled }: VoltageDisplayProps) =>
<p>
<b>{chip && chip.toUpperCase()} {t("Voltage")}: </b>
</p>
<Saucer className={"small-inline"}
color={colorFromThrottle(throttled, ThrottleType.UnderVoltage)} />
<Popover usePortal={false}>
<ThrottleIndicator
throttleDataString={throttled}
throttleType={ThrottleType.UnderVoltage} />
{ThrottleDisplay(throttled)}
</Popover>
</div> : <div className="voltage-display" />;
/** Get the first 8 characters of a commit. */
const shortenCommit = (longCommit: string) => (longCommit || "").slice(0, 8);
interface CommitDisplayProps {
title: string;
repo: string;
commit: string;
}
/** GitHub commit display row: label, commit link. */
const CommitDisplay = ({ title, repo, commit }: {
title: string, repo: string, commit: string
}): JSX.Element => {
const CommitDisplay = (
{ title, repo, commit }: CommitDisplayProps
): JSX.Element => {
const shortCommit = shortenCommit(commit);
return <p>
<b>{title}: </b>
@ -136,8 +187,12 @@ const CommitDisplay = ({ title, repo, commit }: {
</p>;
};
interface UptimeDisplayProps {
uptime_sec: number;
}
/** FBOS uptime display row: label and uptime in relevant unit. */
const UptimeDisplay = ({ uptime_sec }: { uptime_sec: number }): JSX.Element => {
const UptimeDisplay = ({ uptime_sec }: UptimeDisplayProps): JSX.Element => {
const convertUptime = (seconds: number) => {
if (seconds >= 172800) {
return `${Math.round(seconds / 86400)} ${t("days")}`;
@ -152,9 +207,15 @@ const UptimeDisplay = ({ uptime_sec }: { uptime_sec: number }): JSX.Element => {
return <p><b>{t("Uptime")}: </b>{convertUptime(uptime_sec)}</p>;
};
export const betaReleaseOptIn = ({ sourceFbosConfig, shouldDisplay }: {
sourceFbosConfig: SourceFbosConfig, shouldDisplay: ShouldDisplay
}) => {
interface BetaReleaseOptInParams {
sourceFbosConfig: SourceFbosConfig;
shouldDisplay: ShouldDisplay;
}
/** Generate params for BetaReleaseOptInButton. */
export const betaReleaseOptIn = (
{ sourceFbosConfig, shouldDisplay }: BetaReleaseOptInParams
) => {
if (shouldDisplay(Feature.use_update_channel)) {
const betaOptIn = sourceFbosConfig("update_channel" as ConfigurationName);
const betaOptInValue = betaOptIn.value !== "stable";
@ -172,34 +233,39 @@ export const betaReleaseOptIn = ({ sourceFbosConfig, shouldDisplay }: {
}
};
interface BetaReleaseOptInButtonProps {
dispatch: Function;
sourceFbosConfig: SourceFbosConfig;
shouldDisplay: ShouldDisplay;
}
/** Label and toggle button for opting in to FBOS beta releases. */
const BetaReleaseOptInButton =
({ dispatch, sourceFbosConfig, shouldDisplay }: {
dispatch: Function,
sourceFbosConfig: SourceFbosConfig,
shouldDisplay: ShouldDisplay,
}): JSX.Element => {
const { betaOptIn, betaOptInValue, update } =
betaReleaseOptIn({ sourceFbosConfig, shouldDisplay });
return <fieldset>
<label style={{ marginTop: "0.75rem" }}>
{t("Beta release Opt-In")}
</label>
<ToggleButton
toggleValue={betaOptInValue}
dim={!betaOptIn.consistent}
toggleAction={() =>
(betaOptInValue || confirm(Content.OS_BETA_RELEASES)) &&
dispatch(updateConfig(update))} />
</fieldset>;
};
const BetaReleaseOptInButton = (
{ dispatch, sourceFbosConfig, shouldDisplay }: BetaReleaseOptInButtonProps
): JSX.Element => {
const { betaOptIn, betaOptInValue, update } =
betaReleaseOptIn({ sourceFbosConfig, shouldDisplay });
return <fieldset>
<label style={{ marginTop: "0.75rem" }}>
{t("Beta release Opt-In")}
</label>
<ToggleButton
toggleValue={betaOptInValue}
dim={!betaOptIn.consistent}
toggleAction={() =>
(betaOptInValue || confirm(Content.OS_BETA_RELEASES)) &&
dispatch(updateConfig(update))} />
</fieldset>;
};
/** Current technical information about FarmBot OS running on the device. */
export function FbosDetails(props: FbosDetailsProps) {
const {
env, commit, target, node_name, firmware_version, firmware_commit,
soc_temp, wifi_level, uptime, memory_usage, disk_usage, throttled
} = props.botInfoSettings;
soc_temp, wifi_level, uptime, memory_usage, disk_usage, throttled,
wifi_level_percent,
// tslint:disable-next-line:no-any
} = props.botInfoSettings as any;
return <div>
<LastSeen
@ -219,7 +285,8 @@ export function FbosDetails(props: FbosDetailsProps) {
<p><b>{t("Memory usage")}: </b>{memory_usage}MB</p>}
{isNumber(disk_usage) && <p><b>{t("Disk usage")}: </b>{disk_usage}%</p>}
<ChipTemperatureDisplay chip={target} temperature={soc_temp} />
<WiFiStrengthDisplay wifiStrength={wifi_level} />
<WiFiStrengthDisplay
wifiStrength={wifi_level} wifiStrengthPercent={wifi_level_percent} />
<VoltageDisplay chip={target} throttled={throttled} />
<BetaReleaseOptInButton
dispatch={props.dispatch}

View File

@ -23,16 +23,15 @@ import { isString, isFunction } from "lodash";
import { repeatOptions } from "../map_state_to_props_add_edit";
import { SpecialStatus, ParameterApplication } from "farmbot";
import moment from "moment";
import { fakeState } from "../../../__test_support__/fake_state";
import { history } from "../../../history";
import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
import { fakeVariableNameSet } from "../../../__test_support__/fake_variables";
import { clickButton } from "../../../__test_support__/helpers";
import { destroy } from "../../../api/crud";
import { destroy, save } from "../../../api/crud";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
import { error, success, warning } from "../../../toast/toast";
import { error, success } from "../../../toast/toast";
const mockSequence = fakeSequence();
@ -275,10 +274,14 @@ describe("<FarmEventForm/>", () => {
it("warns about missed regimen items", async () => {
const p = props();
const state = fakeState();
state.resources.index.references = { [p.farmEvent.uuid]: p.farmEvent };
p.dispatch = jest.fn(x => { isFunction(x) && x(); return Promise.resolve(); });
p.farmEvent.body.executable_type = "Regimen";
const regimen = fakeRegimen();
regimen.body.regimen_items = [
{ sequence_id: -1, time_offset: 0 },
{ sequence_id: -1, time_offset: 1000000000 },
];
p.findExecutable = () => regimen;
p.dispatch = jest.fn(x => { isFunction(x) && x(); return Promise.resolve(); });
p.farmEvent.body.start_time = "2017-05-22T05:00:00.000Z";
p.farmEvent.body.end_time = "2017-05-22T06:00:00.000Z";
const i = instance(p);
@ -334,14 +337,15 @@ describe("<FarmEventForm/>", () => {
expectStartTimeToBeRejected();
});
it("doesn't display error message on edit: start time has passed", () => {
it("displays error message on edit: start time has passed", () => {
const p = props();
p.title = "edit";
p.farmEvent.body.start_time = "2017-05-22T05:00:00.000Z";
p.farmEvent.body.end_time = "2017-05-22T06:00:00.000Z";
const i = instance(p);
i.commitViewModel(moment("2017-06-22T05:00:00.000Z"));
expect(error).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(expect.stringContaining(
"Nothing to run."), "Unable to save event.");
});
it("displays error message on save: no items", async () => {
@ -351,8 +355,22 @@ describe("<FarmEventForm/>", () => {
p.farmEvent.body.end_time = "2017-05-22T06:00:00.000Z";
const i = instance(p);
await i.commitViewModel(moment("2017-06-22T05:00:00.000Z"));
expect(warning).toHaveBeenCalledWith(expect.stringContaining(
"Nothing to run."));
expect(error).toHaveBeenCalledWith(expect.stringContaining(
"Nothing to run."), "Unable to save event.");
});
it("displays error message on save: save error", async () => {
const p = props();
p.dispatch = jest.fn()
.mockResolvedValueOnce("")
.mockRejectedValueOnce("error");
p.shouldDisplay = () => true;
p.farmEvent.body.start_time = "2017-07-22T05:00:00.000Z";
p.farmEvent.body.end_time = "2017-07-22T06:00:00.000Z";
const i = instance(p);
await i.commitViewModel(moment("2017-06-22T05:00:00.000Z"));
await expect(save).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith("Unable to save event.");
});
it("allows start time: edit with unsupported OS", () => {

View File

@ -1,11 +1,14 @@
import { maybeWarnAboutMissedTasks } from "../util";
import { fakeFarmEvent } from "../../../__test_support__/fake_state/resources";
import moment from "moment";
import { ExecutableType } from "farmbot/dist/resources/api_resources";
describe("maybeWarnAboutMissedTasks()", () => {
function testWarn(time: string): () => void {
function testWarn(
time: string, executableType: ExecutableType = "Regimen"
): () => void {
const callback = jest.fn();
const fe = fakeFarmEvent("Regimen", 1);
const fe = fakeFarmEvent(executableType, 1);
fe.body.start_time = "2017-05-21T22:00:00.000";
maybeWarnAboutMissedTasks(fe,
() => callback("missed event warning"),
@ -21,4 +24,9 @@ describe("maybeWarnAboutMissedTasks()", () => {
const cb = testWarn("2017-05-01T22:00:00.000");
expect(cb).not.toHaveBeenCalled();
});
it("doesn't warn when not a regimen", () => {
const cb = testWarn("2017-05-21T22:00:00.000", "Sequence");
expect(cb).not.toHaveBeenCalled();
});
});

View File

@ -1,6 +1,6 @@
import * as React from "react";
import moment from "moment";
import { success, error, warning } from "../../toast/toast";
import { success, error } from "../../toast/toast";
import {
TaggedFarmEvent, SpecialStatus, TaggedSequence, TaggedRegimen,
ParameterApplication
@ -94,6 +94,13 @@ const startTimeWarning = () => {
error(message, title);
};
const nothingToRunWarning = () => {
const message =
t("All items scheduled before the start time. Nothing to run.");
const title = t("Unable to save event.");
error(message, title);
};
type RecombineOptions = { forceRegimensToMidnight: boolean };
/** Take a FormViewModel and recombine the fields into a FarmEvent
@ -323,9 +330,8 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
return recombine(vm, opts);
}
/** Use the next item run time to display toast messages and return to
* the form if necessary. */
nextRunTimeActions = (now = moment()): boolean => {
/** Use the next item run time to display toast messages. */
nextRunTimeActions = (now = moment()) => {
const nextRun = this.nextItemTime(this.props.farmEvent.body, now);
if (nextRun) {
const nextRunText = this.props.autoSyncEnabled
@ -335,10 +341,6 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
you must first SYNC YOUR DEVICE. If you do not sync, the event will
not run.`.replace(/\s+/g, " "), { timeFromNow: nextRun.from(now) });
success(nextRunText);
return true;
} else {
warning(t("All items scheduled before the start time. Nothing to run."));
return false;
}
}
@ -346,24 +348,27 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
* - Regimen Farm Event:
* * If scheduled for today, warn about the possibility of missing tasks.
* * Display the start time difference from now and maybe prompt to sync.
* * Return to calendar view if items exist to be run past the start time.
* * Return to calendar view.
* - Sequence Farm Event:
* * Determine the time for the next item to be run.
* * If auto-sync is disabled, prompt the user to sync.
* * Return to calendar view only if more items exist to be run.
* * Return to calendar view.
*/
commitViewModel = (now = moment()) => {
if (this.maybeRejectStartTime(this.updatedFarmEvent)) {
return startTimeWarning();
}
if (!this.nextItemTime(this.updatedFarmEvent, now)) {
return nothingToRunWarning();
}
this.dispatch(overwrite(this.props.farmEvent, this.updatedFarmEvent));
this.dispatch(save(this.props.farmEvent.uuid))
.then(() => {
this.setState({ specialStatusLocal: SpecialStatus.SAVED });
this.dispatch(maybeWarnAboutMissedTasks(this.props.farmEvent,
() => alert(t(Content.REGIMEN_TODAY_SKIPPED_ITEM_RISK)), now));
const itemsScheduled = this.nextRunTimeActions(now);
if (itemsScheduled) { history.push("/app/designer/events"); }
this.nextRunTimeActions(now);
history.push("/app/designer/events");
})
.catch(() => {
error(t("Unable to save event."));

View File

@ -91,7 +91,7 @@ export function mapStateToProps(props: Everything): FarmwareProps {
shouldDisplay(Feature.api_farmware_installations) &&
taggedFarmwareInstallations.map(x => {
const name = namePendingInstall(x.body.package, x.body.id);
const alreadyAdded = Object.keys(farmwares).includes(name);
const alreadyAdded = Object.keys(farmwares).includes(x.body.package || name);
const alreadyInstalled = Object.values(farmwares)
.map(fw => fw.url).includes(x.body.url);
if (x.body.id && !alreadyAdded && !alreadyInstalled) {

View File

@ -17,7 +17,7 @@ describe("Contextual `Alert` creation", () => {
expect(results[0]).toEqual({
created_at: 1,
problem_tag: "farmbot_os.firmware.missing",
priority: 0,
priority: 500,
slug: "firmware-missing",
});
});

View File

@ -23,7 +23,7 @@ const toggleAlert = (s: State, body: TaggedFbosConfig["body"]) => {
s.alerts[FIRMWARE_MISSING] = {
created_at: 1,
problem_tag: FIRMWARE_MISSING,
priority: 0,
priority: 500,
slug: "firmware-missing",
};
}

View File

@ -4,11 +4,12 @@ export interface SaucerProps {
color?: string;
active?: boolean;
className?: string;
title?: string;
}
/** A colored UI disc/circle. */
export function Saucer({ color, active, className }: SaucerProps) {
export function Saucer({ color, active, className, title }: SaucerProps) {
const classes = ["saucer", color, className];
if (active) { classes.push("active"); }
return <div className={classes.join(" ")} />;
return <div className={classes.join(" ")} title={title} />;
}

View File

@ -24,20 +24,20 @@
"author": "farmbot.io",
"license": "MIT",
"dependencies": {
"@babel/core": "7.5.4",
"@babel/core": "7.5.5",
"@blueprintjs/core": "3.17.1",
"@blueprintjs/datetime": "3.11.0",
"@blueprintjs/select": "3.9.0",
"@types/enzyme": "3.10.2",
"@types/enzyme": "3.10.3",
"@types/jest": "24.0.15",
"@types/lodash": "4.14.136",
"@types/markdown-it": "0.0.8",
"@types/moxios": "0.4.8",
"@types/node": "12.6.2",
"@types/node": "12.6.8",
"@types/promise-timeout": "1.3.0",
"@types/react": "16.8.23",
"@types/react-color": "3.0.1",
"@types/react-dom": "16.8.4",
"@types/react-dom": "16.8.5",
"@types/react-redux": "7.1.1",
"axios": "0.19.0",
"boxed_value": "1.0.0",
@ -47,7 +47,7 @@
"enzyme-adapter-react-16": "1.14.0",
"farmbot": "8.0.3-rc1",
"i18next": "17.0.6",
"lodash": "4.17.14",
"lodash": "4.17.15",
"markdown-it": "9.0.1",
"markdown-it-emoji": "1.4.0",
"moment": "2.24.0",
@ -60,14 +60,14 @@
"react-addons-test-utils": "15.6.2",
"react-color": "2.17.3",
"react-dom": "16.8.6",
"react-joyride": "2.1.0",
"react-joyride": "2.1.1",
"react-redux": "7.1.0",
"react-test-renderer": "16.8.6",
"react-transition-group": "4.2.1",
"redux": "4.0.4",
"redux-immutable-state-invariant": "2.1.0",
"redux-thunk": "2.3.0",
"sass": "1.22.4",
"sass": "1.22.7",
"sass-lint": "1.13.1",
"takeme": "0.11.1",
"ts-jest": "24.0.2",

File diff suppressed because it is too large Load Diff

View File

@ -18,19 +18,19 @@ npm run translation-check
See the [README](https://github.com/FarmBot/Farmbot-Web-App#translating-the-web-app-into-your-language) for contribution instructions.
Total number of phrases identified by the language helper for translation: __1017__
Total number of phrases identified by the language helper for translation: __1023__
|Language|Percent translated|Translated|Untranslated|Other Translations|
|:---:|---:|---:|---:|---:|
|da|11%|111|906|22|
|de|42%|425|592|122|
|es|77%|781|236|152|
|fr|71%|719|298|180|
|it|9%|88|929|174|
|nl|8%|80|937|142|
|pt|7%|72|945|161|
|ru|59%|605|412|206|
|zh|9%|87|930|142|
|da|11%|111|912|22|
|de|42%|425|598|122|
|es|100%|1023|0|152|
|fr|70%|719|304|180|
|it|9%|88|935|174|
|nl|8%|80|943|142|
|pt|7%|72|951|161|
|ru|59%|606|417|205|
|zh|9%|87|936|142|
**Percent translated** refers to the percent of phrases identified by the
language helper that have been translated. Additional phrases not identified

View File

@ -0,0 +1,2 @@
Please see our guidelines for responsibly disclosing security vulnerabilities:
http://vulnerabilities.farm.bot/

View File

@ -5,20 +5,20 @@ describe Api::FarmEventsController do
describe "#update" do
let(:user) { FactoryBot.create(:user) }
let(:fe) { FactoryBot.create(:farm_event, device: user.device) }
let(:fe) { FactoryBot.create(:farm_event, device: user.device) }
it "allows authorized modification" do
sign_in user
id = FactoryBot.create(:farm_event, device: user.device).id
input = { id: id, farm_event: { repeat: 66 } }
patch :update, format: :json, body: input.to_json, params: {id: id}
patch :update, format: :json, body: input.to_json, params: { id: id }
expect(response.status).to eq(200)
end
it "prevents unauthorized modification" do
sign_in user
id = FactoryBot.create(:farm_event).id
id = FactoryBot.create(:farm_event).id
input = { id: id, repeat: 66 }
patch :update, format: :json, body: input.to_json, params: {id: id}
patch :update, format: :json, body: input.to_json, params: { id: id }
expect(response.status).to eq(403)
expect(json[:error]).to include("Not your farm_event")
end
@ -27,8 +27,8 @@ describe Api::FarmEventsController do
sign_in user
id = FactoryBot.create(:farm_event, device: user.device).id
patch :update,
format: :json,
body: { id: id, repeat: 1, time_unit: FarmEvent::NEVER }.to_json,
format: :json,
body: { id: id, repeat: 1, time_unit: FarmEvent::NEVER }.to_json,
params: { id: id }
fe = FarmEvent.find(id)
expect(response.status).to eq(200)
@ -41,7 +41,7 @@ describe Api::FarmEventsController do
id = FactoryBot.create(:farm_event, device: user.device).id
patch :update,
format: :json,
body: { id: id, end_time: "+045633-08-18T13:25:00.000Z" }.to_json,
body: { id: id, end_time: "+045633-08-18T13:25:00.000Z" }.to_json,
params: { id: id }
expect(response.status).to eq(422)
expect(json[:end_time]).to include("too far in the future")
@ -49,19 +49,20 @@ describe Api::FarmEventsController do
def create_fe_with_fragment
fragment = Fragment.from_celery(
owner: fe,
owner: fe,
device: user.device,
kind: "internal_farm_event",
args: {},
body: [
kind: "internal_farm_event",
args: {},
body: [
{
kind: "parameter_application",
args: {
label: "foo",
data_value: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } }
}
}
])
data_value: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } },
},
},
],
)
return fe
end
@ -69,35 +70,37 @@ describe Api::FarmEventsController do
sign_in user
patch :update,
format: :json,
body: { body: body }.to_json,
body: { body: body }.to_json,
params: { id: fe.id }
end
it "ignores fragment when body is `nil`" do
fe = create_fe_with_fragment
fe = create_fe_with_fragment
fragment_b4 = fe.fragment.id
expect(fe.fragment).not_to be(nil)
update_body(fe, nil)
expect(response.status).to eq(200)
expect(fe.reload.fragment).not_to be(nil)
expect(fe.fragment.id).to eq(fragment_b4)
expect(fe.fragment.id).to eq(fragment_b4)
end
it "deletes old fragment when body is `[]`" do
fe = create_fe_with_fragment
expect(fe.fragment).not_to be(nil)
update_body(fe, [])
expect(response.status).to eq(200)
expect(response.status).to eq(200)
expect(fe.reload.fragment).to be(nil)
expect(json.fetch(:body)).to eq([])
expect(json.fetch(:body)).to eq([])
end
it "replaces old fragment when given a new one" do
fe = create_fe_with_fragment
expect(fe.fragment).not_to be(nil)
old_timestamp = fe.updated_at
update_body(fe, nil)
expect(response.status).to eq(200)
expect(fe.reload.fragment).not_to be(nil)
expect(fe.updated_at).to be > old_timestamp
end
it "inserts new fragment when there originally was none" do
@ -113,11 +116,11 @@ describe Api::FarmEventsController do
args: {
x: 1,
y: 2,
z: 3
}
}
}
}
z: 3,
},
},
},
},
]
update_body(fe, body)
expect(response.status).to eq(200)

View File

@ -1,8 +1,9 @@
require 'spec_helper'
require "spec_helper"
describe Api::ImagesController do
include Devise::Test::ControllerHelpers
let(:user) { FactoryBot.create(:user) }
it "Creates a policy object" do
sign_in user
b4 = Api::ImagesController.store_locally
@ -15,8 +16,7 @@ describe Api::ImagesController do
expect(json[:verb]).to eq("POST")
expect(json[:url]).to include("googleapis")
expect(json[:form_data].keys.sort).to include(:signature)
expect(json[:instructions])
.to include("POST the resulting URL as an 'attachment_url'")
expect(json[:instructions]).to include("POST the resulting URL as an 'attachment_url'")
end
it "Creates a (stub) policy object" do
@ -29,13 +29,13 @@ describe Api::ImagesController do
expect(json).to be_kind_of(Hash)
expect(json[:verb]).to eq("POST")
expect(json[:url]).to include($API_URL)
[ :policy, :signature, :GoogleAccessId ]
[:policy, :signature, :GoogleAccessId]
.map { |key| expect(json.dig(:form_data, key)).to eq("N/A") }
expect(json[:form_data].keys.sort).to include(:signature)
end
describe '#index' do
it 'shows only the max images allowed' do
describe "#index" do
it "shows only the max images allowed" do
sign_in user
device = user.device
# Using the *real* value (10) was super slow (~30 seconds)
@ -48,8 +48,8 @@ describe Api::ImagesController do
end
end
describe '#show' do
it 'shows image meta data' do
describe "#show" do
it "shows image meta data" do
sign_in user
image = FactoryBot.create(:image, device: user.device)
get :show, params: { id: image.id }
@ -62,13 +62,13 @@ describe Api::ImagesController do
end
describe "#create" do
it 'creates one image', :slow do
it "creates one image", :slow do
sign_in user
before_count = Image.count
post :create,
body: { attachment_url: FAKE_ATTACHMENT_URL,
meta: { x: 1, z: 3 } }.to_json,
params: {format: :json}
params: { format: :json }
expect(response.status).to eq(200)
expect(Image.count).to be > before_count
expect(json[:device_id]).to eq(user.device.id)
@ -79,8 +79,8 @@ describe Api::ImagesController do
expect(json.dig :meta, :z).to eq(3)
end
describe '#delete' do
it 'deletes an image' do
describe "#delete" do
it "deletes an image" do
sign_in user
image = FactoryBot.create(:image, device: user.device)
before_count = Image.count

View File

@ -30,16 +30,15 @@ describe Api::PointsController do
pointer_type: "Plant",
openfarm_slug: "mung-bean",
planted_at: time,
plant_stage: "sprouted"
}
plant_stage: "sprouted" }
post :create, body: p.to_json, params: { format: :json }
expect(response.status).to eq(200)
plant = Plant.last
expect(plant.x).to eq(p[:x])
expect(plant.y).to eq(p[:y])
expect(plant.name).to eq(p[:name])
expect(plant.plant_stage).to eq("sprouted")
expect(p[:plant_stage]).to eq("sprouted")
expect(plant.x).to eq(p[:x])
expect(plant.y).to eq(p[:y])
expect(plant.name).to eq(p[:name])
expect(plant.plant_stage).to eq("sprouted")
expect(p[:plant_stage]).to eq("sprouted")
expect(plant.openfarm_slug).to eq(p[:openfarm_slug])
expect(plant.created_at).to be_truthy
p.keys.each do |key|
@ -52,20 +51,20 @@ describe Api::PointsController do
body = { pointer_type: "TypoPointer", x: 0, y: 0 }
post :create, body: body.to_json, params: { format: :json }
expect(response.status).to eq(422)
expected = "Please provide a JSON object "\
expected = "Please provide a JSON object " \
"with a `pointer_type` that matches"
expect(json.fetch(:pointer_type)).to include(expected)
end
it "creates a point" do
sign_in user
body = { x: 1,
y: 2,
z: 3,
radius: 3,
name: "YOLO",
body = { x: 1,
y: 2,
z: 3,
radius: 3,
name: "YOLO",
pointer_type: "GenericPointer",
meta: { foo: "BAR" } }
meta: { foo: "BAR" } }
post :create, body: body.to_json, params: { format: :json }
expect(response.status).to eq(200)
expect(json[:name]).to eq(body[:name])
@ -80,11 +79,11 @@ describe Api::PointsController do
it "requires x" do
sign_in user
body = { y: 2,
z: 3,
radius: 3,
body = { y: 2,
z: 3,
radius: 3,
pointer_type: "GenericPointer",
meta: { foo: "BAR" } }
meta: { foo: "BAR" } }
post :create, body: body.to_json, params: { format: :json }
expect(response.status).to eq(422)
expect(json[:x]).to be
@ -97,8 +96,7 @@ describe Api::PointsController do
SmarfDoc.note("This is what happens when you post bad JSON")
post :create, body: "{'x': 0, 'this isnt': 'JSON'}", params: { format: :json }
expect(response.status).to eq(422)
expect(json[:error])
.to include("Please use a _valid_ JSON object or array")
expect(json[:error]).to include("Please use a _valid_ JSON object or array")
end
it "creates a toolslot with an valid pullout direction" do
@ -106,15 +104,15 @@ describe Api::PointsController do
sign_in user
before_count = ToolSlot.count
post :create,
body: {
pointer_type: "ToolSlot",
name: "foo",
x: 0,
y: 0,
z: 0,
pullout_direction: direction
}.to_json,
params: { format: :json }
body: {
pointer_type: "ToolSlot",
name: "foo",
x: 0,
y: 0,
z: 0,
pullout_direction: direction,
}.to_json,
params: { format: :json }
expect(response.status).to eq(200)
expect(ToolSlot.count).to be > before_count
expect(json[:pullout_direction]).to eq(direction)
@ -128,7 +126,7 @@ describe Api::PointsController do
y: 0,
z: 0 }
old_tool_count = ToolSlot.count
post :create, body: payload.to_json, params: {format: :json}
post :create, body: payload.to_json, params: { format: :json }
expect(response.status).to eq(200)
expect(ToolSlot.count).to be > old_tool_count
expect(json[:pullout_direction]).to eq(0)
@ -137,16 +135,32 @@ describe Api::PointsController do
it "disallows bad `tool_id`s" do
sign_in user
payload = { pointer_type: "ToolSlot",
name: "foo",
x: 0,
y: 0,
z: 0,
tool_id: (Tool.count + 100) }
name: "foo",
x: 0,
y: 0,
z: 0,
tool_id: (Tool.count + 100) }
old_tool_count = ToolSlot.count
post :create, body: payload.to_json, params: {format: :json}
post :create, body: payload.to_json, params: { format: :json }
expect(response.status).to eq(422)
expect(ToolSlot.count).to eq old_tool_count
expect(json[:tool_id]).to include("Can't find tool")
end
it "gracefully handles PG::ProgramLimitExceeded" do
absurdly_large_metadata =
{ key: (1..85).to_a.map { |x| SecureRandom.hex }.join }
sign_in user
payload = { pointer_type: "GenericPointer",
name: "foo",
x: 0,
y: 0,
z: 0,
meta: absurdly_large_metadata }
old_tool_count = ToolSlot.count
post :create, body: payload.to_json, params: { format: :json }
expect(response.status).to eq(422)
expect(json.fetch(:error)).to eq(Api::AbstractController::TOO_MUCH_DATA)
end
end
end

View File

@ -1,12 +1,11 @@
require "spec_helper"
describe Api::RegimensController do
include Devise::Test::ControllerHelpers
describe "#update" do
let(:user) { FactoryBot.create(:user) }
let(:sequence) { FakeSequence.create( device: user.device) }
let(:sequence) { FakeSequence.create(device: user.device) }
it "updates an old regimen" do
sign_in user
@ -17,20 +16,20 @@ describe Api::RegimensController do
"name" => "something new",
"color" => "blue",
"regimen_items" => [
{
"time_offset" => 1555500000,
"sequence_id" => sequence.id
},
{
"time_offset" => 864300000,
"sequence_id" => sequence.id
},
{
"time_offset" => 950700000,
"sequence_id" => sequence.id
}
]
}
{
"time_offset" => 1555500000,
"sequence_id" => sequence.id,
},
{
"time_offset" => 864300000,
"sequence_id" => sequence.id,
},
{
"time_offset" => 950700000,
"sequence_id" => sequence.id,
},
],
}
put :update, body: payload.to_json, params: { id: existing.id }
expect(response.status).to eq(200)
expect(existing.reload.regimen_items.count).to eq(payload["regimen_items"].length)
@ -40,7 +39,7 @@ describe Api::RegimensController do
it "changes variable assignments in `body`" do
sequence = FakeSequence.with_parameters
user = FactoryBot.create(:user, device: sequence.device)
user = FactoryBot.create(:user, device: sequence.device)
sign_in user
var_declr = [
@ -50,21 +49,21 @@ describe Api::RegimensController do
label: "parent",
data_value: {
kind: "coordinate",
args: { x: 0, y: 0, z: 0 }
}
}
}
args: { x: 0, y: 0, z: 0 },
},
},
},
]
existing = Regimens::Create.run!(device: user.device,
name: "x",
color: "red",
existing = Regimens::Create.run!(device: user.device,
name: "x",
color: "red",
regimen_items: [],
body: var_declr)
body: var_declr)
payload = {
id: existing.id,
name: "something new",
id: existing.id,
name: "something new",
color: "blue",
body: [
body: [
{
kind: "parameter_application",
args: {
@ -72,19 +71,20 @@ describe Api::RegimensController do
data_value: {
kind: "tool",
args: {
tool_id: FactoryBot.create(:tool, device: sequence.device).id
}
}
}
}
tool_id: FactoryBot.create(:tool, device: sequence.device).id,
},
},
},
},
],
regimen_items: [
{
time_offset: 950700000,
sequence_id: sequence.id
}
]
sequence_id: sequence.id,
},
],
}
old_timestamp = existing.created_at.as_json
put :update,
body: payload.to_json,
format: :json,
@ -92,22 +92,22 @@ describe Api::RegimensController do
expect(response.status).to eq(200)
path = [:body, 0, :args, :data_value, :kind]
expect(json.dig(*path)).to eq(payload.dig(*path))
expect(json.fetch(:updated_at)).to_not eq(old_timestamp)
end
it "catches bad regimen_items" do
sign_in user
existing = Regimens::Create.run!(device: user.device, name: "x", color: "red", regimen_items: [])
payload = {
"id" => existing.id,
"name" => "something new",
"color" => "blue",
"regimen_items" => [ { "time_offset" => 950700000, "sequence_id" => 0 } ]
}
"id" => existing.id,
"name" => "something new",
"color" => "blue",
"regimen_items" => [{ "time_offset" => 950700000, "sequence_id" => 0 }],
}
put :update, body: payload.to_json, params: { id: existing.id }, format: :json
expect(response.status).to eq(422)
expect(json[:regimen_items]).to be
expect(json[:regimen_items])
.to include("Failed to instantiate nested RegimenItem.")
expect(json[:regimen_items]).to include("Failed to instantiate nested RegimenItem.")
end
end
end

View File

@ -15,7 +15,7 @@ describe Api::RmqUtilsController do
password: token }
end
it "limits users to 10 connections per 10 minutes" do
it "limits users to 20 connections per 5 minutes" do
empty_mail_bag
u = credentials.fetch(:username)
p = credentials.fetch(:password)
@ -24,7 +24,7 @@ describe Api::RmqUtilsController do
.redis
.set("mqtt_limiter:" + u.split("_").last, 0)
10.times do
20.times do
post :user_action, params: { username: u, password: p }
expect(response.status).to eq(200)
expect(response.body).to include("allow")

View File

@ -220,4 +220,14 @@ describe CeleryScript::Corpus do
expect(value.fetch("tags").first).to eq("great")
expect(value.fetch("docs")).to eq("spectacular")
end
it "sets a MAX_WAIT_MS limit for `wait` nodes" do
bad = CeleryScript::AstNode.new({
kind: "wait",
args: { milliseconds: CeleryScriptSettingsBag::MAX_WAIT_MS + 10 },
})
check = CeleryScript::Checker.new(bad, corpus, device)
expect(check.valid?).to be_falsey
expect(check.error.message).to include("cannot exceed 3 minutes")
end
end

View File

@ -6,12 +6,31 @@ describe Image do
it "adds URL attachments", :slow do
image = Image.create(device: device)
expect(image.attachment_processed_at).to be_nil
expect(image.attachment.exists?).to be_falsy
expect(image.attachment.attached?).to be false
image.set_attachment_by_url(FAKE_ATTACHMENT_URL)
image.save!
expect(image.attachment.exists?).to be_truthy
expect(image.attachment.attached?).to be true
expect(image.attachment_processed_at).to be_truthy
end
it "generates legacy URLs for images generated via (deprecated) PaperClip" do
now = Time.now
i = Image.new
i.id = 123
i.attachment_file_name = "foo.jpg"
i.attachment_updated_at = now
url = i.legacy_url("x640")
expect(url).to include("/images/attachments/000/000/123/x640/foo.jpg?")
expect(url).to include(now.to_i.to_s)
end
it "generates a URL when BUCKET is set" do
const_reassign(Image, :BUCKET, "foo") do
i = Image.new
expect(i).to receive(:attachment).and_return(Struct.new(:key).new("bar"))
url = i.regular_url
expect(url).to eq("https://storage.googleapis.com/foo/bar")
end
end
end

View File

@ -0,0 +1,22 @@
require "spec_helper"
describe Images::GeneratePolicy do
it "has a policy object (Hash)" do
policy = Images::GeneratePolicy.new.send(:policy)
expiration = Time.parse(policy.fetch(:expiration))
one_hour = (Time.now + 1.hour).utc
time_diff = (one_hour - expiration).round
expect(time_diff).to be >= 0
expect(time_diff).to be <= 1
conditions = policy.fetch(:conditions).map(&:to_a).map(&:flatten)
{
0 => eq([:bucket, "YOU_MUST_CONFIG_GOOGLE_CLOUD_STORAGE"]),
2 => eq([:acl, "public-read"]),
3 => eq([:eq, "$Content-Type", "image/jpeg"]),
4 => eq(["content-length-range", 1, 7340032]),
}.map do |(index, meet_expectation)|
expect(conditions[index]).to meet_expectation
end
end
end

View File

@ -117,8 +117,14 @@ end
# Reassign constants without getting a bunch of warnings to STDOUT.
# This is just for testing purposes, so NBD.
def const_reassign(target, const, value)
b4 = target.const_get(const)
target.send(:remove_const, const)
target.const_set(const, value)
if block_given?
yield
target.send(:remove_const, const)
target.const_set(const, b4)
end
end
class StubResp