-
Notifications
You must be signed in to change notification settings - Fork 0
Automated Test: rest-serializer-enhancement-post #316
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import RestAdapter from 'discourse/adapters/rest'; | ||
|
|
||
| export default RestAdapter.extend({ | ||
| pathFor() { | ||
| return "/admin/customize/embedding"; | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { bufferedProperty } from 'discourse/mixins/buffered-content'; | ||
| import computed from 'ember-addons/ember-computed-decorators'; | ||
| import { on, observes } from 'ember-addons/ember-computed-decorators'; | ||
| import { popupAjaxError } from 'discourse/lib/ajax-error'; | ||
|
|
||
| export default Ember.Component.extend(bufferedProperty('host'), { | ||
| editToggled: false, | ||
| tagName: 'tr', | ||
| categoryId: null, | ||
|
|
||
| editing: Ember.computed.or('host.isNew', 'editToggled'), | ||
|
|
||
| @on('didInsertElement') | ||
| @observes('editing') | ||
| _focusOnInput() { | ||
| Ember.run.schedule('afterRender', () => { this.$('.host-name').focus(); }); | ||
| }, | ||
|
|
||
| @computed('buffered.host', 'host.isSaving') | ||
| cantSave(host, isSaving) { | ||
| return isSaving || Ember.isEmpty(host); | ||
| }, | ||
|
|
||
| actions: { | ||
| edit() { | ||
| this.set('categoryId', this.get('host.category.id')); | ||
| this.set('editToggled', true); | ||
| }, | ||
|
|
||
| save() { | ||
| if (this.get('cantSave')) { return; } | ||
|
|
||
| const props = this.get('buffered').getProperties('host'); | ||
| props.category_id = this.get('categoryId'); | ||
|
|
||
| const host = this.get('host'); | ||
| host.save(props).then(() => { | ||
| host.set('category', Discourse.Category.findById(this.get('categoryId'))); | ||
| this.set('editToggled', false); | ||
| }).catch(popupAjaxError); | ||
| }, | ||
|
|
||
| delete() { | ||
| bootbox.confirm(I18n.t('admin.embedding.confirm_delete'), (result) => { | ||
| if (result) { | ||
| this.get('host').destroyRecord().then(() => { | ||
| this.sendAction('deleteHost', this.get('host')); | ||
| }); | ||
| } | ||
| }); | ||
| }, | ||
|
|
||
| cancel() { | ||
| const host = this.get('host'); | ||
| if (host.get('isNew')) { | ||
| this.sendAction('deleteHost', host); | ||
| } else { | ||
| this.rollbackBuffer(); | ||
| this.set('editToggled', false); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| export default Ember.Controller.extend({ | ||
| embedding: null, | ||
|
|
||
| actions: { | ||
| saveChanges() { | ||
| this.get('embedding').update({}); | ||
|
Comment on lines
+5
to
+6
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No error handling on
Proposed fix saveChanges() {
- this.get('embedding').update({});
+ this.get('embedding').update({}).catch(popupAjaxError);
},You'll need to import 🤖 Prompt for AI Agents |
||
| }, | ||
|
|
||
| addHost() { | ||
| const host = this.store.createRecord('embeddable-host'); | ||
| this.get('embedding.embeddable_hosts').pushObject(host); | ||
| }, | ||
|
|
||
| deleteHost(host) { | ||
| this.get('embedding.embeddable_hosts').removeObject(host); | ||
| } | ||
| } | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| export default Ember.Route.extend({ | ||
| model() { | ||
| return this.store.find('embedding'); | ||
| }, | ||
|
|
||
| setupController(controller, model) { | ||
| controller.set('embedding', model); | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| {{#if editing}} | ||
| <td> | ||
| {{input value=buffered.host placeholder="example.com" enter="save" class="host-name"}} | ||
| </td> | ||
| <td> | ||
| {{category-chooser value=categoryId}} | ||
| </td> | ||
| <td> | ||
| {{d-button icon="check" action="save" class="btn-primary" disabled=cantSave}} | ||
| {{d-button icon="times" action="cancel" class="btn-danger" disabled=host.isSaving}} | ||
| </td> | ||
| {{else}} | ||
| <td>{{host.host}}</td> | ||
| <td>{{category-badge host.category}}</td> | ||
| <td> | ||
| {{d-button icon="pencil" action="edit"}} | ||
| {{d-button icon="trash-o" action="delete" class='btn-danger'}} | ||
| </td> | ||
| {{/if}} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| {{#if embedding.embeddable_hosts}} | ||
| <table> | ||
| <tr> | ||
| <th style='width: 50%'>{{i18n "admin.embedding.host"}}</th> | ||
| <th style='width: 30%'>{{i18n "admin.embedding.category"}}</th> | ||
| <th style='width: 20%'> </th> | ||
| </tr> | ||
| {{#each embedding.embeddable_hosts as |host|}} | ||
| {{embeddable-host host=host deleteHost="deleteHost"}} | ||
| {{/each}} | ||
| </table> | ||
| {{/if}} | ||
|
|
||
| {{d-button label="admin.embedding.add_host" action="addHost" icon="plus" class="btn-primary"}} | ||
|
|
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,4 +1,4 @@ | ||||||
| const ADMIN_MODELS = ['plugin', 'site-customization']; | ||||||
| const ADMIN_MODELS = ['plugin', 'site-customization', 'embeddable-host']; | ||||||
|
|
||||||
| export function Result(payload, responseJson) { | ||||||
| this.payload = payload; | ||||||
|
|
@@ -19,7 +19,7 @@ function rethrow(error) { | |||||
| export default Ember.Object.extend({ | ||||||
|
|
||||||
| basePath(store, type) { | ||||||
| if (ADMIN_MODELS.indexOf(type) !== -1) { return "/admin/"; } | ||||||
| if (ADMIN_MODELS.indexOf(type.replace('_', '-')) !== -1) { return "/admin/"; } | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested fix- if (ADMIN_MODELS.indexOf(type.replace('_', '-')) !== -1) { return "/admin/"; }
+ if (ADMIN_MODELS.indexOf(type.replace(/_/g, '-')) !== -1) { return "/admin/"; }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| return "/"; | ||||||
| }, | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -189,14 +189,24 @@ export default Ember.Object.extend({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _hydrateEmbedded(type, obj, root) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const self = this; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Object.keys(obj).forEach(function(k) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const m = /(.+)\_id$/.exec(k); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const m = /(.+)\_id(s?)$/.exec(k); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (m) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const subType = m[1]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hydrated = self._lookupSubType(subType, type, obj[k], root); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hydrated) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| obj[subType] = hydrated; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (m[2]) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hydrated = obj[k].map(function(id) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return self._lookupSubType(subType, type, id, root); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| obj[self.pluralize(subType)] = hydrated || []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| delete obj[k]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hydrated = self._lookupSubType(subType, type, obj[k], root); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hydrated) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| obj[subType] = hydrated; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| delete obj[k]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+192
to
+209
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Multi-id hydration can silently introduce Two concerns with the multi-id branch:
Additionally, Proposed fix: filter out failed lookups and guard the delete if (m[2]) {
- const hydrated = obj[k].map(function(id) {
- return self._lookupSubType(subType, type, id, root);
- });
- obj[self.pluralize(subType)] = hydrated || [];
+ const hydrated = obj[k].map(function(id) {
+ return self._lookupSubType(subType, type, id, root);
+ }).filter(Boolean);
+ obj[self.pluralize(subType)] = hydrated;
delete obj[k];📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| class Admin::EmbeddableHostsController < Admin::AdminController | ||
|
|
||
| before_filter :ensure_logged_in, :ensure_staff | ||
|
|
||
| def create | ||
| save_host(EmbeddableHost.new) | ||
| end | ||
|
|
||
| def update | ||
| host = EmbeddableHost.where(id: params[:id]).first | ||
| save_host(host) | ||
| end | ||
|
|
||
| def destroy | ||
| host = EmbeddableHost.where(id: params[:id]).first | ||
| host.destroy | ||
| render json: success_json | ||
| end | ||
|
Comment on lines
+9
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Proposed fix def update
- host = EmbeddableHost.where(id: params[:id]).first
+ host = EmbeddableHost.find(params[:id])
save_host(host)
end
def destroy
- host = EmbeddableHost.where(id: params[:id]).first
+ host = EmbeddableHost.find(params[:id])
host.destroy
render json: success_json
end🤖 Prompt for AI Agents |
||
|
|
||
| protected | ||
|
|
||
| def save_host(host) | ||
| host.host = params[:embeddable_host][:host] | ||
| host.category_id = params[:embeddable_host][:category_id] | ||
| host.category_id = SiteSetting.uncategorized_category_id if host.category_id.blank? | ||
|
|
||
| if host.save | ||
| render_serialized(host, EmbeddableHostSerializer, root: 'embeddable_host', rest_serializer: true) | ||
| else | ||
| render_json_error(host) | ||
| end | ||
| end | ||
|
|
||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| class Admin::EmbeddingController < Admin::AdminController | ||
|
|
||
| before_filter :ensure_logged_in, :ensure_staff, :fetch_embedding | ||
|
|
||
| def show | ||
| render_serialized(@embedding, EmbeddingSerializer, root: 'embedding', rest_serializer: true) | ||
| end | ||
|
|
||
| def update | ||
| render_serialized(@embedding, EmbeddingSerializer, root: 'embedding', rest_serializer: true) | ||
| end | ||
|
Comment on lines
+9
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The 🤖 Prompt for AI Agents |
||
|
|
||
| protected | ||
|
|
||
| def fetch_embedding | ||
| @embedding = OpenStruct.new({ | ||
| id: 'default', | ||
| embeddable_hosts: EmbeddableHost.all.order(:host) | ||
| }) | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,24 @@ | ||||||
| class EmbeddableHost < ActiveRecord::Base | ||||||
| validates_format_of :host, :with => /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?\Z/i | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TLD length cap of 5 characters rejects valid domains. The regex Proposed fix- validates_format_of :host, :with => /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?\Z/i
+ validates_format_of :host, :with => /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,24}(:[0-9]{1,5})?(\/.*)?\Z/i📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| belongs_to :category | ||||||
|
|
||||||
| before_validation do | ||||||
| self.host.sub!(/^https?:\/\//, '') | ||||||
| self.host.sub!(/\/.*$/, '') | ||||||
| end | ||||||
|
Comment on lines
+5
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Proposed fix before_validation do
+ return if self.host.blank?
self.host.sub!(/^https?:\/\//, '')
self.host.sub!(/\/.*$/, '')
end🤖 Prompt for AI Agents |
||||||
|
|
||||||
| def self.record_for_host(host) | ||||||
| uri = URI(host) rescue nil | ||||||
| return false unless uri.present? | ||||||
|
|
||||||
| host = uri.host | ||||||
| return false unless host.present? | ||||||
|
|
||||||
| where("lower(host) = ?", host).first | ||||||
| end | ||||||
|
|
||||||
| def self.host_allowed?(host) | ||||||
| record_for_host(host).present? | ||||||
| end | ||||||
|
|
||||||
| end | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,12 +33,14 @@ def self.import(user, url, title, contents) | |
| # If there is no embed, create a topic, post and the embed. | ||
| if embed.blank? | ||
| Topic.transaction do | ||
| eh = EmbeddableHost.record_for_host(url) | ||
|
|
||
| creator = PostCreator.new(user, | ||
| title: title, | ||
| raw: absolutize_urls(url, contents), | ||
| skip_validations: true, | ||
| cook_method: Post.cook_methods[:raw_html], | ||
| category: SiteSetting.embed_category) | ||
| category: eh.try(:category_id)) | ||
|
Comment on lines
+36
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find TopicEmbed.import callers
rg -n 'TopicEmbed\.import' --type=ruby -A 5 -B 5Repository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 5898 🏁 Script executed: # Examine the TopicEmbed file structure
fd -e rb -p | grep -i topic_embed | head -20Repository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 258 🏁 Script executed: # Read the TopicEmbed.import method to understand the full flow
cat -n app/models/topic_embed.rb | head -100Repository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 4010 🏁 Script executed: # Find EmbeddableHost class and record_for_host method
fd -e rb | xargs rg -l "EmbeddableHost" | head -20Repository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 588 🏁 Script executed: # Look at EmbeddableHost implementation
fd -e rb -p | xargs grep -l "class EmbeddableHost"Repository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 163 🏁 Script executed: # Check poll_feed.rb to understand where the topic comes from
cat -n app/jobs/scheduled/poll_feed.rb | head -70Repository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 2162 🏁 Script executed: # Read EmbeddableHost class implementation
cat -n app/models/embeddable_host.rbRepository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 817 🏁 Script executed: # Check if there's any host validation/authorization logic elsewhere
rg -n "host_allowed|authorized|record_for_host" --type=rubyRepository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 5269 🏁 Script executed: # Check topic_retriever.rb to see how it validates hosts
cat -n lib/topic_retriever.rbRepository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 1956 🏁 Script executed: # Check embed_controller.rb to see how it uses host_allowed
cat -n app/controllers/embed_controller.rb | head -80Repository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 2698 🏁 Script executed: # Verify all direct callers of TopicEmbed.import (not import_remote)
rg -n '\.import\(' --type=ruby | grep -i "TopicEmbed" | grep -v "import_remote"Repository: admin-coderabbit/coderabbit-discourse-mirror Length of output: 876 Add host-authorization guard in TopicEmbed.import before creating posts. The import flow is inconsistent: Currently, when eh = EmbeddableHost.record_for_host(url)
return unless eh.present?This ensures consistent security enforcement across all import paths and prevents arbitrary domains from being embedded via RSS feeds. 🤖 Prompt for AI Agents |
||
| post = creator.create | ||
| if post.present? | ||
| TopicEmbed.create!(topic_id: post.topic_id, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| class EmbeddableHostSerializer < ApplicationSerializer | ||
| attributes :id, :host, :category_id | ||
|
|
||
| def id | ||
| object.id | ||
| end | ||
|
|
||
| def host | ||
| object.host | ||
| end | ||
|
|
||
| def category_id | ||
| object.category_id | ||
| end | ||
| end | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| class EmbeddingSerializer < ApplicationSerializer | ||
| attributes :id | ||
| has_many :embeddable_hosts, serializer: EmbeddableHostSerializer, embed: :ids | ||
|
|
||
| def id | ||
| object.id | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing error handling on
destroyRecord().Unlike the
saveaction which uses.catch(popupAjaxError), thedeleteaction silently swallows server errors. If deletion fails (e.g., 403/500), the user receives no feedback and the host remains in the list without any indication of failure.🐛 Proposed fix to add error handling
delete() { bootbox.confirm(I18n.t('admin.embedding.confirm_delete'), (result) => { if (result) { this.get('host').destroyRecord().then(() => { this.sendAction('deleteHost', this.get('host')); - }); + }).catch(popupAjaxError); } }); },🤖 Prompt for AI Agents