webapp: serve static files from /static
[~helmut/debian-dedup.git] / webapp.py
1 #!/usr/bin/python
2
3 import datetime
4 import optparse
5 import sqlite3
6 from wsgiref.simple_server import make_server
7
8 import jinja2
9 from werkzeug.exceptions import HTTPException, NotFound
10 from werkzeug.routing import Map, Rule, RequestRedirect
11 from werkzeug.wrappers import Request, Response
12 from werkzeug.wsgi import SharedDataMiddleware
13
14 from dedup.utils import fetchiter
15
16 jinjaenv = jinja2.Environment(loader=jinja2.PackageLoader("dedup", "templates"))
17
18 def format_size(size):
19     size = float(size)
20     fmt = "%d B"
21     if size >= 1024:
22         size /= 1024
23         fmt = "%.1f KB"
24     if size >= 1024:
25         size /= 1024
26         fmt = "%.1f MB"
27     if size >= 1024:
28         size /= 1024
29         fmt = "%.1f GB"
30     return fmt % size
31
32 def function_combination(function1, function2):
33     if function1 == function2:
34         return function1
35     return "%s -> %s" % (function1, function2)
36
37 # Workaround for jinja bug #59 (broken filesizeformat)
38 jinjaenv.filters["filesizeformat"] = format_size
39
40 base_template = jinjaenv.get_template("base.html")
41 package_template = jinjaenv.get_template("binary.html")
42 detail_template = jinjaenv.get_template("compare.html")
43 hash_template = jinjaenv.get_template("hash.html")
44 index_template = jinjaenv.get_template("index.html")
45 source_template = jinjaenv.get_template("source.html")
46
47 def encode_and_buffer(iterator):
48     buff = b""
49     for elem in iterator:
50         buff += elem.encode("utf8")
51         if len(buff) >= 2048:
52             yield buff
53             buff = b""
54     if buff:
55         yield buff
56
57 def html_response(unicode_iterator, max_age=24 * 60 * 60):
58     resp = Response(encode_and_buffer(unicode_iterator), mimetype="text/html")
59     resp.cache_control.max_age = max_age
60     resp.expires = datetime.datetime.now() + datetime.timedelta(seconds=max_age)
61     return resp
62
63 class Application(object):
64     def __init__(self, db):
65         self.db = db
66         self.routingmap = Map([
67             Rule("/", methods=("GET",), endpoint="index"),
68             Rule("/binary/<package>", methods=("GET",), endpoint="package"),
69             Rule("/compare/<package1>/<package2>", methods=("GET",), endpoint="detail"),
70             Rule("/hash/<function>/<hashvalue>", methods=("GET",), endpoint="hash"),
71             Rule("/source/<package>", methods=("GET",), endpoint="source"),
72         ])
73
74     @Request.application
75     def __call__(self, request):
76         mapadapter = self.routingmap.bind_to_environ(request.environ)
77         try:
78             endpoint, args = mapadapter.match()
79             if endpoint == "package":
80                 return self.show_package(args["package"])
81             elif endpoint == "detail":
82                 return self.show_detail(args["package1"], args["package2"])
83             elif endpoint == "hash":
84                 if args["function"] == "image_sha512":
85                     # backwards compatibility
86                     raise RequestRedirect("%s/hash/png_sha512/%s" %
87                                           (request.environ["SCRIPT_NAME"],
88                                            args["hashvalue"]))
89                 return self.show_hash(args["function"], args["hashvalue"])
90             elif endpoint == "index":
91                 if not request.environ["PATH_INFO"]:
92                     raise RequestRedirect(request.environ["SCRIPT_NAME"] + "/")
93                 return html_response(index_template.render(dict(urlroot="")))
94             elif endpoint == "source":
95                 return self.show_source(args["package"])
96             raise NotFound()
97         except HTTPException as e:
98             return e
99
100     def get_details(self, package):
101         cur = self.db.cursor()
102         cur.execute("SELECT id, version, architecture FROM package WHERE name = ?;",
103                     (package,))
104         row = cur.fetchone()
105         if not row:
106             raise NotFound()
107         pid, version, architecture = row
108         details = dict(pid=pid,
109                        package=package,
110                        version=version,
111                        architecture=architecture)
112         cur.execute("SELECT count(filename), sum(size) FROM content WHERE pid = ?;",
113                     (pid,))
114         num_files, total_size = cur.fetchone()
115         if total_size is None:
116             total_size = 0
117         details.update(dict(num_files=num_files, total_size=total_size))
118         return details
119
120     def get_dependencies(self, pid):
121         cur = self.db.cursor()
122         cur.execute("SELECT required FROM dependency WHERE pid = ?;",
123                     (pid,))
124         return set(row[0] for row in fetchiter(cur))
125
126     def cached_sharedstats(self, pid):
127         cur = self.db.cursor()
128         sharedstats = {}
129         cur.execute("SELECT pid2, package.name, f1.name, f2.name, files, size FROM sharing JOIN package ON sharing.pid2 = package.id JOIN function AS f1 ON sharing.fid1 = f1.id JOIN function AS f2 ON sharing.fid2 = f2.id WHERE pid1 = ? AND f1.eqclass = f2.eqclass;",
130                     (pid,))
131         for pid2, package2, func1, func2, files, size in fetchiter(cur):
132             curstats = sharedstats.setdefault(
133                     function_combination(func1, func2), list())
134             if pid2 == pid:
135                 package2 = None
136             curstats.append(dict(package=package2, duplicate=files, savable=size))
137         return sharedstats
138
139     def show_package(self, package):
140         params = self.get_details(package)
141         params["dependencies"] = self.get_dependencies(params["pid"])
142         params["shared"] = self.cached_sharedstats(params["pid"])
143         params["urlroot"] = ".."
144         cur = self.db.cursor()
145         cur.execute("SELECT content.filename, issue.issue FROM content JOIN issue ON content.id = issue.cid WHERE content.pid = ?;",
146                     (params["pid"],))
147         params["issues"] = dict(cur.fetchall())
148         cur.close()
149         return html_response(package_template.render(params))
150
151     def compute_comparison(self, pid1, pid2):
152         """Compute a sequence of comparison objects ordery by the size of the
153         object in the first package. Each element of the sequence is a dict
154         defining the following keys:
155          * filenames: A set of filenames in package 1 (pid1) all referring to
156            the same object.
157          * size: Size of the object in bytes.
158          * matches: A mapping from filenames in package 2 (pid2) to a mapping
159            from hash function pairs to hash values.
160         """
161         cur = self.db.cursor()
162         cur.execute("SELECT content.id, content.filename, content.size, hash.hash FROM content JOIN hash ON content.id = hash.cid JOIN duplicate ON content.id = duplicate.cid JOIN function ON hash.fid = function.id WHERE pid = ? AND function.name = 'sha512' ORDER BY size DESC;",
163                     (pid1,))
164         cursize = -1
165         files = dict()
166         minmatch = 2 if pid1 == pid2 else 1
167         for cid, filename, size, hashvalue in fetchiter(cur):
168             if cursize != size:
169                 for entry in files.values():
170                     if len(entry["matches"]) >= minmatch:
171                         yield entry
172                 files.clear()
173                 cursize = size
174
175             if hashvalue in files:
176                 files[hashvalue]["filenames"].add(filename)
177                 continue
178
179             entry = dict(filenames=set((filename,)), size=size, matches={})
180             files[hashvalue] = entry
181
182             cur2 = self.db.cursor()
183             cur2.execute("SELECT fa.name, ha.hash, fb.name, filename FROM hash AS ha JOIN hash AS hb ON ha.hash = hb.hash JOIN content ON hb.cid = content.id JOIN function AS fa ON ha.fid = fa.id JOIN function AS fb ON hb.fid = fb.id WHERE ha.cid = ? AND pid = ?;",
184                          (cid, pid2))
185             for func1, hashvalue, func2, filename in fetchiter(cur2):
186                 entry["matches"].setdefault(filename, {})[func1, func2] = \
187                         hashvalue
188             cur2.close()
189         cur.close()
190
191         for entry in files.values():
192             if len(entry["matches"]) >= minmatch:
193                 yield entry
194
195     def show_detail(self, package1, package2):
196         details1 = details2 = self.get_details(package1)
197         if package1 != package2:
198             details2 = self.get_details(package2)
199
200         shared = self.compute_comparison(details1["pid"], details2["pid"])
201         params = dict(
202             details1=details1,
203             details2=details2,
204             urlroot="../..",
205             shared=shared)
206         return html_response(detail_template.stream(params))
207
208     def show_hash(self, function, hashvalue):
209         cur = self.db.cursor()
210         cur.execute("SELECT package.name, content.filename, content.size, f2.name FROM hash JOIN content ON hash.cid = content.id JOIN package ON content.pid = package.id JOIN function AS f2 ON hash.fid = f2.id JOIN function AS f1 ON f2.eqclass = f1.eqclass WHERE f1.name = ? AND hash = ?;",
211                     (function, hashvalue,))
212         entries = [dict(package=package, filename=filename, size=size,
213                         function=otherfunc)
214                    for package, filename, size, otherfunc in fetchiter(cur)]
215         if not entries:
216             raise NotFound()
217         params = dict(function=function, hashvalue=hashvalue, entries=entries,
218                       urlroot="../..")
219         return html_response(hash_template.render(params))
220
221     def show_source(self, package):
222         cur = self.db.cursor()
223         cur.execute("SELECT name FROM package WHERE source = ?;",
224                     (package,))
225         binpkgs = dict.fromkeys(pkg for pkg, in fetchiter(cur))
226         if not binpkgs:
227             raise NotFound
228         cur.execute("SELECT p1.name, p2.name, f1.name, f2.name, sharing.files, sharing.size FROM sharing JOIN package AS p1 ON sharing.pid1 = p1.id JOIN package AS p2 ON sharing.pid2 = p2.id JOIN function AS f1 ON sharing.fid1 = f1.id JOIN function AS f2 ON sharing.fid2 = f2.id WHERE p1.source = ?;",
229                     (package,))
230         for binary, otherbin, func1, func2, files, size in fetchiter(cur):
231             entry = dict(package=otherbin,
232                          funccomb=function_combination(func1, func2),
233                          duplicate=files, savable=size)
234             oldentry = binpkgs.get(binary)
235             if not (oldentry and oldentry["savable"] >= size):
236                 binpkgs[binary] = entry
237         params = dict(source=package, packages=binpkgs, urlroot="..")
238         return html_response(source_template.render(params))
239
240 def main():
241     parser = optparse.OptionParser()
242     parser.add_option("-d", "--database", action="store",
243                       default="test.sqlite3",
244                       help="path to the sqlite3 database file")
245     options, args = parser.parse_args()
246     app = Application(sqlite3.connect(options.database))
247     app = SharedDataMiddleware(app, {"/static": ("dedup", "static")})
248     make_server("0.0.0.0", 8800, app).serve_forever()
249
250 if __name__ == "__main__":
251     main()