From 2a900a8c5bb4099532e3f4dc79fcf9a1e8ea1f91 Mon Sep 17 00:00:00 2001
From: zzz <zzz@i2pmail.org>
Date: Fri, 3 Dec 2021 06:26:14 -0500
Subject: [PATCH] i2psnark: Add torrent edit page

Additional UI cleanup to follow
---
 .../java/src/org/klomp/snark/MetaInfo.java    |  10 +-
 .../java/src/org/klomp/snark/Snark.java       |   9 +
 .../org/klomp/snark/web/I2PSnarkServlet.java  | 330 +++++++++++++++++-
 3 files changed, 328 insertions(+), 21 deletions(-)

diff --git a/apps/i2psnark/java/src/org/klomp/snark/MetaInfo.java b/apps/i2psnark/java/src/org/klomp/snark/MetaInfo.java
index c191a8220f..e0f412297d 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/MetaInfo.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/MetaInfo.java
@@ -82,8 +82,9 @@ public class MetaInfo
    *  @param created_by may be null
    *  @param url_list may be null
    *  @param comment may be null
+   *  @since public since 0.9.53, was package private
    */
-  MetaInfo(String announce, String name, String name_utf8, List<List<String>> files, List<Long> lengths,
+  public MetaInfo(String announce, String name, String name_utf8, List<List<String>> files, List<Long> lengths,
            int piece_length, byte[] piece_hashes, long length, boolean privateTorrent,
            List<List<String>> announce_list, String created_by, List<String> url_list, String comment)
   {
@@ -442,9 +443,12 @@ public class MetaInfo
   }
 
   /**
-   * Returns the piece hashes. Only used by storage so package local.
+   *  Returns the piece hashes.
+   *
+   *  @return not a copy, do not modify
+   *  @since public since 0.9.53, was package private
    */
-  byte[] getPieceHashes()
+  public byte[] getPieceHashes()
   {
     return piece_hashes;
   }
diff --git a/apps/i2psnark/java/src/org/klomp/snark/Snark.java b/apps/i2psnark/java/src/org/klomp/snark/Snark.java
index b95e9efabc..d669a674d7 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/Snark.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/Snark.java
@@ -1321,6 +1321,15 @@ public class Snark
       }
   }
 
+  /**
+   * Call after editing torrent.
+   * Caller must ensure infohash, files, etc. did not change.
+   *
+   * @since 0.9.53
+   */
+  public void replaceMetaInfo(MetaInfo metainfo) {
+      meta = metainfo;
+  }
 
   ///////////// Begin StorageListener methods
 
diff --git a/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java b/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java
index bf641e3940..d25205c1ce 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java
@@ -3,6 +3,7 @@ package org.klomp.snark.web;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.io.PrintWriter;
 import java.io.Serializable;
 import java.lang.reflect.InvocationTargetException;
@@ -22,6 +23,7 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeMap;
+import java.util.TreeSet;
 
 import javax.servlet.ServletConfig;
 import javax.servlet.ServletException;
@@ -35,8 +37,10 @@ import net.i2p.data.Base64;
 import net.i2p.data.DataHelper;
 import net.i2p.data.Hash;
 import net.i2p.servlet.util.ServletUtil;
+import net.i2p.util.FileUtil;
 import net.i2p.util.Log;
 import net.i2p.util.SecureFile;
+import net.i2p.util.SecureFileOutputStream;
 import net.i2p.util.SystemVersion;
 import net.i2p.util.Translate;
 import net.i2p.util.UIMessages;
@@ -251,11 +255,12 @@ public class I2PSnarkServlet extends BasicServlet {
                     }
                 } else {
                     String base = addPaths(req.getRequestURI(), "/");
+                    boolean showEdit = req.getParameter("showEdit") != null;
                     String listing = getListHTML(resource, base, true, method.equals("POST") ? req.getParameterMap() : null,
-                                                 req.getParameter("sort"));
+                                                 req.getParameter("sort"), showEdit);
                     if (method.equals("POST")) {
                         // P-R-G
-                        sendRedirect(req, resp, "");
+                        sendRedirect(req, resp, showEdit ? "?showEdit" : "");
                     } else if (listing != null) {
                         setHTMLHeaders(resp, cspNonce, true);
                         resp.getWriter().write(listing);
@@ -2984,8 +2989,8 @@ public class I2PSnarkServlet extends BasicServlet {
      * @return String of HTML or null if postParams != null
      * @since 0.7.14
      */
-    private String getListHTML(File xxxr, String base, boolean parent, Map<String, String[]> postParams, String sortParam)
-        throws IOException
+    private String getListHTML(File xxxr, String base, boolean parent, Map<String, String[]> postParams,
+                               String sortParam, boolean showEdit) throws IOException
     {
         String decodedBase = decodePath(base);
         String title = decodedBase;
@@ -3027,6 +3032,10 @@ public class I2PSnarkServlet extends BasicServlet {
                         _manager.startTorrent(snark);
                     } else if (postParams.get("recheck") != null) {
                         _manager.recheckTorrent(snark);
+                    } else if (postParams.get("editTorrent") != null) {
+                        saveTorrentEdit(snark, postParams);
+                    } else if (postParams.get("showEdit") != null) {
+                        // P-R-G only
                     } else {
                         _manager.addMessage("Unknown command");
                     }
@@ -3055,7 +3064,7 @@ public class I2PSnarkServlet extends BasicServlet {
             r = new File("");
         }
 
-        boolean showStopStart = snark != null;
+        boolean showStopStart = snark != null && !showEdit;
         Storage storage = snark != null ? snark.getStorage() : null;
         boolean showPriority = storage != null && !storage.complete() &&
                                r.isDirectory();
@@ -3093,7 +3102,7 @@ public class I2PSnarkServlet extends BasicServlet {
         final boolean includeForm = showStopStart || showPriority || er || ec;
         if (includeForm) {
             buf.append("<form action=\"").append(base).append("\" method=\"POST\">\n" +
-                       "<input type=\"hidden\" name=\"nonce\" value=\"").append(_nonce).append("\" >\n");
+                       "<input type=\"hidden\" name=\"nonce\" value=\"").append(_nonce).append("\">\n");
             if (sortParam != null) {
                 buf.append("<input type=\"hidden\" name=\"sort\" value=\"")
                    .append(DataHelper.stripHTML(sortParam)).append("\" >\n");
@@ -3137,7 +3146,7 @@ public class I2PSnarkServlet extends BasicServlet {
 
             String announce = null;
             MetaInfo meta = snark.getMetaInfo();
-            if (meta != null) {
+            if (meta != null && !showEdit) {
                 announce = meta.getAnnounce();
                 if (announce == null)
                     announce = snark.getTrackerURL();
@@ -3218,7 +3227,7 @@ public class I2PSnarkServlet extends BasicServlet {
                     toThemeImg(buf, "details");
                     buf.append("</td><td><b>")
                        .append(_t("Comment")).append("</b></td><td>")
-                       .append(DataHelper.stripHTML(com))
+                       .append(DataHelper.escapeHTML(com))
                        .append("</td></tr>\n");
                 }
                 long dat = meta.getCreationDate();
@@ -3239,7 +3248,7 @@ public class I2PSnarkServlet extends BasicServlet {
                     toThemeImg(buf, "details");
                     buf.append("</td><td><b>")
                        .append(_t("Created By")).append("</b></td><td>")
-                       .append(DataHelper.stripHTML(cby))
+                       .append(DataHelper.escapeHTML(cby))
                        .append("</td></tr>\n");
                 }
                 long[] dates = _manager.getSavedAddedAndCompleted(snark);
@@ -3390,7 +3399,7 @@ public class I2PSnarkServlet extends BasicServlet {
                     buf.append("<b>").append(_t("Starting")).append("&hellip;</b>");
                 } else if (snark.isAllocating()) {
                     buf.append("<b>").append(_t("Allocating")).append("&hellip;</b>");
-                } else {
+                } else if (isTopLevel && !showEdit) {
                     boolean isRunning = !snark.isStopped();
                     buf.append("<input type=\"submit\" value=\"");
                     if (isRunning)
@@ -3398,14 +3407,23 @@ public class I2PSnarkServlet extends BasicServlet {
                     else
                         buf.append(_t("Start")).append("\" name=\"start\" class=\"starttorrent\">\n");
                     buf.append("<input type=\"submit\" name=\"recheck\" value=\"").append(_t("Force Recheck"));
-                    if (isRunning)
+                    if (isRunning) {
                         buf.append("\" class=\"disabled\" disabled=\"disabled\" title=\"")
-                           .append(_t("Stop the torrent in order to check file integrity"))
-                           .append("\">\n");
-                    else
+                           .append(_t("Torrent must be stopped"));
+                    } else {
+                        buf.append("\" class=\"reload\" title=\"")
+                           .append(_t("Check integrity of the downloaded files"));
+                    }
+                    buf.append("\">\n" +
+                               "<input type=\"submit\" name=\"showEdit\" value=\"").append(_t("Edit Torrent"));
+                    if (isRunning) {
+                        buf.append("\" class=\"disabled\" disabled=\"disabled\" title=\"")
+                           .append(_t("Torrent must be stopped"));
+                    } else {
                         buf.append("\" class=\"reload\" title=\"")
-                           .append(_t("Check integrity of the downloaded files"))
-                           .append("\">\n");
+                           .append(_t("Add or remove trackers"));
+                    }
+                    buf.append("\">\n");
                 }
                 boolean showInOrder = storage != null && !storage.complete() &&
                                       meta != null;
@@ -3439,6 +3457,13 @@ public class I2PSnarkServlet extends BasicServlet {
         }
         buf.append("</table>\n");
 
+        if (snark != null && isTopLevel && showEdit) {
+            // Edit torrent. Show edit section only.
+            displayTorrentEdit(snark, base, buf);
+            buf.append("</form></div></div></center></body></html>");
+            return buf.toString();
+        }
+
         if (snark != null && !r.exists()) {
             // fixup TODO
             buf.append("<table class=\"resourceError\" id=\"DoesNotExist\"><tr><th colspan=\"2\">")
@@ -3481,7 +3506,7 @@ public class I2PSnarkServlet extends BasicServlet {
                 displayComments(snark, er, ec, esc, buf);
             if (includeForm)
                 buf.append("</form>");
-            buf.append("</div></div></body></html>");
+            buf.append("</div></div></center></body></html>");
             return buf.toString();
         }
 
@@ -3778,7 +3803,7 @@ public class I2PSnarkServlet extends BasicServlet {
         // for stop/start/check
         if (includeForm)
             buf.append("</form>");
-        buf.append("</div></div></body></html>");
+        buf.append("</div></div></center></body></html>");
 
         return buf.toString();
     }
@@ -4150,6 +4175,134 @@ public class I2PSnarkServlet extends BasicServlet {
             buf.append("</div>");
     }
 
+    /**
+     * @param snark non-null
+     * @since 0.9.53
+     */
+    private void displayTorrentEdit(Snark snark, String base, StringBuilder buf) {
+        MetaInfo meta = snark.getMetaInfo();
+        if (meta == null)
+            return;
+        buf.append("<div id=\"snarkCommentSection\"><table class=\"snarkTorrentInfo\">\n<tr><th colspan=\"5\">")
+           .append(_t("Edit Torrent"))
+           .append("</th></tr>");
+        boolean isRunning = !snark.isStopped();
+        if (isRunning) {
+            // shouldn't happen
+            buf.append("<tr><td colspan=\"5\">")
+               .append(_t("Torrent must be stopped"))
+               .append("</td></tr></table></div></form>");
+            return;
+        }
+        String announce = meta.getAnnounce();
+        if (announce == null)
+            announce = snark.getTrackerURL();
+        if (announce != null) {
+            // strip non-i2p trackers
+            if (!isI2PTracker(announce))
+                announce = null;
+        }
+        List<List<String>> alist = meta.getAnnounceList();
+        Set<String> annlist = new TreeSet<String>();
+        if (alist != null && !alist.isEmpty()) {
+            // strip non-i2p trackers
+            for (List<String> alist2 : alist) {
+                for (String s : alist2) {
+                    if (isI2PTracker(s))
+                        annlist.add(s);
+                }
+            }
+        }
+        if (announce != null)
+            annlist.add(announce);
+        if (!annlist.isEmpty()) {
+            buf.append("<tr><td colspan=\"3\"></td><td>").append("Primary").append("</td><td>")
+               .append("Delete").append("</td></tr>");
+            for (String s : annlist) {
+                int hc = s.hashCode();
+                buf.append("<tr><td>");
+                toThemeImg(buf, "details");
+                buf.append("</td><td><b>")
+                   .append(_t("Tracker")).append("</b></td><td>");
+                s = DataHelper.stripHTML(s);
+                buf.append("<span class=\"info_tracker\">");
+                buf.append(getShortTrackerLink(s, snark.getInfoHash()));
+                buf.append("</span> ");
+                //buf.append(s);
+                buf.append("</td><td>");
+                buf.append("<input type=\"radio\" class=\"optbox\" name=\"primary\" ");
+                if (s.equals(announce))
+                    buf.append("checked=\"checked\" ");
+                buf.append("value=\"").append(hc);
+                buf.append("\"></td><td>");
+                buf.append("<input type=\"checkbox\" class=\"optbox\" name=\"trdelete.")
+                   .append(hc).append("\" title=\"").append(_t("Mark for deletion")).append("\">");
+                buf.append("</td></tr>\n");
+            }
+        }
+
+        List<Tracker> newTrackers = _manager.getSortedTrackers();
+        for (Iterator<Tracker> iter = newTrackers.iterator(); iter.hasNext(); ) {
+            Tracker t = iter.next();
+            String announceURL = t.announceURL.replace("&#61;", "=");
+            if (announceURL.equals(announce) || annlist.contains(announceURL))
+                iter.remove();
+        }
+        if (!newTrackers.isEmpty()) {
+            buf.append("<tr><td colspan=\"3\"></td><td>").append("Primary").append("</td><td>")
+               .append("Add").append("</td></tr>");
+            for (Tracker t : newTrackers) {
+                String name = t.name;
+                int hc = t.announceURL.hashCode();
+                String announceURL = t.announceURL.replace("&#61;", "=");
+                buf.append("<tr><td>");
+                toThemeImg(buf, "details");
+                buf.append("</td><td><b>")
+                   .append(_t("Add Tracker")).append("</b></td><td>");
+                buf.append(name);
+                buf.append("</td><td><input type=\"radio\" class=\"optbox\" name=\"primary\" value=\"");
+                buf.append(hc);
+                buf.append("\"></td><td>");
+                buf.append("<input type=\"checkbox\" class=\"optbox\" id=\"").append(name).append("\" name=\"tradd.")
+                   .append(hc).append("\" title=\"").append(_t("Add tracker")).append("\"> ")
+                   .append("</td><td></td></tr>\n");
+            }
+        }
+
+        String com = meta.getComment();
+        if (com == null) {
+            com = "";
+        } else if (com.length() > 0) {
+            com = DataHelper.escapeHTML(com);
+        }
+        buf.append("<tr><td>");
+        toThemeImg(buf, "details");
+        buf.append("</td><td><b>")
+           .append(_t("Comment")).append("</b></td>");
+        buf.append("<td colspan=\"2\" id=\"addCommentText\"><textarea name=\"nofilter_newTorrentComment\" cols=\"88\" rows=\"4\">")
+           .append(com).append("</textarea></td><td></td>");
+        buf.append("</tr>\n");
+
+        String cb = meta.getCreatedBy();
+        if (cb == null) {
+            cb = "";
+        } else if (cb.length() > 0) {
+            cb = DataHelper.escapeHTML(cb);
+        }
+        buf.append("<tr><td>");
+        toThemeImg(buf, "details");
+        buf.append("</td><td><b>")
+           .append(_t("Created By")).append("</b></td>");
+        buf.append("<td id=\"editTorrentCreatedBy\"><input type=\"text\" name=\"nofilter_newTorrentCreatedBy\" cols=\"44\" rows=\"1\" value=\"")
+           .append(cb).append("\"></td></tr>");
+
+        buf.append("<tr id=\"torrentInfoControl\"><td colspan=\"5\">");
+        buf.append("<input type=\"submit\" name=\"editTorrent\" value=\"");
+        buf.append(_t("Save Changes"));
+        buf.append("\" class=\"accept\"></td></tr>\n");
+        buf.append("</table></div>");
+    }
+
     /**
      *  @param so null ok
      *  @return query string or ""
@@ -4370,6 +4523,147 @@ public class I2PSnarkServlet extends BasicServlet {
         _manager.setSavedCommentsEnabled(snark, yes);
     }
 
+    /**
+     *  @since 0.9.53
+     */
+    private void saveTorrentEdit(Snark snark, Map<String, String[]> postParams) {
+        if (!snark.isStopped()) {
+            // shouldn't happen
+            _manager.addMessage(_t("Torrent must be stopped"));
+            return;
+        }
+        List<Integer> toAdd = new ArrayList<Integer>();
+        List<Integer> toDel = new ArrayList<Integer>();
+        Integer primary = null;
+        String newComment = "";
+        String newCreatedBy = "";
+        for (Map.Entry<String, String[]> entry : postParams.entrySet()) {
+            String key = entry.getKey();
+            String val = entry.getValue()[0];   // jetty arrays
+            if (key.startsWith("tradd.")) {
+                try {
+                    toAdd.add(Integer.parseInt(key.substring(6)));
+                } catch (NumberFormatException nfe) {}
+            } else if (key.startsWith("trdelete.")) {
+                try {
+                    toDel.add(Integer.parseInt(key.substring(9)));
+                } catch (NumberFormatException nfe) {}
+            } else if (key.equals("primary")) {
+                try {
+                    primary = Integer.parseInt(val);
+                } catch (NumberFormatException nfe) {}
+            } else if (key.equals("nofilter_newTorrentComment")) {
+                newComment = val.trim();
+            } else if (key.equals("nofilter_newTorrentCreatedBy")) {
+                newCreatedBy = val.trim();
+            }
+        }
+        MetaInfo meta = snark.getMetaInfo();
+        if (meta == null) {
+            // shouldn't happen
+            _manager.addMessage("Can't edit magnet");
+            return;
+        }
+        String oldPrimary = meta.getAnnounce();
+        String oldComment = meta.getComment();
+        if (oldComment == null)
+            oldComment = "";
+        String oldCreatedBy = meta.getCreatedBy();
+        if (oldCreatedBy == null)
+            oldCreatedBy = "";
+        if (toAdd.isEmpty() && toDel.isEmpty() &&
+            (primary == null || primary.equals(oldPrimary)) &&
+            oldComment.equals(newComment) &&
+            oldCreatedBy.equals(newCreatedBy)) {
+            _manager.addMessage("No changes to torrent, not saved");
+            return;
+        }
+        List<List<String>> alist = meta.getAnnounceList();
+        Set<String> annlist = new TreeSet<String>();
+        if (alist != null && !alist.isEmpty()) {
+            // strip non-i2p trackers
+            for (List<String> alist2 : alist) {
+                for (String s : alist2) {
+                    if (isI2PTracker(s))
+                        annlist.add(s);
+                }
+            }
+        }
+        if (oldPrimary != null)
+            annlist.add(oldPrimary);
+        List<Tracker> newTrackers = _manager.getSortedTrackers();
+        for (Integer i : toDel) {
+            int hc = i.intValue();
+            for (Iterator<String> iter = annlist.iterator(); iter.hasNext(); ) {
+                String s = iter.next();
+                if (s.hashCode() == hc)
+                    iter.remove();
+            }
+        }
+        for (Integer i : toAdd) {
+            int hc = i.intValue();
+            for (Tracker t : newTrackers) {
+                if (t.announceURL.hashCode() == hc) {
+                    annlist.add(t.announceURL);
+                    break;
+                }
+            }
+        }
+        String thePrimary = oldPrimary;
+        if (primary != null) {
+            int hc = primary.intValue();
+            for (String s : annlist) {
+                if (s.hashCode() == hc) {
+                    thePrimary = s;
+                    break;
+                }
+            }
+        }
+        List<List<String>> newAnnList;
+        if (annlist.isEmpty()) {
+            newAnnList = null;
+            thePrimary = null;
+        } else {
+            List<String> aalist = new ArrayList<String>(annlist);
+            newAnnList = Collections.singletonList(aalist);
+            if (!aalist.contains(thePrimary))
+                thePrimary = aalist.get(0);
+        }
+        if (newComment.equals(""))
+            newComment = null;
+        if (newCreatedBy.equals(""))
+            newCreatedBy = null;
+        MetaInfo newMeta = new MetaInfo(thePrimary, meta.getName(), null, meta.getFiles(), meta.getLengths(),
+                                        meta.getPieceLength(0), meta.getPieceHashes(), meta.getTotalLength(), meta.isPrivate(),
+                                        newAnnList, newCreatedBy, meta.getWebSeedURLs(), newComment);
+        if (!DataHelper.eq(meta.getInfoHash(), newMeta.getInfoHash())) {
+            // shouldn't happen
+            _manager.addMessage("Torrent edit failed, infohash mismatch");
+            return;
+        }
+        File f = new File(_manager.util().getTempDir(), "edit-" + _manager.util().getContext().random().nextLong() + ".torrent");
+        OutputStream out = null;
+        try {
+            out = new SecureFileOutputStream(f);
+            out.write(newMeta.getTorrentData());
+            out.close();
+            boolean ok = FileUtil.rename(f, new File(snark.getName()));
+            if (!ok) {
+                _manager.addMessage("Save edit changes failed");
+                return;
+            }
+        } catch (IOException ioe) {
+            try { if (out != null) out.close(); } catch (IOException ioe2) {}
+            _manager.addMessage("Save edit changes failed: " + ioe);
+            return;
+        } finally {
+            f.delete();
+        }
+        snark.replaceMetaInfo(newMeta);
+        _manager.addMessage("Torrent changes saved");
+    }
+
+
     /** @since 0.9.32 */
     private static boolean noCollapsePanels(HttpServletRequest req) {
         // check for user agents that can't toggle the collapsible panels...
-- 
GitLab