summaryrefslogtreecommitdiffstats
path: root/build/bin/mergetool.py
diff options
context:
space:
mode:
authorHenri Sara <henri.sara@itmill.com>2009-03-25 07:10:17 +0000
committerHenri Sara <henri.sara@itmill.com>2009-03-25 07:10:17 +0000
commitb77e0f7dfbf7d80b806f1dbea4ce7f1d85248d61 (patch)
treec7f1efc877104151f1195f5ded78979d6dd97f84 /build/bin/mergetool.py
parentf65586e992add15e7d89c251ed8bee45aa5cfcfe (diff)
downloadvaadin-framework-b77e0f7dfbf7d80b806f1dbea4ce7f1d85248d61.tar.gz
vaadin-framework-b77e0f7dfbf7d80b806f1dbea4ce7f1d85248d61.zip
Merge from 5.3 to 6.0:
[7015] Updated browser support list in Release Notes. For #2538. [7028] Merge from branches/release/5.3.0 to versions/5.3 (multiple changesets concerning build) [7039] Updated tutorial PDF, also added the header logo element. svn changeset:7166/svn branch:6.0
Diffstat (limited to 'build/bin/mergetool.py')
-rwxr-xr-xbuild/bin/mergetool.py682
1 files changed, 682 insertions, 0 deletions
diff --git a/build/bin/mergetool.py b/build/bin/mergetool.py
new file mode 100755
index 0000000000..bc1b23b090
--- /dev/null
+++ b/build/bin/mergetool.py
@@ -0,0 +1,682 @@
+#!/usr/bin/python
+
+import sys,re,os,string,urllib,httplib
+
+################################################################################
+# Configuration
+################################################################################
+
+# Determine repository root
+pin = os.popen("svn info|grep 'Repository Root'|sed -e 's/^.\+: //'", "r")
+REPOSITORY = pin.read().rstrip()+"/"
+pin.close()
+
+print "Repository: %s" % (REPOSITORY)
+
+################################################################################
+# Parse command-line arguments
+################################################################################
+def help(exitvalue = 0):
+ print "Usage: batchmerge [options] <command>\n"
+ print "Options:"
+ print "\t-m\tOnly merge. (For 'single' command.)"
+ print "\t-c\tOnly commit. (For 'single' command.)"
+ print "\t-html\tHTML output. (For 'log' command.)"
+ print "\t-author\tHTML output. (Include author in HTML log.)"
+ print "\t-milestone <ms>\tList tickets in milestone (For 'log' command.)"
+ print "\nCommands:"
+ print "massmerge <cfg> <src> <trg> [<from>] "
+ print " - Merges changesets listed in the configuration"
+ print " file. The file is in svn log (text) format."
+ print " You can comment out unwanted changesets."
+ print " Merge is stopped on conflict."
+ print " If you give the optional <from> parameter,"
+ print " merge is started from the changeset number. "
+ print "single <chg#> <target> - Merges a single changeset. If -m is given,"
+ print " only merge is done. If -c is given, only commit is done."
+ print "revert - Reverts all changes made to repository except"
+ print " changes in this program and the merge files."
+ print "log <cfg> <src> <trg> - Prints a ChangeLog as it will appear in the"
+ print " commit log. If -html option is given,"
+ print " the log is printed in a HTML format"
+ print " suitable for the Release Notes."
+ print "commit <cfg> <src> <trg> - Commits all changes, except changes to this"
+ print " program and merge files. The commit log"
+ print " comment includes list of all changesets"
+ print " listed in the configuration. The <target>"
+ print " is the branch name, e.g., \"5.2\".\n"
+ print "Common parameters:"
+ print " <cfg> - Configuration file (svn text log format)."
+ print " <src> - Source branch relative to repository URI."
+ print " <trg> - Target branch relative to repository URI."
+ print "You must run the command in the root directory of the branch."
+ print "The program file contains some basic configuration parameters."
+ sys.exit(exitvalue)
+
+################################################################################
+# Globals
+################################################################################
+tickets = {}
+
+################################################################################
+# Utility Functions
+################################################################################
+def command(cmd, dryrun=0):
+ print cmd
+ if not dryrun:
+ if os.system(cmd):
+ print "Command failed, exiting."
+ sys.exit(1)
+ else:
+ print "Dry run - not executing."
+
+def listChangedFiles():
+ # Get Svn status
+ pin = os.popen("svn st", "r")
+ lines = pin.readlines()
+ pin.close()
+
+ changed = []
+ for line in lines:
+ # Remove trailing newline
+ line = line[:-1]
+
+ # Extract the file state and name
+ (filestate, filename) = re.split(r'[ \+]+', line)
+
+ # Ignore files in build directory
+ if (filename.startswith("build/merge/") \
+ or filename.startswith("build/bin/mergetool.py") \
+ or filename.startswith("build/testing")) \
+ and filestate == "M":
+ continue
+
+ # File is changed if it is not local
+ if filestate != "?":
+ changed.append(filename)
+
+ return changed
+
+# Retrieves ticket summary string with HTTP
+# Returns: (summary, milestone)
+def fetchSummary(ticketno):
+ params = urllib.urlencode({'format': 'tab'})
+ conn = httplib.HTTPConnection("dev.itmill.com")
+ conn.request("GET", "/ticket/%d?%s" % (ticketno, params) )
+ response = conn.getresponse()
+ data = response.read()
+ conn.close()
+
+ lines = data.split("\n")
+ data = reduce(lambda x,y: x+"\n"+y, lines[1:])
+ #cols = lines[1].split("\t")
+
+ cols = data.split("\t")
+
+ return (cols[1],cols[8])
+
+# Adds summary to ticket number, unless the context already has it
+# Returns: (summary, milestone)
+def addSummary(m):
+ ticketnum = int(m.group(1))
+ context = m.group(2)
+ if re.match(" *\(", context):
+ # The context already has ticket summary
+ return "#%d%s" % (ticketnum, context)
+
+ (summary,milestone) = fetchSummary(ticketnum)
+
+ # Remove possible " quotation from the summary
+ if summary.startswith('"'):
+ summary = summary.strip('"')
+ summary = summary.replace('""', '"')
+
+ # Add summaries to further ticket numbers recursively
+ context = re.sub(r'#([0-9]+)(.*)', addSummary, context)
+
+ return "#%s (<i>%s</i>) %s" % (ticketnum, summary, context)
+
+################################################################################
+# Change
+################################################################################
+class Ticket:
+ def __init__(self, id, summary=None, milestone=None):
+ self.id = id
+ self.summary = summary
+ self.milestone = milestone
+
+ def fetchData(self):
+ (summary, milestone) = fetchSummary(self.id)
+ self.summary = summary
+ self.milestone = milestone
+
+################################################################################
+# Change
+################################################################################
+class Change:
+ def __init__(self, id, undo=0, author=""):
+ self.id = id
+ self.author = author
+ self.comment = ""
+ self.undo = undo
+ self.tickets = []
+
+ def addCommentLine(self, line):
+ self.comment += line
+
+ def merge(self, trunkURI, dryrun=0):
+ drycmd = ""
+ if dryrun:
+ drycmd = "--dry-run"
+
+ # Handle negative merge
+ mergesign = ""
+ if self.undo:
+ mergesign = "-"
+
+ # Build the merge command
+ cmd = "svn merge --non-interactive %s -c %s%d %s" % (drycmd, mergesign, self.id, trunkURI)
+ print cmd
+
+ # Run the merge command
+ pin = os.popen(cmd, "r")
+ lines = pin.readlines()
+ pin.close()
+
+ # Parse the lines for conflicts
+ conflicts = 0
+ for line in lines:
+ print line[:-1]
+
+ # Check for conflict
+ if line.startswith("C"):
+ conflicts += 1
+
+ # Check for skipped file
+ elif line.startswith("Skipped"):
+ conflicts += 1
+
+ filename = line[8:-1]
+
+ # Simply exit if there was any problem
+ if conflicts > 0:
+ print "Problems detected. Exiting."
+ sys.exit(1)
+
+ def fetchComment(self, trunkURI):
+ cmd = "svn log -r %d %s" % (self.id, trunkURI)
+
+ # Run the log command
+ pin = os.popen(cmd, "r")
+ lines = pin.readlines()
+ pin.close()
+
+ STATE_START = 0
+ STATE_COMMENT = 1
+ comment = None
+ state = STATE_START
+ for line in lines:
+ if state == STATE_START:
+ if line == "\n":
+ state = STATE_COMMENT
+ elif state == STATE_COMMENT:
+ if line.startswith("-----------------"):
+ self.comment = comment
+ return comment
+ elif comment:
+ comment += "\n" + line.rstrip("\n")
+ else:
+ comment = line.rstrip("\n")
+
+ self.comment = comment
+ return comment
+
+ def commit(self):
+ # Write the log comment to a temporary file
+ logtmpname = "/tmp/merge-single-%d.log" % (os.getpid())
+ fout = open(logtmpname, "w")
+ fout.write(self.comment)
+ fout.close()
+
+ # Get listo
+ files = listChangedFiles()
+ if len(files) == 0:
+ print "Error: Will not do empty commit."
+ sys.exit(1)
+
+ # Write the list of files to be committed to a temporary file
+ changedfiles = ("\n".join(files)) + "\n"
+ targettmpname = "/tmp/merge-targets-%d.txt" % (os.getpid())
+ fout = open(targettmpname, "w")
+ fout.write(changedfiles)
+ fout.close()
+ print changedfiles,
+
+ command("svn commit --file %s --targets %s" % (logtmpname, targettmpname))
+
+ command("rm %s %s" % (logtmpname, targettmpname))
+
+
+ def getNumber(self):
+ return self.id
+
+ def getComment(self):
+ return self.comment
+
+ def getUndo(self):
+ return self.undo
+
+ def getAuthor(self):
+ return self.author
+
+ def isForMilestone(self, milestone):
+ return self.author
+
+ def addSummary(self, m, target_milestone=None):
+ ticketnum = int(m.group(1))
+ context = m.group(2)
+ if re.match(" *\(", context):
+ # The context already has ticket summary
+ return "#%d%s" % (ticketnum, context)
+
+ # Check for cached ticket
+ if tickets.has_key(ticketnum):
+ summary = tickets[ticketnum].summary
+ ticket_milestone = tickets[ticketnum].milestone
+ else:
+ # Fetch ticket from server and add to cache
+ (summary,ticket_milestone) = fetchSummary(ticketnum)
+ tickets[ticketnum] = Ticket(ticketnum,summary,ticket_milestone)
+
+ self.tickets.append(ticketnum);
+
+ # Remove possible " quotation from the summary
+ if summary.startswith('"'):
+ summary = summary.strip('"')
+ summary = summary.replace('""', '"')
+
+ ticketnum = "#%s" % (ticketnum)
+
+ # Emphasize tickets matching the target milestone
+ if target_milestone:
+ if ticket_milestone.find(target_milestone) != -1:
+ ticketnum = "<b>%s</b>" % (ticketnum)
+
+ # Add summaries to further ticket numbers recursively
+ context = re.sub(r'#([0-9]+)(.*)', lambda m: self.addSummary(m, target_milestone=target_milestone), context)
+
+ return "%s (<i>%s</i>) %s" % (ticketnum, summary, context)
+
+ def registerTicket(self, m, ticketNumbers):
+ ticketNumbers[int(m.group(1))] = 1
+ return ""
+
+ # Returns a list of ticket numbers referenced by this change
+ def listTickets(self):
+ ticketNumbers = {}
+ re.sub(r'#([0-9]+)', lambda m: self.registerTicket(m,ticketNumbers=ticketNumbers), self.comment)
+ return ticketNumbers.keys()
+
+################################################################################
+# Read configuration file
+################################################################################
+class Configuration:
+ def __init__(self, cfgfilename, startfrom=0):
+ self.changes = []
+ self.readConfig(cfgfilename, startfrom)
+
+ def readConfig(self, cfgfilename, startfrom=0):
+ fin = open(cfgfilename, "r")
+ content = fin.readlines()
+ fin.close()
+
+ # Parse configuration
+ currentChange = None
+ skipChange = 0
+ for line in content:
+ m_changestart = re.match(r'(-?)r([0-9]+)', line)
+ m_endofchange = re.match(r'------+', line)
+ m_emptyline = re.match(r'^$', line)
+ if m_changestart:
+ # Parse negative merge
+ undo = 0
+ if m_changestart.group(1) == "-":
+ undo = 1
+
+ # Get changeset number
+ id = int(m_changestart.group(2))
+
+ # Skip the target if its number is too small
+ if startfrom != 0 and id < startfrom:
+ skipChange = 1
+
+ # Get the author
+ author = re.sub(r'\@.+', '', line.split("|")[1].strip())
+
+ currentChange = Change(id, undo=undo, author=author)
+
+ elif m_endofchange:
+ # Register changeset, unless it is marked
+ # for skipping.
+ if currentChange:
+ if skipChange:
+ skipChange = 0
+ else:
+ self.changes.append(currentChange)
+ currentChange = None
+ elif m_emptyline:
+ pass
+ else:
+ if currentChange:
+ currentChange.addCommentLine(line)
+
+ def massMerge(self, trunkURI, dryrun=0):
+ for change in self.changes:
+ change.merge(trunkURI, dryrun=dryrun)
+
+ def createLogComment(self):
+ # Create a log comment that lists all merged changesets with
+ # comments
+ logcomment = "Merge from %s to %s:\n" % (sourcebranch, targetbranch)
+ for change in self.changes:
+ changeno = change.getNumber()
+ changecomment = change.getComment().rstrip("\n")
+ if change.getUndo():
+ logcomment += "Reverted [%d] merge: %s\n" % (changeno, changecomment)
+ else:
+ logcomment += "Merged [%d]: %s\n" % (changeno, changecomment)
+ return logcomment
+
+ def logHtml(self, author=0, milestone=None):
+ # In author inclusion mode, include some styles to make a printout look better
+ if author:
+ print "<head>\n<style type=\"text/css\">\n"+ \
+ "tr {\n vertical-align: top;\n}\ntd {\n font-size: 8pt;\n}\n</style>\n</head>\n"
+
+ # Print header
+ print "<table id=\"changelog-table\">"
+ authorcolumnheader = ""
+ if author:
+ authorcolumnheader = "<td>Author</td>"
+ print " <tr><td>#</td><td>Changeset Comment</td>%s</tr>" % (authorcolumnheader)
+
+ # Print body: the changes
+ for change in self.changes:
+ changeno = change.getNumber()
+ changecomment = change.getComment().rstrip("\n")
+
+ changeref = "[%d]" % (changeno)
+
+ # Handle merge undo markup
+ if change.getUndo():
+ changeref = "<font class=\"changeset-merge-undone\">%s</font>" % (changeref)
+ changecomment = "<font class=\"changeset-merge-undone\">%s</font>" % (changecomment)
+ changecomment = "Reverted a change: "+changecomment
+
+ authorcolumn = ""
+ if author:
+ authorcolumn = "<td>%s</td>" % (change.getAuthor())
+
+ # Add ticket summary after ticket number, if missing
+ # TODO: this handles only one
+ changecomment = re.sub(r'#([0-9]+)(.*)', lambda m: change.addSummary(m, target_milestone=milestone), changecomment, 100)
+ # item = re.sub(r'#([0-9]+)(.*)', '#\\1 (SUMMARY) \\2', item)
+
+ # Change ticket numbers to references to tickets
+ changecomment = re.sub(r'#([0-9]+)', '#<a href="http://dev.itmill.com/ticket/\\1">\\1</a>', changecomment)
+
+ # Change changeset numbers to references to changesets
+ changecomment = re.sub(r'\[([0-9]+)\]', '[<a href="http://dev.itmill.com/changeset/\\1">\\1</a>]', changecomment)
+ changeref = re.sub(r'\[([0-9]+)\]', '[<a href="http://dev.itmill.com/changeset/\\1">\\1</a>]', changeref)
+
+ # See if any of the tickets have milestone under work.
+ if milestone:
+ for ticketnum in change.tickets:
+ ticket = tickets[ticketnum]
+ if ticket.milestone.find(milestone) != -1:
+ changeref = "<b>%s</b>" % (changeref)
+
+ # Make basic HTML formatting
+ item = " <tr><td>%s:</td><td>%s</td>%s</tr>" % (changeref, changecomment, authorcolumn)
+
+ print item
+ sys.stdout.flush()
+ print "</table>"
+
+ # Prints a commit log to standard output
+ def log(self, sourcebranch, targetbranch, html=0, author=0, milestone=None):
+ if html:
+ self.logHtml(author=author,milestone=milestone)
+ return
+ sys.stdout.write(self.createLogComment())
+
+ def commit(self, sourcebranch, targetbranch, dryrun=0):
+ logcomment = self.createLogComment()
+
+ # Write the log comment to a temporary file
+ logtmpname = "/tmp/massmerge-pid-%d.log" % (os.getpid())
+ fout = open(logtmpname, "w")
+ fout.write(logcomment)
+ fout.close()
+
+ # Write the list of files to be committed to a temporary file
+ changedfiles = "\n".join(listChangedFiles())
+ targettmpname = "/tmp/massmerge-targets-%d.txt" % (os.getpid())
+ fout = open(targettmpname, "w")
+ fout.write(changedfiles)
+ fout.close()
+
+ if dryrun:
+ print "Log:"
+ os.system("cat %s" % (logtmpname))
+ print "\nChanged Files:"
+ os.system("cat %s" % (targettmpname))
+ print "\n"
+
+ command("svn commit --file %s --targets %s" % (logtmpname, targettmpname), dryrun=dryrun)
+
+ command("rm %s %s" % (logtmpname, targettmpname))
+
+ def listTickets(self):
+ fixed = {}
+
+ for change in self.changes:
+ changeno = change.getNumber()
+ changeTickets = change.listTickets()
+ if len(changeTickets)>0 and change.comment.lower().find("fix") != -1:
+ for ticket in changeTickets:
+ fixed[ticket] = 1
+
+ fixedlist = fixed.keys()
+ fixedlist.sort()
+ # print "Fixed:", fixedlist
+
+ print "<ul>"
+
+ for ticketNum in fixedlist:
+ if not tickets.has_key(ticketNum):
+ ticket = Ticket(ticketNum)
+ ticket.fetchData()
+ tickets[ticketNum] = ticket
+
+ ticket = tickets[ticketNum]
+ # print "%d: %s" % (ticket.id, ticket.summary)
+ print " <li><a href=\"http://dev.itmill.com/ticket/%d\">#%d</a>: %s</li>" % (ticket.id, ticket.id, ticket.summary)
+ sys.stdout.flush()
+
+ print "</ul>"
+
+################################################################################
+# Commands
+################################################################################
+
+# Command: revert
+def commandRevert():
+ # Get Svn status
+ pin = os.popen("svn st", "r")
+ lines = pin.readlines()
+ pin.close()
+
+ reverted = []
+ removed = []
+ for line in lines:
+ # Remove trailing newline
+ line = line[:-1]
+
+ # Extract the file state and name
+ (filestate, filename) = re.split(r'[ \+]+', line)
+
+ # Ignore files in build directory
+ if (filename.startswith("build/merge/") \
+ or filename.startswith("build/bin/")) \
+ and filestate == "M":
+ continue
+
+ # Added files are simply deleted
+ if filestate != "?":
+ reverted.append(filename)
+
+ # Added files have to be removed in addition to reverting
+ if filestate == "A":
+ removed.append(filename)
+
+ # Remove conflict choises
+ elif filestate == "?" and \
+ (filename.find(".merge-left.r") != -1 or \
+ filename.find(".merge-right.r") != -1):
+ removed.append(filename)
+
+ # Revert files marked for reverting
+ donework = 0
+ if len(reverted) > 0:
+ files = " ".join(reverted)
+ command("svn revert -R %s" % (files))
+ donework = 1
+
+ # Remove files marked for deletion
+ if len(removed) > 0:
+ files = " ".join(removed)
+ command("rm %s" % (files))
+ donework = 1
+
+ if not donework:
+ print "Nothing to do."
+
+# Command: massmerge
+def commandMassmerge(cfgfilename, sourceuri, startfrom, dryrun=0):
+ cfg = Configuration(cfgfilename, startfrom=startfrom)
+ cfg.massMerge(sourceuri, dryrun=dryrun)
+
+# Command: single
+def commandSingle(trunkuri, changeset, sourcebranch, targetbranch, onlymerge=0, onlycommit=0):
+ change = Change(changeset)
+ print "Found changeset with log comment:\n "+change.fetchComment(trunkuri) + "\n"
+
+ change.merge(trunkuri, dryrun=onlycommit)
+ if onlycommit:
+ print "Got file list."
+ else:
+ print "Merge successful."
+
+ # Change the comment
+ change.comment = "Merged [%d] from %s to %s branch: %s" % (change.id, sourcebranch, branchname, change.comment)
+ print "\nLog comment: \"%s\"" % (change.comment)
+
+ if not onlymerge:
+ print "Committing."
+ change.commit()
+
+# Command: commit
+def commandCommit(cfgfilename, sourcebranch, targetbranch, dryrun=0):
+ cfg = Configuration(cfgfilename)
+ cfg.commit(sourcebranch, targetbranch, dryrun=dryrun)
+
+# Command: log
+def commandLog(cfgfilename, sourcebranch, targetbranch, html=0, author=0, milestone=None):
+ cfg = Configuration(cfgfilename)
+ cfg.log(sourcebranch, targetbranch, html=html, author=author, milestone=milestone)
+
+# Command: tickets
+def commandTickets(cfgfilename):
+ cfg = Configuration(cfgfilename)
+ cfg.listTickets()
+
+################################################################################
+# Main Program
+################################################################################
+
+# Handle switches
+dryrun = 0
+html = 0
+html_author = 0
+html_milestone = None
+onlymerge = 0
+onlycommit = 0
+while len(sys.argv)>1 and sys.argv[1][0] == '-':
+ if sys.argv[1] == "-d":
+ dryrun = 1
+ del sys.argv[1:2]
+
+ elif sys.argv[1] == "-html":
+ html = 1
+ del sys.argv[1:2]
+
+ elif sys.argv[1] == "-author":
+ html_author = 1
+ del sys.argv[1:2]
+
+ elif sys.argv[1] == "-milestone":
+ html_milestone = sys.argv[2]
+ del sys.argv[1:3]
+
+ elif sys.argv[1] == "-m":
+ onlymerge = 1
+ del sys.argv[1:2]
+
+ elif sys.argv[1] == "-c":
+ onlycommit = 1
+ del sys.argv[1:2]
+
+ else:
+ print "Invalid option '%s'." % (sys.argv[1])
+ sys.exit(1)
+
+if len(sys.argv) < 2:
+ help(1)
+
+# Handle commands
+if sys.argv[1] == "revert":
+ commandRevert()
+
+elif (len(sys.argv) == 4 or len(sys.argv)==5) and sys.argv[1] == "massmerge":
+ cfgfilename = sys.argv[2]
+ sourcebranch = sys.argv[3]
+ startfrom = None
+ if len(sys.argv)>4:
+ startfrom = int(sys.argv[4])
+ commandMassmerge(cfgfilename, sourceuri=REPOSITORY+sourcebranch, startfrom=startfrom, dryrun=dryrun)
+
+elif len(sys.argv) == 5 and sys.argv[1] == "single":
+ changeset = int(sys.argv[2])
+ sourcebranch = sys.argv[3]
+ targetbranch = sys.argv[4]
+ commandSingle(REPOSITORY+sourcebranch, changeset, targetbranch, onlymerge=onlymerge, onlycommit=onlycommit)
+
+elif len(sys.argv) == 5 and sys.argv[1] == "commit":
+ cfgfilename = sys.argv[2]
+ sourcebranch = sys.argv[3]
+ targetbranch = sys.argv[4]
+ commandCommit(cfgfilename, sourcebranch, targetbranch, dryrun=dryrun)
+
+elif len(sys.argv) == 5 and sys.argv[1] == "log":
+ cfgfilename = sys.argv[2]
+ sourcebranch = sys.argv[3]
+ targetbranch = sys.argv[4]
+ commandLog(cfgfilename, sourcebranch, targetbranch, html=html, author=html_author, milestone=html_milestone)
+
+elif len(sys.argv) == 3 and sys.argv[1] == "tickets":
+ cfgfilename = sys.argv[2]
+ commandTickets(cfgfilename)
+
+else:
+ help(1)
32/stable30 Nextcloud server, a safe home for all your data: https://github.com/nextcloud/serverwww-data
aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Streamer.php
blob: 52f824fedf8e4b33ebb52a57e110d40ad4529853 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
<?php
/**
 * @copyright Copyright (c) 2016, ownCloud, Inc.
 *
 * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
 * @author Christoph Wurst <christoph@winzerhof-wurst.at>
 * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
 * @author Joas Schilling <coding@schilljs.com>
 * @author Roeland Jago Douma <roeland@famdouma.nl>
 * @author szaimen <szaimen@e.mail.de>
 * @author Thomas Müller <thomas.mueller@tmit.eu>
 * @author Victor Dubiniuk <dubiniuk@owncloud.com>
 *
 * @license AGPL-3.0
 *
 * This code is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License, version 3,
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License, version 3,
 * along with this program. If not, see <http://www.gnu.org/licenses/>
 *
 */
namespace OC;

use OC\Files\Filesystem;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use ownCloud\TarStreamer\TarStreamer;
use ZipStreamer\ZipStreamer;

class Streamer {
	// array of regexp. Matching user agents will get tar instead of zip
	private array $preferTarFor = [ '/macintosh|mac os x/i' ];

	// streamer instance
	private $streamerInstance;

	/**
	 * Streamer constructor.
	 *
	 * @param IRequest $request
	 * @param int|float $size The size of the files in bytes
	 * @param int $numberOfFiles The number of files (and directories) that will
	 *        be included in the streamed file
	 */
	public function __construct(IRequest $request, int|float $size, int $numberOfFiles) {
		/**
		 * zip32 constraints for a basic (without compression, volumes nor
		 * encryption) zip file according to the Zip specification:
		 * - No file size is larger than 4 bytes (file size < 4294967296); see
		 *   4.4.9 uncompressed size
		 * - The size of all files plus their local headers is not larger than
		 *   4 bytes; see 4.4.16 relative offset of local header and 4.4.24
		 *   offset of start of central directory with respect to the starting
		 *   disk number
		 * - The total number of entries (files and directories) in the zip file
		 *   is not larger than 2 bytes (number of entries < 65536); see 4.4.22
		 *   total number of entries in the central dir
		 * - The size of the central directory is not larger than 4 bytes; see
		 *   4.4.23 size of the central directory
		 *
		 * Due to all that, zip32 is used if the size is below 4GB and there are
		 * less than 65536 files; the margin between 4*1000^3 and 4*1024^3
		 * should give enough room for the extra zip metadata. Technically, it
		 * would still be possible to create an invalid zip32 file (for example,
		 * a zip file from files smaller than 4GB with a central directory
		 * larger than 4GiB), but it should not happen in the real world.
		 *
		 * We also have to check for a size above 0. As negative sizes could be
		 * from not fully scanned external storage. And then things fall apart
		 * if somebody tries to package to much.
		 */
		if ($size > 0 && $size < 4 * 1000 * 1000 * 1000 && $numberOfFiles < 65536) {
			$this->streamerInstance = new ZipStreamer(['zip64' => false]);
		} elseif ($request->isUserAgent($this->preferTarFor)) {
			$this->streamerInstance = new TarStreamer();
		} else {
			$this->streamerInstance = new ZipStreamer(['zip64' => PHP_INT_SIZE !== 4]);
		}
	}

	/**
	 * Send HTTP headers
	 * @param string $name
	 */
	public function sendHeaders($name) {
		header('X-Accel-Buffering: no');
		$extension = $this->streamerInstance instanceof ZipStreamer ? '.zip' : '.tar';
		$fullName = $name . $extension;
		$this->streamerInstance->sendHeaders($fullName);
	}

	/**
	 * Stream directory recursively
	 *
	 * @throws NotFoundException
	 * @throws NotPermittedException
	 * @throws InvalidPathException
	 */
	public function addDirRecursive(string $dir, string $internalDir = ''): void {
		$dirname = basename($dir);
		$rootDir = $internalDir . $dirname;
		if (!empty($rootDir)) {
			$this->streamerInstance->addEmptyDir($rootDir);
		}
		$internalDir .= $dirname . '/';
		// prevent absolute dirs
		$internalDir = ltrim($internalDir, '/');

		$userFolder = \OC::$server->getRootFolder()->get(Filesystem::getRoot());
		/** @var Folder $dirNode */
		$dirNode = $userFolder->get($dir);
		$files = $dirNode->getDirectoryListing();

		foreach ($files as $file) {
			if ($file instanceof File) {
				try {
					$fh = $file->fopen('r');
				} catch (NotPermittedException $e) {
					continue;
				}
				$this->addFileFromStream(
					$fh,
					$internalDir . $file->getName(),
					$file->getSize(),
					$file->getMTime()
				);
				fclose($fh);
			} elseif ($file instanceof Folder) {
				if ($file->isReadable()) {
					$this->addDirRecursive($dir . '/' . $file->getName(), $internalDir);
				}
			}
		}
	}

	/**
	 * Add a file to the archive at the specified location and file name.
	 *
	 * @param resource $stream Stream to read data from
	 * @param string $internalName Filepath and name to be used in the archive.
	 * @param int|float $size Filesize
	 * @param int|false $time File mtime as int, or false
	 * @return bool $success
	 */
	public function addFileFromStream($stream, string $internalName, int|float $size, $time): bool {
		$options = [];
		if ($time) {
			$options = [
				'timestamp' => $time
			];
		}

		if ($this->streamerInstance instanceof ZipStreamer) {
			return $this->streamerInstance->addFileFromStream($stream, $internalName, $options);
		} else {
			return $this->streamerInstance->addFileFromStream($stream, $internalName, $size, $options);
		}
	}

	/**
	 * Add an empty directory entry to the archive.
	 *
	 * @param string $dirName Directory Path and name to be added to the archive.
	 * @return bool $success
	 */
	public function addEmptyDir($dirName) {
		return $this->streamerInstance->addEmptyDir($dirName);
	}

	/**
	 * Close the archive.
	 * A closed archive can no longer have new files added to it. After
	 * closing, the file is completely written to the output stream.
	 * @return bool $success
	 */
	public function finalize() {
		return $this->streamerInstance->finalize();
	}
}