1
0
mirror of https://github.com/elisspace/autopsy.git synced 2026-09-06 02:24:30 +00:00

Merge branch 'collaborative' of https://github.com/sleuthkit/autopsy into add_data_source

This commit is contained in:
Eugene Livis
2015-05-26 11:10:36 -04:00
14 changed files with 178 additions and 112 deletions

View File

@@ -21,69 +21,70 @@ package org.sleuthkit.autopsy.corecomponentinterfaces;
import javax.swing.JPanel;
/*
* Defines an interface used by the Add DataSource wizard to discover different
* Data SourceProcessors.
/**
* Interface used by the Add DataSource wizard to allow different
* types of data sources to be added to a case. Examples of data
* sources include disk images, local files, etc.
*
* Each data source may have its unique attributes and may need to be processed
* differently.
*
* The DataSourceProcessor interface defines a uniform mechanism for the Autopsy UI
* The interface provides a uniform mechanism for the Autopsy UI
* to:
* - collect details for the data source to be processed.
* - Process the data source in the background
* - Be notified when the processing is complete
* - Collect details from the user about the data source to be processed.
* - Process the data source in the background and add data to the database
* - Provides progress feedback to the user / UI.
*/
public interface DataSourceProcessor {
/*
/**
* The DSP Panel may fire Property change events
* The caller must enure to add itself as a listener and
* then react appropriately to the events
*/
enum DSP_PANEL_EVENT {
UPDATE_UI, // the content of JPanel has changed that MAY warrant updates to the caller UI
FOCUS_NEXT // the caller UI may move focus the the next UI element, following the panel.
UPDATE_UI, ///< the content of JPanel has changed that MAY warrant updates to the caller UI
FOCUS_NEXT ///< the caller UI may move focus the the next UI element, following the panel.
};
/**
* Returns the type of Data Source it handles.
* This name gets displayed in the drop-down listbox
**/
*/
String getDataSourceType();
/**
* Returns the picker panel to be displayed along with any other
* runtime options supported by the data source handler.
**/
* runtime options supported by the data source handler. The
* DSP is responsible for storing the settings so that a later
* call to run() will have the user-specified settings.
*
* Should be less than 544 pixels wide and 173 pixels high.
*/
JPanel getPanel();
/**
* Called to validate the input data in the panel.
* Returns true if no errors, or
* Returns false if there is an error.
**/
*/
boolean isPanelValid();
/**
* Called to invoke the handling of Data source in the background.
* Returns after starting the background thread
* @param settings wizard settings to read/store properties
* @param progressPanel progress panel to be updated while processing
* Called to invoke the handling of data source in the background.
* Returns after starting the background thread.
*
**/
* @param progressPanel progress panel to be updated while processing
* @param dspCallback Contains the callback method DataSourceProcessorCallback.done() that the DSP must call when the background thread finishes with errors and status.
*/
void run(DataSourceProcessorProgressMonitor progressPanel, DataSourceProcessorCallback dspCallback);
/**
* Called to cancel the background processing.
**/
*/
void cancel();
/**
* Called to reset/reinitialize the DSP.
**/
*/
void reset();
}

View File

@@ -16,7 +16,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.sleuthkit.autopsy.corecomponentinterfaces;
import java.awt.EventQueue;
@@ -25,42 +24,52 @@ import org.sleuthkit.datamodel.Content;
/**
* Abstract class for a callback for a DataSourceProcessor.
*
* Ensures that DSP invokes the caller overridden method, doneEDT(),
* in the EDT thread.
*
*
* Ensures that DSP invokes the caller overridden method, doneEDT(), in the EDT
* thread.
*
*/
public abstract class DataSourceProcessorCallback {
public enum DataSourceProcessorResult
{
NO_ERRORS,
CRITICAL_ERRORS,
NONCRITICAL_ERRORS,
public enum DataSourceProcessorResult {
NO_ERRORS, ///< No errors were encountered while ading the data source
CRITICAL_ERRORS, ///< No data was added to the database. There were fundamental errors processing the data (such as no data or system failure).
NONCRITICAL_ERRORS, ///< There was data added to the database, but there were errors from data corruption or a small number of minor issues.
};
/*
* Invoke the caller supplied callback function on the EDT thread
/**
* Called by a DSP implementation when it is done adding a data source
* to the database. Users of the DSP can override this method if they do
* not want to be notified on the EDT. Otherwise, this method will call
* doneEDT() with the same arguments.
* @param result Code for status
* @param errList List of error strings
* @param newContents List of root Content objects that were added to database. Typically only one is given.
*/
public void done(DataSourceProcessorResult result, List<String> errList, List<Content> newContents)
{
public void done(DataSourceProcessorResult result, List<String> errList, List<Content> newContents) {
final DataSourceProcessorResult resultf = result;
final List<String> errListf = errList;
final List<Content> newContentsf = newContents;
// Invoke doneEDT() that runs on the EDT .
EventQueue.invokeLater(new Runnable() {
@Override
public void run() {
doneEDT(resultf, errListf, newContentsf );
}
});
// Invoke doneEDT() that runs on the EDT .
EventQueue.invokeLater(new Runnable() {
@Override
public void run() {
doneEDT(resultf, errListf, newContentsf);
}
});
}
/*
* calling code overrides to provide its own calllback
*/
public abstract void doneEDT(DataSourceProcessorResult result, List<String> errList, List<Content> newContents);
/**
* Called by done() if the default implementation is used. Users of DSPs
* that have UI updates to do after the DSP is finished adding the DS can
* implement this method to receive the updates on the EDT.
*
* @param result Code for status
* @param errList List of error strings
* @param newContents List of root Content objects that were added to database. Typically only one is given.
*/
public abstract void doneEDT(DataSourceProcessorResult result, List<String> errList, List<Content> newContents);
};

View File

@@ -18,10 +18,10 @@
*/
package org.sleuthkit.autopsy.corecomponentinterfaces;
/*
/**
* An GUI agnostic DataSourceProcessorProgressMonitor interface for DataSourceProcesssors to
* indicate progress.
* It models after a JProgressbar though it could use any underlying implementation
* It models after a JProgressbar though it could use any underlying implementation (or NoOps)
*/
public interface DataSourceProcessorProgressMonitor {

View File

@@ -78,8 +78,10 @@ import org.sleuthkit.datamodel.TskData;
@ServiceProvider(service = FrameCapture.class)
})
public class FXVideoPanel extends MediaViewVideoPanel {
private static final String[] EXTENSIONS = new String[]{".mov", ".m4v", ".flv", ".mp4", ".mpg", ".mpeg"}; //NON-NLS
// Refer to https://docs.oracle.com/javafx/2/api/javafx/scene/media/package-summary.html
// for Javafx supported formats
private static final String[] EXTENSIONS = new String[]{".m4v", ".fxm", ".flv", ".m3u8", ".mp4", ".aif", ".aiff", ".mp3", "m4a", ".wav"}; //NON-NLS
private static final List<String> MIMETYPES = Arrays.asList("audio/x-aiff", "video/x-javafx", "video/x-flv", "application/vnd.apple.mpegurl", " audio/mpegurl", "audio/mpeg", "video/mp4", "audio/x-m4a", "video/x-m4v", "audio/x-wav"); //NON-NLS
private static final Logger logger = Logger.getLogger(MediaViewVideoPanel.class.getName());
@@ -478,6 +480,10 @@ public class FXVideoPanel extends MediaViewVideoPanel {
pauseButton.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent e) {
if (mediaPlayer == null) {
return;
}
Status status = mediaPlayer.getStatus();
switch (status) {
@@ -496,7 +502,7 @@ public class FXVideoPanel extends MediaViewVideoPanel {
// If the MediaPlayer is in an unexpected state, stop playback.
mediaPlayer.stop();
setInfoLabelText(NbBundle.getMessage(this.getClass(),
"FXVideoPanel.pauseButton.infoLabel.playbackErr"));
"FXVideoPanel.pauseButton.infoLabel.playbackErr"));
break;
}
}
@@ -505,6 +511,10 @@ public class FXVideoPanel extends MediaViewVideoPanel {
stopButton.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent e) {
if (mediaPlayer == null) {
return;
}
mediaPlayer.stop();
}
});
@@ -512,6 +522,10 @@ public class FXVideoPanel extends MediaViewVideoPanel {
progressSlider.valueProperty().addListener(new InvalidationListener() {
@Override
public void invalidated(Observable o) {
if (mediaPlayer == null) {
return;
}
if (progressSlider.isValueChanging()) {
mediaPlayer.seek(duration.multiply(progressSlider.getValue() / 100.0));
}
@@ -559,6 +573,9 @@ public class FXVideoPanel extends MediaViewVideoPanel {
* media.
*/
private void updateProgress() {
if (mediaPlayer == null) {
return;
}
Duration currentTime = mediaPlayer.getCurrentTime();
updateSlider(currentTime);
updateTime(currentTime);
@@ -634,6 +651,10 @@ public class FXVideoPanel extends MediaViewVideoPanel {
@Override
public void run() {
if (mediaPlayer == null) {
return;
}
duration = mediaPlayer.getMedia().getDuration();
long durationInMillis = (long) mediaPlayer.getMedia().getDuration().toMillis();
@@ -657,6 +678,10 @@ public class FXVideoPanel extends MediaViewVideoPanel {
@Override
public void run() {
if (mediaPlayer == null) {
return;
}
Duration beginning = mediaPlayer.getStartTime();
mediaPlayer.stop();
mediaPlayer.pause();

View File

@@ -33,11 +33,9 @@ import java.util.List;
import java.util.logging.Level;
import javax.imageio.ImageIO;
import javax.swing.ImageIcon;
import org.openide.util.Exceptions;
import org.sleuthkit.autopsy.casemodule.Case;
import org.sleuthkit.autopsy.corelibs.ScalrWrapper;
import org.sleuthkit.datamodel.AbstractFile;
import org.sleuthkit.datamodel.BlackboardArtifact;
import org.sleuthkit.datamodel.BlackboardAttribute;
import org.sleuthkit.datamodel.BlackboardAttribute.ATTRIBUTE_TYPE;
import org.sleuthkit.datamodel.Content;
@@ -55,7 +53,11 @@ public class ImageUtils {
private static final Logger logger = Logger.getLogger(ImageUtils.class.getName());
private static final Image DEFAULT_ICON = new ImageIcon("/org/sleuthkit/autopsy/images/file-icon.png").getImage(); //NON-NLS
private static final List<String> SUPP_EXTENSIONS = Arrays.asList(ImageIO.getReaderFileSuffixes());
private static final List<String> SUPP_MIME_TYPES = Arrays.asList(ImageIO.getReaderMIMETypes());
private static final List<String> SUPP_MIME_TYPES = new ArrayList(Arrays.asList(ImageIO.getReaderMIMETypes()));
static {
SUPP_MIME_TYPES.add("image/x-ms-bmp");
}
/**
* Get the default Icon, which is the icon for a file.
* @return
@@ -88,14 +90,17 @@ public class ImageUtils {
return true;
}
}
// if the file type is known and we don't support it, bail
if (attributes.size() > 0) {
return false;
}
}
catch (TskCoreException ex) {
logger.log(Level.WARNING, "Error while getting file signature from blackboard.", ex); //NON-NLS
}
final String extension = f.getNameExtension();
// if we have an extension, check it
final String extension = f.getNameExtension();
if (extension.equals("") == false) {
// Note: thumbnail generator only supports JPG, GIF, and PNG for now
if (SUPP_EXTENSIONS.contains(extension)) {
@@ -109,7 +114,8 @@ public class ImageUtils {
/**
* Get an icon of a specified size.
* Get a thumbnail of a specified size. Generates the image if it is
* not already cached.
*
* @param content
* @param iconSize
@@ -118,6 +124,7 @@ public class ImageUtils {
public static Image getIcon(Content content, int iconSize) {
Image icon;
// If a thumbnail file is already saved locally
// @@@ Bug here in that we do not refer to size in the cache.
File file = getFile(content.getId());
if (file.exists()) {
try {
@@ -125,7 +132,7 @@ public class ImageUtils {
if (bicon == null) {
icon = DEFAULT_ICON;
} else if (bicon.getWidth() != iconSize) {
icon = generateAndSaveIcon(content, iconSize);
icon = generateAndSaveIcon(content, iconSize, file);
} else {
icon = bicon;
}
@@ -134,18 +141,17 @@ public class ImageUtils {
icon = DEFAULT_ICON;
}
} else { // Make a new icon
icon = generateAndSaveIcon(content, iconSize);
icon = generateAndSaveIcon(content, iconSize, file);
}
return icon;
}
/**
* Get the cached file of the icon. Generates the icon and its file if it
* doesn't already exist, so this method guarantees to return a file that
* exists.
* Get a thumbnail of a specified size. Generates the image if it is
* not already cached.
* @param content
* @param iconSize
* @return
* @return File object for cached image. Is guaranteed to exist.
*/
public static File getIconFile(Content content, int iconSize) {
if (getIcon(content, iconSize) != null) {
@@ -155,13 +161,12 @@ public class ImageUtils {
}
/**
* Get the cached file of the content object with the given id.
*
* The returned file may not exist.
* Get a file object for where the cached icon should exist. The returned file may not exist.
*
* @param id
* @return
*/
// TODO: This should be private and be renamed to something like getCachedThumbnailLocation().
public static File getFile(long id) {
return new File(Case.getCurrentCase().getCacheDirectory() + File.separator + id + ".png");
}
@@ -223,18 +228,24 @@ public class ImageUtils {
}
private static Image generateAndSaveIcon(Content content, int iconSize) {
/**
* Generate an icon and save it to specified location.
* @param content File to generate icon for
* @param iconSize
* @param saveFile Location to save thumbnail to
* @return Generated icon or null on error
*/
private static Image generateAndSaveIcon(Content content, int iconSize, File saveFile) {
Image icon = null;
try {
icon = generateIcon(content, iconSize);
if (icon == null) {
return DEFAULT_ICON;
} else {
File f = getFile(content.getId());
if (f.exists()) {
f.delete();
if (saveFile.exists()) {
saveFile.delete();
}
ImageIO.write((BufferedImage) icon, "png", getFile(content.getId())); //NON-NLS
ImageIO.write((BufferedImage) icon, "png", saveFile); //NON-NLS
}
} catch (IOException ex) {
logger.log(Level.WARNING, "Could not write cache thumbnail: " + content, ex); //NON-NLS
@@ -243,7 +254,7 @@ public class ImageUtils {
}
/*
* Generate a scaled image
* Generate and return a scaled image
*/
private static BufferedImage generateIcon(Content content, int iconSize) {

View File

@@ -44,7 +44,14 @@ class CollapseAction extends AbstractAction {
// Collapse all
BeanTreeView tree = DirectoryTreeTopComponent.findInstance().getTree();
collapseAll(tree, selectedNode[0]);
if(selectedNode.length != 0) {
collapseSelectedNode(tree, selectedNode[0]);
} else {
// If no node is selected, all the level-2 nodes (children of the
// root node) are collapsed.
for(Node childOfRoot: em.getRootContext().getChildren().getNodes())
collapseSelectedNode(tree, childOfRoot);
}
}
/**
@@ -53,13 +60,13 @@ class CollapseAction extends AbstractAction {
* @param tree the given tree
* @param currentNode the current selectedNode
*/
private void collapseAll(BeanTreeView tree, Node currentNode) {
private void collapseSelectedNode(BeanTreeView tree, Node currentNode) {
Children c = currentNode.getChildren();
for (Node next : c.getNodes()) {
if (tree.isExpanded(next)) {
this.collapseAll(tree, next);
this.collapseSelectedNode(tree, next);
}
}

View File

@@ -39,6 +39,7 @@ import org.sleuthkit.datamodel.Content;
import org.sleuthkit.datamodel.Directory;
import org.sleuthkit.datamodel.Image;
import org.sleuthkit.datamodel.TskCoreException;
import org.sleuthkit.datamodel.VirtualDirectory;
/**
* This class sets the actions for the nodes in the directory tree and creates
@@ -106,22 +107,35 @@ class DirectoryTreeFilterNode extends FilterNode {
actions.add(ExtractAction.getInstance());
}
// file search action
final Image img = this.getLookup().lookup(Image.class);
if (img != null) {
actions.add(new FileSearchAction(
NbBundle.getMessage(this.getClass(), "DirectoryTreeFilterNode.action.openFileSrcByAttr.text")));
VirtualDirectory virtualDirectory = this.getLookup().lookup(VirtualDirectory.class);
// determine if the virtualDireory is at root-level (Logical File Set).
boolean isRootVD = false;
if (virtualDirectory != null) {
try {
if (virtualDirectory.getParent() == null) {
isRootVD = true;
}
} catch (TskCoreException ex) {
logger.log(Level.WARNING, "Error determining the parent of the virtual directory", ex); // NON-NLS
}
}
//ingest action
actions.add(new AbstractAction(
NbBundle.getMessage(this.getClass(), "DirectoryTreeFilterNode.action.runIngestMods.text")) {
@Override
public void actionPerformed(ActionEvent e) {
final RunIngestModulesDialog ingestDialog = new RunIngestModulesDialog(Collections.<Content>singletonList(content));
ingestDialog.display();
}
});
// 'run ingest' action and 'file search' action are added only if the
// selected node is img node or a root level virtual directory.
if (img != null || isRootVD) {
actions.add(new FileSearchAction(
NbBundle.getMessage(this.getClass(), "DirectoryTreeFilterNode.action.openFileSrcByAttr.text")));
actions.add(new AbstractAction(
NbBundle.getMessage(this.getClass(), "DirectoryTreeFilterNode.action.runIngestMods.text")) {
@Override
public void actionPerformed(ActionEvent e) {
final RunIngestModulesDialog ingestDialog = new RunIngestModulesDialog(Collections.<Content>singletonList(content));
ingestDialog.display();
}
});
}
}
//check if delete actions should be added

View File

@@ -98,7 +98,7 @@ class SampleFileIngestModule implements FileIngestModule {
@Override
public IngestModule.ProcessResult process(AbstractFile file) {
if (attrId != -1) {
if (attrId == -1) {
return IngestModule.ProcessResult.ERROR;
}

View File

@@ -248,7 +248,7 @@ final class PhotoRecCarverFileIngestModule implements FileIngestModule {
*/
@Override
public void shutDown() {
if (refCounter.decrementAndGet(this.context.getJobId()) == 0) {
if (this.context != null && refCounter.decrementAndGet(this.context.getJobId()) == 0) {
try {
// The last instance of this module for an ingest job cleans out
// the working paths map entry for the job and deletes the temp dir.

View File

@@ -153,6 +153,7 @@ public enum ThumbnailCache {
}
private static File getCacheFile(long id) {
// @@@ should use ImageUtils.getFile();
return new File(Case.getCurrentCase().getCacheDirectory() + File.separator + id + ".png");
}

View File

@@ -340,7 +340,6 @@ class LuceneQuery implements KeywordSearchQuery {
List<String> snippetList = highlightResponse.get(docId).get(Server.Schema.TEXT.toString());
// list is null if there wasn't a snippet
if (snippetList != null) {
snippetList.sort(null);
snippet = EscapeUtil.unEscapeHtml(snippetList.get(0)).trim();
}
}
@@ -440,7 +439,6 @@ class LuceneQuery implements KeywordSearchQuery {
//docs says makes sense for the original Highlighter only, but not really
//analyze all content SLOW! consider lowering
q.setParam("hl.maxAnalyzedChars", Server.HL_ANALYZE_CHARS_UNLIMITED); //NON-NLS
q.setParam("hl.preserveMulti", true); //NON-NLS
try {
QueryResponse response = solrServer.query(q, METHOD.POST);
@@ -453,8 +451,6 @@ class LuceneQuery implements KeywordSearchQuery {
if (contentHighlights == null) {
return "";
} else {
// Sort contentHighlights in order to get consistently same snippet.
contentHighlights.sort(null);
// extracted content is HTML-escaped, but snippet goes in a plain text field
return EscapeUtil.unEscapeHtml(contentHighlights.get(0)).trim();
}

View File

@@ -1097,8 +1097,7 @@ public class Server {
filterQuery = filterQuery + Server.ID_CHUNK_SEP + chunkID;
}
q.addFilterQuery(filterQuery);
// sort the TEXT field
q.setSortField(Schema.TEXT.toString(), SolrQuery.ORDER.asc);
q.setFields(Schema.TEXT.toString());
try {
// Get the first result.
SolrDocument solrDocument = solrCore.query(q).getResults().get(0);

View File

@@ -271,17 +271,22 @@ that the samples do not do anything particularly useful.
Autopsy allows you to provide a graphical panel that will be displayed when the user decides to enable the ingest module. This panel is supposed to be for settings that the user may turn on or off for different data sources.
To provide options for each ingest job:
- Update org.sleuthkit.autopsy.ingest.IngestModuleFactory.hasIngestJobSettingsPanel() in your factory class to return true.
- Update org.sleuthkit.autopsy.ingest.IngestModuleFactory.getIngestJobSettingsPanel() in your factory class to return a IngestModuleIngestJobSettingsPanel that displays the needed configuration options and returns a IngestModuleIngestJobSettings object based on the settings. This will get passed in the last configuration setting that was used, so that you can populate the panel accordlingly and the user doesn't have to choose new settings.
- Create a class based on org.sleuthkit.autopsy.ingest.IngestModuleIngestJobSettings to store the settings.
- Update org.sleuthkit.autopsy.ingest.IngestModuleFactory.getIngestJobSettingsPanel() in your factory class to return a IngestModuleIngestJobSettingsPanel that displays the needed configuration options. The org.sleuthkit.autopsy.ingest.IngestModuleIngestJobSettingsPanel.getSettings() method should return an instance of a org.sleutkit.autopsy.ingest.IngestModuleIngestJobSettings object based on the user-specified settings (see next bullet).
- Create a class that implements org.sleutkit.autopsy.ingest.IngestModuleIngestJobSettings. Your IngestModuleIngestJobSettingsPanel should store settings in here. This class needs to be Serializable, so keep all data types simple or mark them as transient with some custom deserialization code. You should also set the serialVersionUID (see http://stackoverflow.com/questions/285793/what-is-a-serialversionuid-and-why-should-i-use-it).
- If you decide to store settings internal to the module (NOT RECOMMENDED), the getSettings() method can return an instance of NoIngestModuleIngestJobSettings.
- Your instance of IngestModuleIngestJobSettings will be saved and passed to your panel the next time so that you can pre-populate it accordingly.
Your panel should create the IngestModuleIngestJobSettings class to store the settings and that will be passed back into your factory with each call to createDataSourceIngestModule() or createFileIngestModule(). The way that we have implemented this in Autopsy modules is that the factory casts the IngestModuleINgestJobSettings object to the module-specific implementation and then passes it into the constructor of the ingest module. The ingest module can then call whatever getter methods that were defined based on the panel settings.
Your panel should create the IngestModuleIngestJobSettings class to store the settings and that will be passed back into your factory with each call to createDataSourceIngestModule() or createFileIngestModule(). The factory should cast it to its internal class that implements IngestModuleIngestJobSettings and pass that object into the constructor of its ingest module so that it can use the settings when it runs.
You can also implement the getDefaultIngestJobSettings() method to return an instance of your IngestModuleIngestJobSettings class with default settings. Autopsy will call this when the module has not been run before.
NOTE: We recommend storing simple data in the IngestModuleIngestJobSettings-based class. In the case of our hash lookup module, we store the string names of the hash databases to do lookups in. We then get the hash database handles in the call to startUp() using the global module settings.
NOTE: The main benefit of using the IngestModuleIngestJobSettings-based class to store settings (versus some static variables in your package) are:
- When multiple jobs are running at the same time, each can have their own settings.
- Autopsy persists them so that the last used settings get passed into the call to getIngestJobSettingsPanel() and you don't need to save them yoursevles to provide the user the benefit of re-using the last settings.

View File

@@ -44,12 +44,10 @@ The blackboard allows modules to communicate with each other and the UI. It has
The blackboard is not unique to Autopsy. It is part of The Sleuth Kit datamodel and The Sleuth Kit Framework. In the name of reducing the amount of documentation that we need to maintain, we provide links here to those documentation sources.
- Details on the blackboard concepts (artifacts versus attributes) can be found at http://sleuthkit.org/sleuthkit/docs/framework-docs/mod_bbpage.html. These documents are about the C++ implementation of the blackboard, but it is the same concepts.
- Details of the Java classes can be found in \ref jni_blackboard section of the The Sleuth Kit JNI documents (http://sleuthkit.org/sleuthkit/docs/jni-docs/).
- \ref mod_bbpage (http://sleuthkit.org/sleuthkit/docs/jni-docs/mod_bbpage.html)
\subsection mod_dev_other_services Framework Services and Utilities
\section mod_dev_other_services Framework Services and Utilities
The following are basic services that are available to any module. They are provided here to be used as a reference. When you are developing your module and feel like something should be provided by the framework, then refer to this list to find out where it could be. If you don't find it, let us know and we'll talk about adding it for other writers to benefit.