Greeting,
am working on a recursive tree menu, but am getting stuck on somthing.
Recursive code
$get_parent = "0";
$rep_1 = mysqli_query($connexion,' SELECT * FROM category');
$get_array = array();
while($don_1 = mysqli_fetch_array($rep_1))
{
$get_array[$don_1['id']] = array("id" => $don_1['id'], "id_parent" => $don_1['id_parent'], "title" => $don_1['title']);
}
function tree_2($array,$parent,$currLevel=0)
{
foreach($array as $key => $value)
{
if($value['id_parent'] == $parent)
{
echo "".str_repeat("-", $currLevel)."id : ".$value['id']." | id_parent : ".$value['id_parent']." | title : ".$value['title']."<br/>";
$currLevel++;
$children = tree_2($array,$key,$currLevel);
$currLevel--;
}
}
}
echo tree_2($get_array,$get_parent);
My table
== Table structure for table category
|------
|Column|Type|Null|Default
|------
|//**id**//|int(11)|No|
|title|varchar(20)|No|
|id_parent|int(11)|Yes|NULL
|path|varchar(11)|No|
== Dumping data for table category
|1|ELECTRONICS|0|0
|2|TELEVISIONS|1|1
|3|TUBE|2|2
|4|LCD|2|2
|5|PLASMA|2|2
|6|PORTABLE ELECTRONICS|1|1
|7|MP3 PLAYERS|6|2
|8|FLASH|7|2
|9|CD PLAYERS|6|2
|10|2 WAY RADIOS|6|3
|11|PLANS|0|0
|12|SPITFIRE|11|1
|13|MP3 PLAYERS|8|2
This code work's very well,
however am stuck for when i want to get only one branch. Like
FLASH
CD PLAYERS
WAY RADIO.
In fact, i'd like to do like into forums, where we can get the path back to the forum root. Like
FLASH > CD PLAYERS > WAY RADIO.
If any idea !
after some try, I finally found a solution to get from a php recursive tree only one branch. am searing the code, for those who are looking for this kind of script.
Sample source come from this blog : Managing Hierarchical Data in MySQL
The table source :
===Database model3
== Table structure for table category
|------
|Column|Type|Null|Default
|------
|//**id**//|int(11)|No|
|title|varchar(20)|No|
|id_parent|int(11)|Yes|NULL
|path|varchar(11)|No|
== Dumping data for table category
|1|ELECTRONICS|0|0
|2|TELEVISIONS|1|1/2
|3|TUBE|2|1/2/3
|4|LCD|2|1/2/4
|5|PLASMA|2|1/2/5
|6|PORTABLE ELECTRONICS|1|1/6
|7|MP3 PLAYERS|6|1/6/7
|8|FLASH|7|1/6/7/8
|9|CD PLAYERS|6|1/6/9
|10|2 WAY RADIOS|6|1/6/10
|11|PLANS|0|0
|12|SPITFIRE|11|11/12
|13|LOW QUALITY|8|1/6/7/8/13
Before the code, some explanation :
With php recursive tree, we are able to get from an array a tree with its branch, like this :
ELECTRONICS
TELEVISIONS
TUBE
LCD
PLASMA
PORTABLE ELECTRONICS
MP3 PLAYERS
FLASH
LOW QUALITY
CD PLAYERS
2 WAY RADIOS
PLANS
SPITFIRE
The very simple code that I use for the sample over :
$get_parent = "0";
$rep_1 = mysqli_query($connexion,' SELECT * FROM category');
$get_array = array();
while($don_1 = mysqli_fetch_array($rep_1))
{
$get_array[$don_1['id']] = array("id" => $don_1['id'], "id_parent" => $don_1['id_parent'], "title" => $don_1['title']);
}
mysqli_free_result($rep_1);
function tree($array,$parent,$currLevel=0)
{
foreach($array as $key => $value)
{
if($value['id_parent'] == $parent)
{
echo "".str_repeat("-", $currLevel)."title : ".$value['title']."<br/>";
$currLevel++;
tree($array,$key,$currLevel);
$currLevel--;
}
}
}
echo tree($get_array,$get_parent);
The The Adjacency List Model is great for simple mysql query, however, after several days in google search, I found that it was a real head hash to Retrieving a Single Path as we must know the final level, for static menu its fine, but for other purpose like forum parent or pages parent I found it wasn't the best way to process for Retrieving a Single Path.
I documented about the The Nested Set Model, on paper, it's great. But I found it was a bit a mess when INSERT / UPDATE and DELETE are requested.
I finally did some test with the path enumeration : Hierarchical data in MySQL (and other RDBMS) and found a solution for Retrieving a Single Path, like this :
ELECTRONICS
PORTABLE ELECTRONICS
FLASH
LOW QUALITY
The very simple code that am using :
$get_parent = "0";
$rep_1 = mysqli_query($connexion,' SELECT * FROM category WHERE id=7');
$get_array = array();
while($don_1 = mysqli_fetch_array($rep_1))
{
$explod_array = explode("/",$don_1['path'],9999);
foreach ($explod_array as $key=>$value)
{
$rep_2 = mysqli_query($connexion,' SELECT * FROM category WHERE id="'.$value.'"');
while($don_2 = mysqli_fetch_array($rep_2))
{
$get_array[$don_2['id']] = array("id" => $don_2['id'], "id_parent" => $don_2['id_parent'], "title" => $don_2['title']);
}
mysqli_free_result($rep_2);
}
}
mysqli_free_result($rep_1);
function tree_1($array,$parent,$currLevel=0)
{
foreach($array as $key => $value)
{
if($value['id_parent'] == $parent)
{
echo "".str_repeat("-", $currLevel)."id : ".$value['id']." | id_parent : ".$value['id_parent']." | title : ".$value['title']."<br/>";
$currLevel++;
tree_1($array,$key,$currLevel);
$currLevel--;
}
}
}
echo tree_1($get_array,$get_parent);
This way, I keep the same php recursive tree menu code. I do not charge my mysql table with a big query.
The badest point, is that I will have to code a bit more for the INSERT / UPDATE AND DELETE query, but I already worked on it, and it's doable with a little code.
I hope it will help.
Related
The search function is as shown below matching results after 3 characters and input and they match the product name or description. I'm looking for a change in search function of MegaShop theme in Prestashop 1.7 as follows:
The search should be able to find words parts. In example, If the user writes "hi he", the search should be able to find "high heels". This should work also in other orders, lets say "he hi" (instead of "hi he") would return also "high heels" and every other article that match these word parts in different words.
Inside /root/modules/tptnsearch the file "tptnsearch-ajax-php contains:
<?php
require_once('../../config/config.inc.php');
require_once('../../init.php');
require_once(dirname(__FILE__).'/tptnsearch.php');
$tptnsearch = new TptnSearch();
$result_products = array();
$products = array();
$tptnsearch_key = Tools::getValue('search_key');
$context = Context::getContext();
$count = 0;
$product_link = $context->link;
if (Tools::strlen($tptnsearch_key) >= 3) {
$products = Product::searchByName($context->language->id, $tptnsearch_key);
$total_products = count($products);
if ($total_products) {
for ($i = 0; $i < $total_products; $i++) {
if (($products[$i]['name']) && ($products[$i]['active'])) {
$images = Image::getImages($context->language->id, $products[$i]['id_product']);
$product = new Product($products[$i]['id_product']);
$products[$i]['link'] = $product_link->getProductLink($products[$i]['id_product'], $product->link_rewrite[1], $product->id_category_default, $product->ean13);
$products[$i]['link_rewrite'] = $product->link_rewrite[1];
$products[$i]['id_image'] = $images[0]['id_image'];
$products[$i]['price'] = Tools::displayPrice(Tools::convertPrice($products[$i]['price_tax_incl'], $context->currency), $context->currency);
if ($count < Configuration::get('TPTN_SEARCH_COUNT')) {
$result_products[] = $products[$i];
$count ++;
} else {
break;
}
}
}
}
$context->smarty->assign(array(
'enable_image' => Configuration::get('TPTN_SEARCH_IMAGE'),
'enable_price' => Configuration::get('TPTN_SEARCH_PRICE'),
'enable_name' => Configuration::get('TPTN_SEARCH_NAME'),
'search_alert' => $tptnsearch->no_product,
'link' => $context->link,
'products' => $result_products,
));
$context->smarty->display(dirname(__FILE__).'/views/templates/hook/popupsearch.tpl');
} else {
echo '<div class="wrap_item">'.$tptnsearch->three_character.'</div>';
}
I believe changes must be done within this file.
I think your approach wouldn't give you desirable behavior. Basically, I think you need to create your own search query or override existed one and modify SQL query. Because now there are only LIKE %text% conditions and mean that your text should appear in the exact same way. So it means that that you are able to find "gh he" but not "hi he".
Or you can split your search request by gaps and then search by words doing the checking if all of them are in the request. But also I think it would be better to modify LIKE from %text% to %text to exclude duplicating and search only by words beginnings
I want to, in the admin, sort by position and, when some products share the same, sort them by name.
catalog > manage categories > "Select categorie" > Category Products > sort by position
But I can't find the method that do the work. Any idea?
More or less all the sorting stuff are in:
app/code/core/Mage/Adminhtml/Block/Widget/Grid.php
And the function I was looking for (already modified to fit my pourpouse):
protected function _setCollectionOrder($column)
{
$collection = $this->getCollection();
if ($collection) {
$columnIndex = $column->getFilterIndex() ?
$column->getFilterIndex() : $column->getIndex();
$collection->setOrder($columnIndex, strtoupper($column->getDir()))
->setOrder('entity_id', 'asc'); // new line
}
return $this;
}
CakePHP Version 3.5.5
The id is visible in the address bar for view and edit which for my application creates a security risk. Any logged in user at the same company can change the id in the address bar and view or edit the details
of users they are not allowed to.
IE: https://localhost/crm/users/edit/1378 can be manually changed in the address bar to https://localhost/crm/users/edit/1215 and entered. This would display the details of user 1215 which is not allowed.
To overcome this I am selecting the ids which the user is allowed to edit and checking that the id from the url is one of these ids with the following code:
public function view($id = null)
{
if ($this->request->is('get')) {
// Select the permitted ids.
if (superuser) { // example to explain only
$query = $this->Users->find()
->where(['companyid' => $cid])
->andWhere(['status' => 1])
->toArray();
}
elseif (manager) { // example to explain only
$query = $this->Users->find()
->where(['areaid' => $areaid])
->andWhere(['status' => 1])
->toArray();
}
elseif (team leader) { // example to explain only
$query = $this->Users->find()
->where(['teamid' => $teamid])
->andWhere(['status' => 1])
->toArray();
}
// Check if the edit id is in the array of permitted ids.
$ids = array_column($query, 'id');
$foundKey = array_search($id, $ids);
// If the edit id is not in the array of permitted ids redirect to blank.
if (empty($foundKey)) {
// Handle error.
}
$user = $this->Users->get($id);
$this->set('user', $user);
$this->set('_serialize', ['user']);
}
else {
// Handle error.
}
}
My question: Is the above code the best cake way of achieving this or is there a better way to do it?
This code does work but because it's to do with security I'd appreciate any input which would improve it or point out it's weakness/es.
/////////////////////////////////////////////////////////////////////////////
As requested by cgTag please see below.
My app has superusers, managers, team leaders and users.
Managers manage one area which can contain many teams.
Team Leaders lead one team and must belong to an area.
Users are assigned to an area or a team.
For example:
Area is UK
Team is England
Team is Scotland
Team is Wales
Area is USA
Team is Florida
Team is California
Team is Texas
On index - superusers see all the superusers, managers, team leaders and users in the company.
On index - managers see themself and users in their area, team leaders in their area and users in the teams.
On index - team leaders see themself and users in their team
My problem is say the manager of area UK clicks edit on one of the records and that record is displayed with a url of https://localhost/crm/users/edit/1378
Then say this disgruntled manager makes a guess and changes the url to https://localhost/crm/users/edit/1215 and submits it then this record is displayed. (This record could be anyone, a superuser, another manager, a team leader who is not in their area or a user not in their area.
This manager could then change say the email address and submit this and it's this type of situation that I need to protect against.
My fix is to reiterate the find for the superuser, manager and team leader I've done on index in the view and edit class. This ensures that say a manager can only view or edit someone in their area.
Hopefully I've explained it well enough but if not just let me know and I'll have another go.
Thanks. Z.
/////////////////////////////////////////////////////////////////////////////
Thanks cgTag, I feel a lot more confident with this approach but I cannot use this code because you have correctly assumed that I am using an id to select all the companies results but I'm using a 40 char string. I do this so I can make my sql queries more robust.
It's impossible for you to help me unless you have all the info required so I have posted an accurate representation below:
public function view($id = null)
{
if(!$this->request->is('get') || !$id) {
//throw new ForbiddenException();
echo 'in request is NOT get or id NOT set ' . '<hr />';
}
$user_id = $this->Auth->user('id');
// regular users can never view other users.
if($user_id !== $id) {
//throw new ForbiddenException();
echo 'in $user_id !== $id ' . '<hr />';
}
// Declare client id 1.
if ($this->cid1() === false) {
echo 'in throw exception ' . '<hr />';
}
else {
$c1 = null;
$c1 = $this->cid1();
}
$company_ids = $this->getCompanyIds($c1);
$area_ids = $this->getAreaIds($user_id, $c1);
$team_ids = $this->getTeamIds($user_id, $c1);
// company_id does not exist which will cause an unknown column error.
// The column I select by is cid_1 so I have changed this column to cid_1 as shown below.
$user = $this->Users->find()
->where([
'id' => $id,
'cid_1 IN' => $company_ids,
'area_id IN' => $area_ids,
'team_id IN' => $team_ids,
'status' => 1
])
->firstOrFail();
$this->set(compact('user'));
}
The functions:
public function cid1()
{
$session = $this->request->session();
if ($session->check('Cid.one')) {
$c1 = null;
$c1 = $session->read('Cid.one');
if (!is_string($c1) || is_numeric($c1) || (strlen($c1) !== 40)) {
return false;
}
return $c1;
}
return false;
}
public function getCompanyIds($c1 = null)
{
$query = $this->Users->find()
->where(['status' => 1])
->andWhere(['cid_1' => $c1]);
return $query;
}
public function getAreaIds($c1 = null, $user_id = null)
{
$query = $this->Users->find()
->where(['status' => 1])
->andWhere(['cid_1' => $c1])
->andWhere(['area_id' => $user_id]);
return $query;
}
public function getTeamIds($c1 = null, $user_id = null)
{
$query = $this->Users->find()
->where(['status' => 1])
->andWhere(['cid_1' => $c1])
->andWhere(['team_id' => $user_id]);
return $query;
}
With this code I get the following error:
Error: SQLSTATE[21000]: Cardinality violation: 1241 Operand should contain 1 column(s)
I don't know if your example will work with this new information but at least you have all the information now.
If it can be ammended great but if not I really don't mind. And I do appreciate the time you've put aside to try to help.
Thanks Z
/////////////////////////////////////////////////////////////////////////////
#tarikul05 - Thanks for the input.
Your suggestion is very similar to my first effort at addressing this security issue but I went for security through obscurity and hid the id in a 80 char string, example below.
// In a cell
public function display($id = null)
{
// Encrypt the id to pass with view and edit links.
$idArray = str_split($id);
foreach($idArray as $arrkey => $arrVal) {
$id0 = "$idArray[0]";
$id1 = "$idArray[1]";
$id2 = "$idArray[2]";
$id3 = "$idArray[3]";
}
// Generate string for the id to be obscured in.
$enc1 = null;
$enc1 = sha1(uniqid(mt_rand(), true));
$enc2 = null;
$enc2 = sha1(uniqid(mt_rand(), true));
$encIdStr = $enc1 . $enc2;
// Split the string.
$encIdArray = null;
$encIdArray = str_split($encIdStr);
// Generate the coded sequence.
$codedSequence = null;
$codedSequence = array(9 => "$id0", 23 => "$id1", 54 => "$id2", 76 => "$id3");
// Replace the id in the random string.
$idTemp = null;
$idTemp = array_replace($encIdArray, $codedSequence);
// Implode the array.
$encryptedId = null;
$encryptedId = implode("",$idTemp);
// Send the encrypted id to the view.
$this->set('encryptedId', $encryptedId);
}
And then decrypted with
// In function in the app controller
public function decryptTheId($encryptedId = null)
{
$idArray = str_split($encryptedId);
foreach($idArray as $arrkey => $arrVal) {
$id0 = "$idArray[9]";
$id1 = "$idArray[23]";
$id2 = "$idArray[54]";
$id3 = "$idArray[76]";
}
$id = null;
$id = $id0.$id1.$id2.$id3;
return $id;
}
The problem with this was that when testing I managed to get the script to error which revealed the array positions which would of undermined the security by obscurity principle and made it a lot easier for a hacker.
Your suggestion is neater than my obscurity method but I believe md5 has been cracked therefore it should not be used.
I'm no security expert but in my opinion checking the view and edit id against an array of permitted ids is the most secure way to address this.
Maybe I'm wrong but if I do it this way there's is no way a hacker no matter what they try in the address bar can see or edit data they are not meant to and it keeps the url cleaner.
What I was originally looking/hoping for was a Cake method/function which addressed this but I couldn't find anything in the cookbook.
Thanks anyway. Z.
I would simplify your code so that the SQL that fetches the user record only finds that record if the current user has permissions. When you're dependent upon associated data for those conditions. Follow this approach even if you have to use joins.
You create the SQL conditions and then call firstOrFail() on the query. This throws a NotFoundException if there is no match for the record.
public function view($id = null) {
if(!$this->request->is('get') || !$id) {
throw new ForbiddenException();
}
$user_id = $this->Auth->user('id');
// regular users can never view other users.
if($user_id !== $id) {
throw new ForbiddenException();
}
$company_ids = $this->getCompanyIds($user_id);
$area_ids = $this->getAreaIds($user_id);
$team_ids = $this->getTeamIds($user_id);
$user = $this->Users->find()
->where([
'id' => $id
'company_id IN' => $company_ids,
'area_id IN' => $area_ids,
'team_id IN' => $team_ids,
'status' => 1
])
->firstOrFail();
$this->set(compact('user'));
}
The above logic should be sound when a user belongsTo a hierarchical structure of data. Where by, they can view many users but only if those users belong to one of the upper associations they have access too.
It works because of the IN clause of the where conditions.
Note: The IN operator throws an error if the array is empty. When you have users who can see all "teams" just exclude that where condition instead of using an empty array.
The key here is to have functions which return an array of allowed parent associations such as; getCompanyIds($user_id) would return just the company IDs the current user is allowed access too.
I think if you implement it this way then the logic is easy to understand, the security is solid and a simple firstOrFail() prevents access.
I have an item bucket with more then 30 000 items inside. What I need is to quickly search items that have particular field set to particular value, or even better is to make something like SELECT WHERE fieldValue IN (1,2,3,4) statement. Are there any ready solutions?
I searched the web and the only thing I found is "Developer's Guide to Item
Buckets and Search" but there is no code examples.
You need something like this. The Bucket item is an IIndexable so it can be searched using Sitecore 7 search API.
This code snippet below can easily be adapted to meet your needs and it's just a question of modifying the where clause.if you need any further help with the sitecore 7 syntax just write a comment on the QuickStart blog post below and I'll get back to you.
var bucketItem = Sitecore.Context.Database.GetItem(bucketPath);
if (bucketItem != null && BucketManager.IsBucket(bucketItem))
{
using (var searchContext = ContentSearchManager.GetIndex(bucketItem as IIndexable).CreateSearchContext())
{
var result = searchContext.GetQueryable<SearchResultItem().Where(x => x.Name == itemName).FirstOrDefault();
if(result != null)
Context.Item = result.GetItem();
}
}
Further reading on my blog post here:
http://coreblimey.azurewebsites.net/sitecore-7-search-quick-start-guide/
Using Sitecore Content Editor:
Go to the bucket item then In search tab, start typing the following (replace fieldname and value with actual field name and value):
custom:fieldname|value
Then hit enter, you see the result of the query, you can multiple queries at once if you want.
Using Sitecore Content Search API:
using Sitecore.ContentSearch;
using Sitecore.ContentSearch.Linq;
using Sitecore.ContentSearch.SearchTypes;
using Sitecore.ContentSearch.Linq.Utilities
ID bucketItemID = "GUID of your bucket item";
ID templateID = "Guid of your item's template under bucket";
string values = "1,2,3,4,5";
using (var context = ContentSearchManager.GetIndex("sitecore_web_index").CreateSearchContext())
{
var predicate = PredicateBuilder.True<SearchResultItem>();
predicate = PredicateBuilder.And(item => item.TemplateId == new ID(templateID)
&& item.Paths.Contains(bucketItemID));
var innerPredicate = PredicateBuilder.False<SearchResultItem>();
foreach(string val in values.Split(','))
{
innerPredicate = PredicateBuilder.False<SearchResultItem>();
innerPredicate = innerPredicate.Or(item => item["FIELDNAME"] == val);
}
predicate = predicate.And(innerPredicate);
var result = predicate.GetResults();
List<Item> ResultsItems = new List<Item>();
foreach (var hit in result.Hits)
{
Item item = hit.Document.GetItem();
if(item !=null)
{
ResultsItems .Add(item);
}
}
}
The following links can give good start with the Search API:
http://www.fusionworkshop.co.uk/news-and-insight/tech-lab/sitecore-7-search-a-quickstart-guide#.VPw8AC4kWnI
https://www.sitecore.net/learn/blogs/technical-blogs/sitecore-7-development-team/posts/2013/06/sitecore-7-poco-explained.aspx
https://www.sitecore.net/learn/blogs/technical-blogs/sitecore-7-development-team/posts/2013/05/sitecore-7-predicate-builder.aspx
Hope this helps!
Is it possible to eager load a field when querying content using the ContentManager?
I'm using the ContentManager to retrieve all content items of a specific content type. The content type has a MediaLibraryPickerField on it which is creating a select n+1 issue when I iterate over the results of the query. I'd like to force this data to be loaded upfront (join on initial query). This seems straightforward for a ContentPart but I can't get it to work for a ContentField. Is this possible or is there another way to avoid the select n+1 issue with fields?
Here's what I've tried but it has not effect:
var myQuery = _contentManager.Query(new[] { "MyContentType" })
.WithQueryHints(new QueryHints().ExpandParts<MediaPart>());
I've also tried expanding the record:
var myQuery = _contentManager.Query(new[] { "MyContentType" })
.WithQueryHints(new QueryHints().ExpandRecords<MediaPartRecord>());
Here's how I fixed the problem for a projection page, but the same method, or something simpler, could be applied in your case.
In an alternate template for the Content shape of the projection page, Content-ProjectionPage.cshtml, I did the following, which creates a lookup for media that items will be able to use later:
// Pre-fetch images
var projectionItems = ((IEnumerable<dynamic>)
((IEnumerable<dynamic>)Model.Content.Items)
.First(i => i.Metadata.Type == "List").Items)
.Select(s => (ContentItem)s.ContentItem);
var mediaLibraryFields = projectionItems
.SelectMany(i => i.Parts.SelectMany(p => p.Fields.Where(f => f is MediaLibraryPickerField)))
.Cast<MediaLibraryPickerField>();
var firstMediaIds = mediaLibraryFields
.Select(f => f.Ids.FirstOrDefault())
.Where(id => id != default(int))
.Distinct()
.ToArray();
var firstMedia = WorkContext.Resolve<IContentManager>()
.GetMany<MediaPart>(firstMediaIds, VersionOptions.Published, QueryHints.Empty);
var mediaCache = Layout.MediaCache == null
? Layout.MediaCache = new Dictionary<int, MediaPart>()
: (Dictionary<int, MediaPart>) Layout.MediaCache;
foreach (var media in firstMedia) {
mediaCache.Add(media.Id, media);
}
In your case, you don't have to do the complicated drilling into shapes to dig out the fields, as you have access to them directly. I had to do that because the view or a shape table provider is unfortunately the easiest place for me to do that.
Then, when I want to display an image, all I have to do is access my lookup and try to get it from there. In my alternate template MediaLibraryPicker.Summary.cshtml, I do this:
var field = (MediaLibraryPickerField)Model.ContentField;
var imageIds = field.Ids;
if (imageIds.Any()) {
var cm = Model.ContentPart.ContentItem.ContentManager as IContentManager;
var title = cm == null || Model.ContentPart == null
? "" : cm.GetItemMetadata(Model.ContentPart).DisplayText;
var mediaCache = Layout.MediaCache as Dictionary<int, MediaPart>;
var firstImage = mediaCache != null
? mediaCache[imageIds.First()]
: cm.Get(imageIds.First()).As<MediaPart>();
<div class="gallery">
<img src="#Display.ResizeMediaUrl(Path: firstImage.MediaUrl, Width: 132)" class="main" alt="#title"/>
</div>
}
I'm only displaying the first image in the field, here, but you could change that where it does f.Ids.FirstOrDefault(). Just do f.Ids instead and replace the Select with a SelectMany. Also change the summary template so it displays all images after looking them up in the same dictionary.
Once I did that, I had no select N+1, and instead got a single SQL query for all the images on the page.