/icon.png
95 | override_dir = os.path.join(root_dir, 'overrides')
96 |
97 | ## The directory which contains custom files for each repository.
98 | ## Usage of files could be variable.
99 | customs_dir = os.path.join(root_dir, 'customs')
100 |
101 | ## Full path to the SSH identity file, or None to let SSH decide.
102 | ssh_identity_file = None
103 |
104 | ## True if SSH verbose mode should be used.
105 | ssh_verbose = False
106 |
107 | ## The time that a login token should be valid for. Specify "None" to
108 | ## prevent login tokens from expiring.
109 | login_token_duration = timedelta(hours=6)
110 |
111 | ## Defines, how .git folder should be handled during build process, it uses values from GitFolderHandling enum:
112 | ## * DELETE_BEFORE_BUILD - Native behaviour, that deletes .git folder before .flux-build runs.
113 | ## * DELETE_AFTER_BUILD - Deletes .git folder after .flux-build successfully runs, before artifact is zipped.
114 | ## * DISABLE_DELETE - .git folder is never deleted, it will be part of artifact ZIP.
115 | git_folder_handling = GitFolderHandling.DELETE_BEFORE_BUILD
116 |
--------------------------------------------------------------------------------
/flux/templates/integration.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% set page_title = "Integration" %}
3 | {% block body %}
4 | {% if user.can_manage %}
5 | Public Key
6 | {% if public_key %}
7 |
8 | This is the public key of the Flux CI server that needs to be added
9 | to the Git server from which repositories are cloned.
10 |
11 | {{ public_key }}
12 | {% else %}
13 |
14 |
15 |
16 |
17 |
The server has no SSH public key!
18 |
19 | {% endif %}
20 |
21 | Webhook
22 |
23 | This is the Webhook URL. You should use the appropriate name of
24 | the Git server for the ?api= url parameter. A list of
25 | Git servers for which webhooks are supported can be found below.
26 |
27 | {{ flux.utils.strip_url_path(config.app_url) }}{{ url_for('hook_push') }}
28 |
29 |
30 |
31 |
32 | Service
33 | Webhook URL Parameter
34 |
35 |
36 |
37 |
38 |
39 | BitBucket (Self-hosted)
40 | ?api=bitbucket
41 |
42 |
43 |
44 | BitBucket (Cloud)
45 | ?api=bitbucket-cloud
46 |
47 |
48 |
49 | GitBucket
50 | ?api=gitbucket
51 |
52 |
53 |
54 | Gitea
55 | ?api=gitea
56 |
57 |
58 |
59 | GitHub
60 | ?api=github
61 |
62 |
63 |
64 | GitLab
65 | ?api=gitlab
66 |
67 |
68 |
69 | Gogs
70 | ?api=gogs
71 |
72 |
73 |
74 | Bare repository
75 | ?api=bare
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | The
bare API is a simplified JSON payload for the purpose of using Flux CI
85 | with
bare repositories .
86 |
87 | Example of custom webhook request:
88 |
89 |
90 | {
91 | "owner": "owner",
92 | "name": "name",
93 | "ref": "refs/tags/1.0",
94 | "commit": "0000000000000000000000000000000000000000",
95 | "secret": "custom-project-secret-in-plain-text"
96 | }
97 |
98 |
99 | Example of custom update hook:
100 |
101 |
102 | #!/bin/sh
103 | refname="$1"
104 | newrev="$3"
105 |
106 | if [ "$newrev" = "0000000000000000000000000000000000000000" ]; then
107 | exit 0
108 | fi
109 |
110 | curl --header "Content-Type: application/json" --request POST --data "{\"owner\": \"owner\", \"name\": \"name\", \"ref\": \"$refname\", \"commit\": \"$newrev\", \"secret\": \"custom-project-secret-in-plain-text\"}" http://localhost/flux/hook/push?api=bare > /dev/null
111 |
112 | exit 0
113 |
114 |
115 |
116 | {% endif %}
117 |
118 | {% endblock body %}
119 |
--------------------------------------------------------------------------------
/flux/templates/edit_repo.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% from "macros.html" import render_error_list %}
3 | {% set page_title = "Edit Repository" if repo else "Add Repository" %}
4 | {% block toolbar %}
5 |
6 |
7 | {{ repo.name if repo else "Repositories" }}
8 |
9 |
10 | {% endblock toolbar %}
11 |
12 | {% block body %}
13 | {{ render_error_list(errors) }}
14 |
15 | You must ensure that the Flux CI server has read permission for
16 | the clone URL that you specify below. The URL is stored unencrypted
17 | in the database, thus you should avoid using the
18 | https://username:password@host/name format.
19 |
20 |
85 |
110 | {% endblock body %}
111 |
--------------------------------------------------------------------------------
/flux/templates/view_build.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% from "macros.html" import build_icon, build_ref, fmtdate %}
3 | {% set page_title = build.repo.name + " #" + build.num|string %}
4 | {% block head %}
5 | {% if build.status == build.Status_Building %}
6 |
7 | {% endif %}
8 | {% endblock head %}
9 |
10 | {% block toolbar %}
11 |
12 |
13 | {{ build.repo.name }}
14 |
15 |
16 | {% if user.can_manage or build.check_download_permission(build.Data_Log, user) or build.check_download_permission(build.Data_Artifact, user) %}
17 |
18 |
19 | Options
20 |
21 |
48 |
49 |
50 | {% if build.check_download_permission(build.Data_Log, user) %}
51 |
52 | Download Log
53 |
54 | {% endif %}
55 | {% if build.check_download_permission(build.Data_Artifact, user) %}
56 |
57 | Download Artifacts
58 |
59 | {% endif %}
60 | {% if user.can_manage %}
61 | {% if build.status == build.Status_Building %}
62 |
63 | Stop Build
64 |
65 | {% else %}
66 | {% if build.status != build.Status_Queued %}
67 |
68 | Restart
69 |
70 | {% endif %}
71 |
72 | Delete Build
73 |
74 | {% endif %}
75 | {% endif %}
76 |
77 | {% endif %}
78 | {% endblock toolbar %}
79 |
80 | {% block body %}
81 |
82 |
83 |
84 | {{ build_icon(build) }}
85 |
86 |
87 | #{{ build.num }}
88 |
89 |
90 |
91 | {{ build_ref(build) }}
92 |
93 |
94 | {{ build.commit_sha[0:8]}}
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | {{ fmtdate(build.date_queued) }}
103 |
104 |
105 |
106 |
107 |
108 |
109 | {{ fmtdate(build.date_started) }}
110 |
111 |
112 | {{ fmtdate(build.date_finished) }}
113 |
114 |
115 |
116 | {{ flux.utils.get_date_diff(build.date_finished, build.date_started) }}
117 |
118 |
119 |
120 |
121 | {% if build.status != build.Status_Queued and build.check_download_permission(build.Data_Log, user) %}
122 | Build Log
123 | {% if not build.exists(build.Data_Log) %}
124 |
125 |
126 |
127 |
128 |
Build log missing.
129 |
130 | {% else %}
131 | {{ build.log_contents() }}
132 | {% endif %}
133 | {% endif %}
134 | {% endblock %}
135 |
--------------------------------------------------------------------------------
/flux/flux-fontello.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "css_prefix_text": "fa-",
4 | "css_use_suffix": false,
5 | "hinting": true,
6 | "units_per_em": 1000,
7 | "ascent": 850,
8 | "glyphs": [
9 | {
10 | "uid": "e99461abfef3923546da8d745372c995",
11 | "css": "cog",
12 | "code": 59393,
13 | "src": "fontawesome"
14 | },
15 | {
16 | "uid": "43ab845088317bd348dee1d975700c48",
17 | "css": "check-circle",
18 | "code": 59392,
19 | "src": "fontawesome"
20 | },
21 | {
22 | "uid": "598a5f2bcf3521d1615de8e1881ccd17",
23 | "css": "clock-o",
24 | "code": 59395,
25 | "src": "fontawesome"
26 | },
27 | {
28 | "uid": "ead4c82d04d7758db0f076584893a8c1",
29 | "css": "calendar-o",
30 | "code": 61747,
31 | "src": "fontawesome"
32 | },
33 | {
34 | "uid": "9de4ac1aec6b1cca1929e1407eecf3db",
35 | "css": "calendar-check-o",
36 | "code": 62068,
37 | "src": "fontawesome"
38 | },
39 | {
40 | "uid": "3363990fa5a224d75ed1740e1ec50bb6",
41 | "css": "stop-circle",
42 | "code": 62093,
43 | "src": "fontawesome"
44 | },
45 | {
46 | "uid": "a73c5deb486c8d66249811642e5d719a",
47 | "css": "refresh",
48 | "code": 59396,
49 | "src": "fontawesome"
50 | },
51 | {
52 | "uid": "0d20938846444af8deb1920dc85a29fb",
53 | "css": "sign-out",
54 | "code": 59398,
55 | "src": "fontawesome"
56 | },
57 | {
58 | "uid": "d870630ff8f81e6de3958ecaeac532f2",
59 | "css": "chevron-left",
60 | "code": 59399,
61 | "src": "fontawesome"
62 | },
63 | {
64 | "uid": "9a76bc135eac17d2c8b8ad4a5774fc87",
65 | "css": "download",
66 | "code": 59400,
67 | "src": "fontawesome"
68 | },
69 | {
70 | "uid": "44e04715aecbca7f266a17d5a7863c68",
71 | "css": "plus",
72 | "code": 59401,
73 | "src": "fontawesome"
74 | },
75 | {
76 | "uid": "d35a1d35efeb784d1dc9ac18b9b6c2b6",
77 | "css": "pencil",
78 | "code": 59402,
79 | "src": "fontawesome"
80 | },
81 | {
82 | "uid": "bbfb51903f40597f0b70fd75bc7b5cac",
83 | "css": "trash",
84 | "code": 61944,
85 | "src": "fontawesome"
86 | },
87 | {
88 | "uid": "3db5347bd219f3bce6025780f5d9ef45",
89 | "css": "tag",
90 | "code": 59403,
91 | "src": "fontawesome"
92 | },
93 | {
94 | "uid": "bc4b94dd7a9a1dd2e02f9e4648062596",
95 | "css": "code-fork",
96 | "code": 61734,
97 | "src": "fontawesome"
98 | },
99 | {
100 | "uid": "0f4cae16f34ae243a6144c18a003f2d8",
101 | "css": "times-circle",
102 | "code": 59404,
103 | "src": "fontawesome"
104 | },
105 | {
106 | "uid": "e82cedfa1d5f15b00c5a81c9bd731ea2",
107 | "css": "info-circle",
108 | "code": 59397,
109 | "src": "fontawesome"
110 | },
111 | {
112 | "uid": "c76b7947c957c9b78b11741173c8349b",
113 | "css": "exclamation-triangle",
114 | "code": 59394,
115 | "src": "fontawesome"
116 | },
117 | {
118 | "uid": "559647a6f430b3aeadbecd67194451dd",
119 | "css": "bars",
120 | "code": 61641,
121 | "src": "fontawesome"
122 | },
123 | {
124 | "uid": "12f4ece88e46abd864e40b35e05b11cd",
125 | "css": "check",
126 | "code": 59405,
127 | "src": "fontawesome"
128 | },
129 | {
130 | "uid": "5211af474d3a9848f67f945e2ccaf143",
131 | "css": "times",
132 | "code": 59406,
133 | "src": "fontawesome"
134 | },
135 | {
136 | "uid": "17ebadd1e3f274ff0205601eef7b9cc4",
137 | "css": "question-circle",
138 | "code": 59407,
139 | "src": "fontawesome"
140 | },
141 | {
142 | "uid": "57a0ac800df728aad61a7cf9e12f5fef",
143 | "css": "flag",
144 | "code": 59408,
145 | "src": "fontawesome"
146 | },
147 | {
148 | "uid": "4aad6bb50b02c18508aae9cbe14e784e",
149 | "css": "share-alt",
150 | "code": 61920,
151 | "src": "fontawesome"
152 | },
153 | {
154 | "uid": "8b80d36d4ef43889db10bc1f0dc9a862",
155 | "css": "user",
156 | "code": 59409,
157 | "src": "fontawesome"
158 | },
159 | {
160 | "uid": "5434b2bf3a3a6c4c260a11a386e3f5d1",
161 | "css": "stop-circle-o",
162 | "code": 62094,
163 | "src": "fontawesome"
164 | },
165 | {
166 | "uid": "399ef63b1e23ab1b761dfbb5591fa4da",
167 | "css": "chevron-right",
168 | "code": 59410,
169 | "src": "fontawesome"
170 | },
171 | {
172 | "uid": "9bd60140934a1eb9236fd7a8ab1ff6ba",
173 | "css": "wait-spin",
174 | "code": 59444,
175 | "src": "fontelico"
176 | },
177 | {
178 | "uid": "b091a8bd0fdade174951f17d936f51e4",
179 | "css": "folder-o",
180 | "code": 61716,
181 | "src": "fontawesome"
182 | },
183 | {
184 | "uid": "1b5a5d7b7e3c71437f5a26befdd045ed",
185 | "css": "file-o",
186 | "code": 59411,
187 | "src": "fontawesome"
188 | },
189 | {
190 | "uid": "eeec3208c90b7b48e804919d0d2d4a41",
191 | "css": "upload",
192 | "code": 59412,
193 | "src": "fontawesome"
194 | },
195 | {
196 | "uid": "6846d155ad5bda456569df81f3057faa",
197 | "css": "clone",
198 | "code": 62029,
199 | "src": "fontawesome"
200 | },
201 | {
202 | "uid": "8beac4a5fd5bed9f82ca7a96cc8ba218",
203 | "css": "key",
204 | "code": 59413,
205 | "src": "entypo"
206 | }
207 | ]
208 | }
--------------------------------------------------------------------------------
/flux/file_utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf8 -*-
2 |
3 | import io
4 | import os
5 | import shutil
6 |
7 |
8 | def split_url_path(path):
9 | """
10 | Separates URL path to repository name and path.
11 |
12 | # Parameters
13 | path (str): The path from URL.
14 |
15 | # Return
16 | tuple (str, str): The repository name and the path to be listed.
17 | """
18 |
19 | separator = '/'
20 | parts = path.split(separator)
21 | return separator.join(parts[0:2]), separator.join(parts[2:])
22 |
23 |
24 | def list_folder(cwd):
25 | """
26 | List folder on *cwd* path as list of *File*.
27 |
28 | # Parameters
29 | cwd (str): The absolute path to be listed.
30 |
31 | # Return
32 | list (File): The list of files and folders listed in path.
33 | """
34 |
35 | data = sorted(os.listdir(cwd))
36 | dirs = []
37 | files = []
38 | for filename in data:
39 | file = File(filename, cwd)
40 | if file.type == File.TYPE_FOLDER:
41 | dirs.append(file)
42 | else:
43 | files.append(file)
44 | result = dirs + files
45 | return result
46 |
47 |
48 | def create_folder(cwd, folder_name):
49 | """
50 | Creates folder named *folder_name* on defined *cwd* path.
51 | If does not exist, it creates it and return new path of folder.
52 | If already exists, it returns empty str.
53 |
54 | # Parameters
55 | cwd (str): The absolute path, where folder should be created.
56 | folder_name (str): The name of folder to be created.
57 |
58 | # Return
59 | str: Path of newly created folder, if it does not already exist.
60 | """
61 |
62 | path = os.path.join(cwd, folder_name)
63 | if not os.path.exists(path):
64 | os.makedirs(path)
65 | return path
66 | return ''
67 |
68 |
69 | def create_file(cwd, file_name):
70 | """
71 | Creates file named *file_name* on defined *cwd* path.
72 | If does not exist, it creates it and return new path of file.
73 | If already exists, it returns empty str.
74 |
75 | # Parameters
76 | cwd (str): The absolute path, where file should be created.
77 | file_name (str): The name of file to be created.
78 |
79 | # Return
80 | str: Path of newly created file, if it does not already exist.
81 | """
82 |
83 | path = os.path.join(cwd, file_name)
84 | if not os.path.exists(path):
85 | open(path, 'w').close()
86 | return path
87 | return ''
88 |
89 |
90 | def create_file_path(file_path):
91 | """
92 | Creates file defined by *file_path*.
93 | If does not exist, it creates it and return new path of file.
94 | If already exists, it returns empty str.
95 |
96 | # Parameters
97 | cwd (str): The absolute path, where file should be created.
98 | file_name (str): The name of file to be created.
99 |
100 | # Return
101 | str: Path of newly created file, if it does not already exist.
102 | """
103 |
104 | if not os.path.exists(file_path):
105 | open(file_path, 'w').close()
106 | return file_path
107 | return ''
108 |
109 |
110 | def read_file(path):
111 | """
112 | Reads file located in defined *path*.
113 | If does not exist, it returns empty str.
114 |
115 | # Parameters
116 | path (str): The absolute path of file to be read.
117 |
118 | # Return
119 | str: Text content of file.
120 | """
121 |
122 | if os.path.isfile(path):
123 | file = open(path, mode='r')
124 | content = file.read()
125 | file.close()
126 | return content
127 | return ''
128 |
129 |
130 | def write_file(path, data, encoding='utf8'):
131 | """
132 | Writes *data* into file located in *path*, only if it already exists.
133 | As workaround, it replaces \r symbol.
134 |
135 | # Parameters
136 | path (str): The absolute path of file to be written.
137 | data (str): Text content to be written.
138 | """
139 |
140 | if os.path.isfile(path):
141 | file = io.open(path, mode='w', encoding=encoding)
142 | file.write(data.replace('\r', ''))
143 | file.close()
144 |
145 |
146 | def rename(path, new_path):
147 | """
148 | Performs rename operation from *path* to *new_path*. This operation
149 | performs only in case, that there is not file/folder with same name.
150 |
151 | # Parameters
152 | path (str): Old path to be renamed.
153 | new_path (str): New path to be renamed to.
154 | """
155 |
156 | if (os.path.isfile(path) and not os.path.isfile(new_path)) or (os.path.isdir(path) and not os.path.isdir(new_path)):
157 | os.rename(path, new_path)
158 |
159 |
160 | def delete(path):
161 | """
162 | Performs delete operation on file or folder stored on *path*.
163 | If on *path* is file, it performs os.remove().
164 | If on *path* is folder, it performs shutil.rmtree().
165 |
166 | # Parameters
167 | path (str): The absolute path of file or folder to be deleted.
168 | """
169 |
170 | if os.path.isfile(path):
171 | os.remove(path)
172 | elif os.path.isdir(path):
173 | shutil.rmtree(path)
174 |
175 |
176 | def human_readable_size(filesize = 0):
177 | """
178 | Converts number of bytes from *filesize* to human-readable format.
179 | e.g. 2048 is converted to "2 kB".
180 |
181 | # Parameters:
182 | filesize (int): The size of file in bytes.
183 |
184 | # Return:
185 | str: Human-readable size.
186 | """
187 |
188 | for unit in ['', 'k', 'M', 'G', 'T', 'P']:
189 | if filesize < 1024:
190 | return "{} {}B".format("{:.2f}".format(filesize).rstrip('0').rstrip('.'), unit)
191 | filesize = filesize / 1024
192 | return '0 B'
193 |
194 |
195 | class File:
196 | TYPE_FOLDER = 'folder'
197 | TYPE_FILE = 'file'
198 |
199 | def __init__(self, filename, path):
200 | full_path = os.path.join(path, filename)
201 |
202 | self.type = File.TYPE_FOLDER if os.path.isdir(full_path) else File.TYPE_FILE
203 | self.filename = filename
204 | self.path = path
205 | self.filesize = os.path.getsize(full_path) if self.type == File.TYPE_FILE else 0
206 | self.filesize_readable = human_readable_size(self.filesize)
207 |
--------------------------------------------------------------------------------
/flux/templates/overrides_list.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% from "macros.html" import render_error_list %}
3 | {% set page_title = "File Overrides" %}
4 | {% block head %}
5 |
33 | {% endblock head%}
34 |
35 | {% block toolbar %}
36 | {% if overrides_path != None and overrides_path != '' %}
37 |
38 |
39 | /{{ parent_name }}
40 |
41 |
42 | {% else %}
43 |
44 |
45 | {{ repo.name }}
46 |
47 |
48 | {% endif %}
49 |
50 |
51 | Options
52 |
53 |
62 |
63 |
64 |
65 | Upload file
66 |
67 |
68 | {% endblock toolbar %}
69 |
70 | {% block body %}
71 | {{ render_error_list(errors) }}
72 | /{{ overrides_path }}
73 | {% if overrides_path != None and overrides_path != '' %}
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | ../
83 |
84 |
85 |
86 |
87 |
88 | {% endif %}
89 | {% if files %}
90 | {% for file in files %}
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | {% set url_path = list_path if file.type == 'folder' else edit_path %}
101 |
102 | {{ file.filename }}
103 |
104 |
105 |
106 | {% if file.type == 'file' %}
107 | {{ file.filesize_readable }}
108 | {% else %}
109 |
110 | {% endif %}
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | {% if file.type == 'file' %}
121 |
124 |
125 |
126 | {% endif %}
127 |
133 |
134 |
135 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | {% endfor %}
146 | {% elif not files and (overrides_path == None or overrides_path == '') %}
147 |
148 |
149 |
150 |
151 |
No files in this directory.
152 |
153 | {% endif %}
154 | {% endblock body %}
155 |
--------------------------------------------------------------------------------
/flux/models.py:
--------------------------------------------------------------------------------
1 |
2 | """
3 | This package provides the database models using PonyORM.
4 |
5 | Note that we do not rely on the auto increment feature as the previous
6 | SQLAlchemy implementation did not set AUTO INCREMENT on the ID fields,
7 | as PonyORM would.
8 | """
9 |
10 | from flask import url_for
11 | from flux import app, config, utils
12 |
13 | import datetime
14 | import hashlib
15 | import os
16 | import pony.orm as orm
17 | import shutil
18 | import uuid
19 |
20 | db = orm.Database(**config.database)
21 | session = orm.db_session
22 | commit = orm.commit
23 | rollback = orm.rollback
24 | select = orm.select
25 | desc = orm.desc
26 |
27 |
28 | class User(db.Entity):
29 | _table_ = 'users'
30 |
31 | id = orm.PrimaryKey(int)
32 | name = orm.Required(str, unique=True)
33 | passhash = orm.Required(str)
34 | can_manage = orm.Required(bool)
35 | can_download_artifacts = orm.Required(bool)
36 | can_view_buildlogs = orm.Required(bool)
37 | login_tokens = orm.Set('LoginToken')
38 |
39 | def __init__(self, **kwargs):
40 | if 'id' not in kwargs:
41 | kwargs['id'] = (orm.max(x.id for x in User) or 0) + 1
42 | super().__init__(**kwargs)
43 |
44 | def set_password(self, password):
45 | self.passhash = utils.hash_pw(password)
46 |
47 | @classmethod
48 | def get_by_login_details(cls, user_name, password):
49 | passhash = utils.hash_pw(password)
50 | return orm.select(x for x in cls if x.name == user_name and
51 | x.passhash == passhash).first()
52 |
53 | @classmethod
54 | def get_root_user(cls):
55 | return orm.select(x for x in cls if x.name == config.root_user).first()
56 |
57 | @classmethod
58 | def create_or_update_root(cls):
59 | root = cls.get_root_user()
60 | if root:
61 | # Make sure the root has all privileges.
62 | root.can_manage = True
63 | root.can_download_artifacts = True
64 | root.can_view_buildlogs = True
65 | root.set_password(config.root_password)
66 | root.name = config.root_user
67 | else:
68 | # Create a new root user.
69 | app.logger.info('Creating new root user: {!r}'.format(config.root_user))
70 | root = cls(
71 | name=config.root_user,
72 | passhash=utils.hash_pw(config.root_password),
73 | can_manage=True,
74 | can_download_artifacts=True,
75 | can_view_buildlogs=True)
76 | return root
77 |
78 | def url(self):
79 | return url_for('edit_user', user_id=self.id)
80 |
81 |
82 | class LoginToken(db.Entity):
83 | """
84 | A login token represents the credentials that we can savely store in the
85 | browser's session and it will not reveal any information about the users
86 | password. For additional security, a login token is bound to the IP that
87 | the user logged in from an has an expiration date.
88 |
89 | The expiration duration can be set with the `login_token_duration`
90 | configuration value. Setting this option to #None will prevent tokens
91 | from expiring.
92 | """
93 |
94 | _table_ = 'logintokens'
95 |
96 | id = orm.PrimaryKey(int)
97 | ip = orm.Required(str)
98 | user = orm.Required(User)
99 | token = orm.Required(str, unique=True)
100 | created = orm.Required(datetime.datetime)
101 |
102 | @classmethod
103 | def create(cls, ip, user):
104 | " Create a new login token assigned to the specified IP and user. "
105 |
106 | id = (orm.max(x.id for x in cls) or 0) + 1
107 | created = datetime.datetime.now()
108 | token = str(uuid.uuid4()).replace('-', '')
109 | token += hashlib.md5((token + str(created)).encode()).hexdigest()
110 | return cls(id=id, ip=ip, user=user, token=token, created=created)
111 |
112 | def expired(self):
113 | " Returns #True if the token is expired, #False otherwise. "
114 |
115 | if config.login_token_duration is None:
116 | return False
117 | now = datetime.datetime.now()
118 | return (self.created + config.login_token_duration) < now
119 |
120 |
121 | class Repository(db.Entity):
122 | """
123 | Represents a repository for which push events are being accepted. The Git
124 | server specified at the `clone_url` must accept the Flux server's public
125 | key.
126 | """
127 |
128 | _table_ = 'repos'
129 |
130 | id = orm.PrimaryKey(int)
131 | name = orm.Required(str)
132 | secret = orm.Optional(str)
133 | clone_url = orm.Required(str)
134 | build_count = orm.Required(int, default=0)
135 | builds = orm.Set('Build')
136 | ref_whitelist = orm.Optional(str) # newline separated list of accepted Git refs
137 |
138 | def __init__(self, **kwargs):
139 | if 'id' not in kwargs:
140 | kwargs['id'] = (orm.max(x.id for x in Repository) or 0) + 1
141 | super().__init__(**kwargs)
142 |
143 | def url(self, **kwargs):
144 | return url_for('view_repo', path=self.name, **kwargs)
145 |
146 | def check_accept_ref(self, ref):
147 | whitelist = list(filter(bool, self.ref_whitelist.split('\n')))
148 | if not whitelist or ref in whitelist:
149 | return True
150 | return False
151 |
152 | def validate_ref_whitelist(self, value, oldvalue, initiator):
153 | return '\n'.join(filter(bool, (x.strip() for x in value.split('\n'))))
154 |
155 | def most_recent_build(self):
156 | return self.builds.select().order_by(desc(Build.date_started)).first()
157 |
158 |
159 | class Build(db.Entity):
160 | """
161 | Represents a build that is generated on a push to a repository. The build is
162 | initially queued and then processed when a slot is available. The build
163 | directory is generated from the configured root directory and the build
164 | #uuid. The log file has the exact same path with the `.log` suffix appended.
165 |
166 | After the build is complete (whether successful or errornous), the build
167 | directory is zipped and the original directory is removed.
168 | """
169 |
170 | _table_ = 'builds'
171 |
172 | Status_Queued = 'queued'
173 | Status_Building = 'building'
174 | Status_Error = 'error'
175 | Status_Success = 'success'
176 | Status_Stopped = 'stopped'
177 | Status = [Status_Queued, Status_Building, Status_Error, Status_Success, Status_Stopped]
178 |
179 | Data_BuildDir = 'build_dir'
180 | Data_OverrideDir = 'override_dir'
181 | Data_Artifact = 'artifact'
182 | Data_Log = 'log'
183 |
184 | class CanNotDelete(Exception):
185 | pass
186 |
187 | id = orm.PrimaryKey(int)
188 | repo = orm.Required(Repository, column='repo_id')
189 | ref = orm.Required(str)
190 | commit_sha = orm.Required(str)
191 | num = orm.Required(int)
192 | status = orm.Required(str) # One of the Status strings
193 | date_queued = orm.Required(datetime.datetime, default=datetime.datetime.now)
194 | date_started = orm.Optional(datetime.datetime)
195 | date_finished = orm.Optional(datetime.datetime)
196 |
197 | def __init__(self, **kwargs):
198 | # Backwards compatibility for when SQLAlchemy was used, Auto Increment
199 | # was not enabled there.
200 | if 'id' not in kwargs:
201 | kwargs['id'] = (orm.max(x.id for x in Build) or 0) + 1
202 | super(Build, self).__init__(**kwargs)
203 |
204 | def url(self, data=None, **kwargs):
205 | path = self.repo.name + '/' + str(self.num)
206 | if not data:
207 | return url_for('view_build', path=path, **kwargs)
208 | elif data in (self.Data_Artifact, self.Data_Log):
209 | return url_for('download', build_id=self.id, data=data, **kwargs)
210 | else:
211 | raise ValueError('invalid mode: {!r}'.format(mode))
212 |
213 | def path(self, data=Data_BuildDir):
214 | base = os.path.join(config.build_dir, self.repo.name.replace('/', os.sep), str(self.num))
215 | if data == self.Data_BuildDir:
216 | return base
217 | elif data == self.Data_Artifact:
218 | return base + '.zip'
219 | elif data == self.Data_Log:
220 | return base + '.log'
221 | elif data == self.Data_OverrideDir:
222 | return os.path.join(config.override_dir, self.repo.name.replace('/', os.sep))
223 | else:
224 | raise ValueError('invalid value for "data": {!r}'.format(data))
225 |
226 | def exists(self, data):
227 | return os.path.exists(self.path(data))
228 |
229 | def log_contents(self):
230 | path = self.path(self.Data_Log)
231 | if os.path.isfile(path):
232 | with open(path, 'r') as fp:
233 | return fp.read()
234 | return None
235 |
236 | def check_download_permission(self, data, user):
237 | if data == self.Data_Artifact:
238 | return user.can_download_artifacts and (
239 | self.status == self.Status_Success or user.can_view_buildlogs)
240 | elif data == self.Data_Log:
241 | return user.can_view_buildlogs
242 | else:
243 | raise ValueError('invalid value for data: {!r}'.format(data))
244 |
245 | def delete_build(self):
246 | if self.status == self.Status_Building:
247 | raise self.CanNotDelete('can not delete build in progress')
248 | try:
249 | os.remove(self.path(self.Data_Artifact))
250 | except OSError as exc:
251 | app.logger.exception(exc)
252 | try:
253 | os.remove(self.path(self.Data_Log))
254 | except OSError as exc:
255 | app.logger.exception(exc)
256 |
257 | # db.Entity Overrides
258 |
259 | def before_delete(self):
260 | self.delete_build()
261 |
262 |
263 | def get_target_for(path):
264 | """
265 | Given an URL path, returns either a #Repository or #Build that the path
266 | identifies. #None will be retunred if the path points to an unknown
267 | repository or build.
268 |
269 | Examples:
270 |
271 | /User/repo => Repository(User/repo)
272 | /User/repo/1 => Build(1, Repository(User/repo))
273 | """
274 |
275 | parts = path.split('/')
276 | if len(parts) not in (2, 3):
277 | return None
278 | repo_name = parts[0] + '/' + parts[1]
279 | repo = Repository.get(name=repo_name)
280 | if not repo:
281 | return None
282 | if len(parts) == 3:
283 | try: num = int(parts[2])
284 | except ValueError: return None
285 | return Build.get(repo=repo, num=num)
286 | return repo
287 |
288 |
289 | db.generate_mapping(create_tables=True)
290 |
--------------------------------------------------------------------------------
/flux/build.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2016 Niklas Rosenstein
2 | #
3 | # Permission is hereby granted, free of charge, to any person obtaining a copy
4 | # of this software and associated documentation files (the "Software"), to deal
5 | # in the Software without restriction, including without limitation the rights
6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | # copies of the Software, and to permit persons to whom the Software is
8 | # furnished to do so, subject to the following conditions:
9 | #
10 | # The above copyright notice and this permission notice shall be included in
11 | # all copies or substantial portions of the Software.
12 | #
13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | # THE SOFTWARE.
20 | '''
21 | This module implements the Flux worker queue. Flux will start one or
22 | more threads (based on the ``parallel_builds`` configuration value)
23 | that will process the queue.
24 | '''
25 |
26 | from flux import app, config, utils, models
27 | from flux.enums import GitFolderHandling
28 | from flux.models import select, Build
29 | from collections import deque
30 | from threading import Event, Condition, Thread
31 | from datetime import datetime
32 | from distutils import dir_util
33 |
34 | import contextlib
35 | import os
36 | import shlex
37 | import shutil
38 | import stat
39 | import subprocess
40 | import time
41 | import traceback
42 |
43 |
44 | class BuildConsumer(object):
45 | ''' This class can start a number of threads that consume
46 | :class:`Build` objects and execute them. '''
47 |
48 | def __init__(self):
49 | self._cond = Condition()
50 | self._running = False
51 | self._queue = deque()
52 | self._terminate_events = {}
53 | self._threads = []
54 |
55 | def put(self, build):
56 | if not isinstance(build, Build):
57 | raise TypeError('expected Build instance')
58 | assert build.id is not None
59 | # TODO: Check if the build is commited to the database, we should'nt
60 | # enqueue it before it is.
61 | if build.status != Build.Status_Queued:
62 | raise TypeError('build status must be {!r}'.format(Build.Status_Queued))
63 | with self._cond:
64 | if build.id not in self._queue:
65 | self._queue.append(build.id)
66 | self._cond.notify()
67 |
68 | def terminate(self, build):
69 | ''' Given a :class:`Build` object, terminates the ongoing build
70 | process or removes the build from the queue and sets its status
71 | to "stopped". '''
72 |
73 | if not isinstance(build, Build):
74 | raise TypeError('expected Build instance')
75 | with self._cond:
76 | if build.id in self._terminate_events:
77 | self._terminate_events[build.id].set()
78 | elif build.id in self._queue:
79 | self._queue.remove(build.id)
80 | build.status = build.Status_Stopped
81 |
82 | def stop(self, join=True):
83 | with self._cond:
84 | for event in self._terminate_events.values():
85 | event.set()
86 | self._running = False
87 | self._cond.notify()
88 | if join:
89 | [t.join() for t in self._threads]
90 |
91 | def start(self, num_threads=1):
92 | def worker():
93 | while True:
94 | with self._cond:
95 | while not self._queue and self._running:
96 | self._cond.wait()
97 | if not self._running:
98 | break
99 | build_id = self._queue.popleft()
100 | with models.session():
101 | build = Build.get(id=build_id)
102 | if not build or build.status != Build.Status_Queued:
103 | continue
104 | with self._cond:
105 | do_terminate = self._terminate_events[build_id] = Event()
106 | try:
107 | do_build(build_id, do_terminate)
108 | except BaseException as exc:
109 | traceback.print_exc()
110 | finally:
111 | with self._cond:
112 | self._terminate_events.pop(build_id)
113 |
114 | if num_threads < 1:
115 | raise ValueError('num_threads must be >= 1')
116 | with self._cond:
117 | if self._running:
118 | raise RuntimeError('already running')
119 | self._running = True
120 | self._threads = [Thread(target=worker) for i in range(num_threads)]
121 | [t.start() for t in self._threads]
122 |
123 | def is_running(self, build):
124 | with self._cond:
125 | return build.id in self._queue
126 |
127 |
128 | _consumer = BuildConsumer()
129 | enqueue = _consumer.put
130 | terminate_build = _consumer.terminate
131 | run_consumers = _consumer.start
132 | stop_consumers = _consumer.stop
133 |
134 |
135 | def update_queue(consumer=None):
136 | ''' Make sure all builds in the database that are still queued
137 | are actually queued in the BuildConsumer. '''
138 |
139 | if consumer is None:
140 | consumer = _consumer
141 | with models.session():
142 | for build in select(x for x in Build if x.status == Build.Status_Queued):
143 | enqueue(build)
144 | for build in select(x for x in Build if x.status == Build.Status_Building):
145 | if not consumer.is_running(build):
146 | build.status = Build.Status_Stopped
147 |
148 | def deleteGitFolder(build_path):
149 | shutil.rmtree(os.path.join(build_path, '.git'))
150 |
151 | def do_build(build_id, terminate_event):
152 | """
153 | Performs the build step for the build in the database with the specified
154 | *build_id*.
155 | """
156 |
157 | logfile = None
158 | logger = None
159 | status = None
160 |
161 | with contextlib.ExitStack() as stack:
162 | try:
163 | try:
164 | # Retrieve the current build information.
165 | with models.session():
166 | build = Build.get(id=build_id)
167 | app.logger.info('Build {}#{} started.'.format(build.repo.name, build.num))
168 |
169 | build.status = Build.Status_Building
170 | build.date_started = datetime.now()
171 |
172 | build_path = build.path()
173 | override_path = build.path(Build.Data_OverrideDir)
174 | utils.makedirs(os.path.dirname(build_path))
175 | logfile = stack.enter_context(open(build.path(build.Data_Log), 'w'))
176 | logger = utils.create_logger(logfile)
177 |
178 | # Prefetch the repository member as it is required in do_build_().
179 | build.repo
180 |
181 | # Execute the actual build process (must not perform writes to the
182 | # 'build' object as the DB session is over).
183 | if do_build_(build, build_path, override_path, logger, logfile, terminate_event):
184 | status = Build.Status_Success
185 | else:
186 | if terminate_event.is_set():
187 | status = Build.Status_Stopped
188 | else:
189 | status = Build.Status_Error
190 |
191 | finally:
192 | # Create a ZIP from the build directory.
193 | if os.path.isdir(build_path):
194 | logger.info('[Flux]: Zipping build directory...')
195 | utils.zipdir(build_path, build_path + '.zip')
196 | utils.rmtree(build_path, remove_write_protection=True)
197 | logger.info('[Flux]: Done')
198 |
199 | except BaseException as exc:
200 | with models.session():
201 | build = Build.get(id=build_id)
202 | build.status = Build.Status_Error
203 | if logger:
204 | logger.exception(exc)
205 | else:
206 | app.logger.exception(exc)
207 |
208 | finally:
209 | with models.session():
210 | build = Build.get(id=build_id)
211 | if status is not None:
212 | build.status = status
213 | build.date_finished = datetime.now()
214 |
215 | return status == Build.Status_Success
216 |
217 |
218 | def do_build_(build, build_path, override_path, logger, logfile, terminate_event):
219 | logger.info('[Flux]: build {}#{} started'.format(build.repo.name, build.num))
220 |
221 | # Clone the repository.
222 | if build.repo and os.path.isfile(utils.get_repo_private_key_path(build.repo)):
223 | identity_file = utils.get_repo_private_key_path(build.repo)
224 | else:
225 | identity_file = config.ssh_identity_file
226 |
227 | ssh_command = utils.ssh_command(None, identity_file=identity_file) # Enables batch mode
228 | env = {'GIT_SSH_COMMAND': ' '.join(map(shlex.quote, ssh_command))}
229 | logger.info('[Flux]: GIT_SSH_COMMAND={!r}'.format(env['GIT_SSH_COMMAND']))
230 | clone_cmd = ['git', 'clone', build.repo.clone_url, build_path, '--recursive']
231 | res = utils.run(clone_cmd, logger, env=env)
232 | if res != 0:
233 | logger.error('[Flux]: unable to clone repository')
234 | return False
235 |
236 | if terminate_event.is_set():
237 | logger.info('[Flux]: build stopped')
238 | return False
239 |
240 | if build.ref and build.commit_sha == ("0" * 32):
241 | build_start_point = build.ref
242 | is_ref_build = True
243 | else:
244 | build_start_point = build.commit_sha
245 | is_ref_build = False
246 |
247 | # Checkout the correct build_start_point.
248 | checkout_cmd = ['git', 'checkout', '-q', build_start_point]
249 | res = utils.run(checkout_cmd, logger, cwd=build_path)
250 | if res != 0:
251 | logger.error('[Flux]: failed to checkout {!r}'.format(build_start_point))
252 | return False
253 |
254 | # If checkout was initiated by Start build, update commit_sha and ref of build
255 | if is_ref_build:
256 | # update commit sha
257 | get_ref_sha_cmd = ['git', 'rev-parse', 'HEAD']
258 | res_ref_sha, res_ref_sha_stdout = utils.run(get_ref_sha_cmd, logger, cwd=build_path, return_stdout=True)
259 | if res_ref_sha == 0 and res_ref_sha_stdout != None:
260 | with models.session():
261 | Build.get(id=build.id).commit_sha = res_ref_sha_stdout.strip()
262 | else:
263 | logger.error('[Flux]: failed to read current sha')
264 | return False
265 | # update ref; user could enter just branch name, e.g 'master'
266 | get_ref_cmd = ['git', 'rev-parse', '--symbolic-full-name', build_start_point]
267 | res_ref, res_ref_stdout = utils.run(get_ref_cmd, logger, cwd=build_path, return_stdout=True)
268 | if res_ref == 0 and res_ref_stdout != None and res_ref_stdout.strip() != 'HEAD' and res_ref_stdout.strip() != '':
269 | with models.session():
270 | Build.get(id=build.id).ref = res_ref_stdout.strip()
271 | elif res_ref_stdout.strip() == '':
272 | # keep going, used ref was probably commit sha
273 | pass
274 | else:
275 | logger.error('[Flux]: failed to read current ref')
276 | return False
277 |
278 | if terminate_event.is_set():
279 | logger.info('[Flux]: build stopped')
280 | return False
281 |
282 | # Deletes .git folder before build, if is configured so.
283 | if config.git_folder_handling == GitFolderHandling.DELETE_BEFORE_BUILD or config.git_folder_handling == None:
284 | logger.info('[Flux]: removing .git folder before build')
285 | deleteGitFolder(build_path)
286 |
287 | # Copy over overridden files if any
288 | if os.path.exists(override_path):
289 | dir_util.copy_tree(override_path, build_path)
290 |
291 | # Find the build script that we need to execute.
292 | script_fn = None
293 | for fname in config.build_scripts:
294 | script_fn = os.path.join(build_path, fname)
295 | if os.path.isfile(script_fn):
296 | break
297 | script_fn = None
298 |
299 | if not script_fn:
300 | choices = '{' + ','.join(map(str, config.build_scripts)) + '}'
301 | logger.error('[Flux]: no build script found, choices are ' + choices)
302 | return False
303 |
304 | # Make sure the build script is executable.
305 | st = os.stat(script_fn)
306 | os.chmod(script_fn, st.st_mode | stat.S_IEXEC)
307 |
308 | # Execute the script.
309 | logger.info('[Flux]: executing {}'.format(os.path.basename(script_fn)))
310 | logger.info('$ ' + shlex.quote(script_fn))
311 | popen = subprocess.Popen([script_fn], cwd=build_path,
312 | stdout=logfile, stderr=subprocess.STDOUT, stdin=None)
313 |
314 | # Wait until the process finished or the terminate event is set.
315 | while popen.poll() is None and not terminate_event.is_set():
316 | time.sleep(0.5)
317 | if terminate_event.is_set():
318 | try:
319 | popen.terminate()
320 | except OSError as exc:
321 | logger.exception(exc)
322 | logger.error('[Flux]: build stopped. build script terminated')
323 | return False
324 |
325 | # Deletes .git folder after build, if is configured so.
326 | if config.git_folder_handling == GitFolderHandling.DELETE_AFTER_BUILD:
327 | logger.info('[Flux]: removing .git folder after build')
328 | deleteGitFolder(build_path)
329 |
330 | logger.info('[Flux]: exit-code {}'.format(popen.returncode))
331 | return popen.returncode == 0
332 |
--------------------------------------------------------------------------------
/flux/static/flux/fonts/fontello.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Copyright (C) 2018 by original authors @ fontello.com
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 |
--------------------------------------------------------------------------------
/flux/utils.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2016 Niklas Rosenstein
2 | #
3 | # Permission is hereby granted, free of charge, to any person obtaining a copy
4 | # of this software and associated documentation files (the "Software"), to deal
5 | # in the Software without restriction, including without limitation the rights
6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | # copies of the Software, and to permit persons to whom the Software is
8 | # furnished to do so, subject to the following conditions:
9 | #
10 | # The above copyright notice and this permission notice shall be included in
11 | # all copies or substantial portions of the Software.
12 | #
13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | # THE SOFTWARE.
20 |
21 | import io
22 | import functools
23 | import hashlib
24 | import hmac
25 | import logging
26 | import os
27 | import re
28 | import shlex
29 | import shutil
30 | import stat
31 | import subprocess
32 | import urllib.parse
33 | import uuid
34 | import werkzeug
35 | import zipfile
36 |
37 | from . import app, config, models
38 | from urllib.parse import urlparse
39 | from flask import request, session, redirect, url_for, Response
40 | from datetime import datetime
41 | from cryptography.hazmat.primitives import serialization
42 | from cryptography.hazmat.primitives.asymmetric import rsa
43 | from cryptography.hazmat.backends import default_backend
44 |
45 |
46 | def get_raise(data, key, expect_type=None):
47 | ''' Helper function to retrieve an element from a JSON data structure.
48 | The *key* must be a string and may contain periods to indicate nesting.
49 | Parts of the key may be a string or integer used for indexing on lists.
50 | If *expect_type* is not None and the retrieved value is not of the
51 | specified type, TypeError is raised. If the key can not be found,
52 | KeyError is raised. '''
53 |
54 | parts = key.split('.')
55 | resolved = ''
56 | for part in parts:
57 | resolved += part
58 | try:
59 | part = int(part)
60 | except ValueError:
61 | pass
62 |
63 | if isinstance(part, str):
64 | if not isinstance(data, dict):
65 | raise TypeError('expected dictionary to access {!r}'.format(resolved))
66 | try:
67 | data = data[part]
68 | except KeyError:
69 | raise KeyError(resolved)
70 | elif isinstance(part, int):
71 | if not isinstance(data, list):
72 | raise TypeError('expected list to access {!r}'.format(resolved))
73 | try:
74 | data = data[part]
75 | except IndexError:
76 | raise KeyError(resolved)
77 | else:
78 | assert False, "unreachable"
79 |
80 | resolved += '.'
81 |
82 | if expect_type is not None and not isinstance(data, expect_type):
83 | raise TypeError('expected {!r} but got {!r} instead for {!r}'.format(
84 | expect_type.__name__, type(data).__name__, key))
85 | return data
86 |
87 |
88 | def get(data, key, expect_type=None, default=None):
89 | ''' Same as :func:`get_raise`, but returns *default* if the key could
90 | not be found or the datatype doesn't match. '''
91 |
92 | try:
93 | return get_raise(data, key, expect_type)
94 | except (TypeError, ValueError):
95 | return default
96 |
97 |
98 | def basic_auth(message='Login required'):
99 | ''' Sends a 401 response that enables basic auth. '''
100 |
101 | headers = {'WWW-Authenticate': 'Basic realm="{}"'.format(message)}
102 | return Response('Please log in.', 401, headers, mimetype='text/plain')
103 |
104 |
105 | def requires_auth(func):
106 | ''' Decorator for view functions that require basic authentication. '''
107 |
108 | @functools.wraps(func)
109 | def wrapper(*args, **kwargs):
110 | ip = request.remote_addr
111 | token_string = session.get('flux_login_token')
112 | token = models.LoginToken.select(lambda t: t.token == token_string).first()
113 | if not token or token.ip != ip or token.expired():
114 | if token and token.expired():
115 | flash("Your login session has expired.")
116 | token.delete()
117 | return redirect(url_for('login'))
118 |
119 | request.login_token = token
120 | request.user = token.user
121 | return func(*args, **kwargs)
122 |
123 | return wrapper
124 |
125 |
126 | def with_io_response(kwarg='stream', stream_type='text', **response_kwargs):
127 | ''' Decorator for View functions that create a :class:`io.StringIO` or
128 | :class:`io.BytesIO` (based on the *stream_type* parameter) and pass it
129 | as *kwarg* to the wrapped function. The contents of the buffer are
130 | sent back to the client. '''
131 |
132 | if stream_type == 'text':
133 | factory = io.StringIO
134 | elif stream_type == 'bytes':
135 | factory = io.BytesIO
136 | else:
137 | raise ValueError('invalid value for stream_type: {!r}'.format(stream_type))
138 |
139 | def decorator(func):
140 | @functools.wraps(func)
141 | def wrapper(*args, **kwargs):
142 | if kwarg in kwargs:
143 | raise RuntimeError('keyword argument {!r} already occupied'.format(kwarg))
144 | kwargs[kwarg] = stream = factory()
145 | status = func(*args, **kwargs)
146 | return Response(stream.getvalue(), status=status, **response_kwargs)
147 | return wrapper
148 |
149 | return decorator
150 |
151 |
152 | def with_logger(kwarg='logger', stream_dest_kwarg='stream', replace=True):
153 | ''' Decorator that creates a new :class:`logging.Logger` object
154 | additionally to or in-place for the *stream* parameter passed to
155 | the wrapped function. This is usually used in combination with
156 | the :func:`with_io_response` decorator.
157 |
158 | Note that exceptions with this decorator will be logged and the
159 | returned status code will be 500 Internal Server Error. '''
160 |
161 | def decorator(func):
162 | @functools.wraps(func)
163 | def wrapper(*args, **kwargs):
164 | if replace:
165 | stream = kwargs.pop(stream_dest_kwarg)
166 | else:
167 | stream = kwargs[stream_dest_kwarg]
168 | kwargs[kwarg] = logger = create_logger(stream)
169 | try:
170 | return func(*args, **kwargs)
171 | except BaseException as exc:
172 | logger.exception(exc)
173 | return 500
174 | return wrapper
175 |
176 | return decorator
177 |
178 |
179 | def create_logger(stream, name=__name__, fmt=None):
180 | ''' Creates a new :class:`logging.Logger` object with the
181 | specified *name* and *fmt* (defaults to a standard logging
182 | formating including the current time, levelname and message).
183 |
184 | The logger will also output to stderr. '''
185 |
186 | fmt = fmt or '[%(asctime)-15s - %(levelname)s]: %(message)s'
187 | formatter = logging.Formatter(fmt)
188 |
189 | logger = logging.Logger(name)
190 | handler = logging.StreamHandler(stream)
191 | handler.setFormatter(formatter)
192 | logger.addHandler(handler)
193 |
194 | return logger
195 |
196 |
197 | def stream_file(filename, name=None, mime=None):
198 | def generate():
199 | with open(filename, 'rb') as fp:
200 | yield from fp
201 | if name is None:
202 | name = os.path.basename(filename)
203 | headers = {}
204 | headers['Content-Type'] = mime or 'application/x-octet-stream'
205 | headers['Content-Length'] = os.stat(filename).st_size
206 | headers['Content-Disposition'] = 'attachment; filename="' + name + '"'
207 | return Response(generate(), 200, headers)
208 |
209 |
210 | def flash(message=None):
211 | if message is None:
212 | return session.pop('flux_flash', None)
213 | else:
214 | session['flux_flash'] = message
215 |
216 |
217 | def make_secret():
218 | return str(uuid.uuid4())
219 |
220 |
221 | def hash_pw(pw):
222 | return hashlib.md5(pw.encode('utf8')).hexdigest()
223 |
224 |
225 | def makedirs(path):
226 | ''' Shorthand that creates a directory and stays silent when it
227 | already exists. '''
228 |
229 | if not os.path.exists(path):
230 | os.makedirs(path)
231 |
232 |
233 | def rmtree(path, remove_write_protection=False):
234 | """
235 | A wrapper for #shutil.rmtree() that can try to remove write protection
236 | if removing fails, if enabled.
237 | """
238 |
239 | if remove_write_protection:
240 | def on_rm_error(func, path, exc_info):
241 | os.chmod(path, stat.S_IWRITE)
242 | os.unlink(path)
243 | else:
244 | on_rm_error = None
245 |
246 | shutil.rmtree(path, onerror=on_rm_error)
247 |
248 |
249 | def zipdir(dirname, filename):
250 | dirname = os.path.abspath(dirname)
251 | zipf = zipfile.ZipFile(filename, 'w')
252 | for root, dirs, files in os.walk(dirname):
253 | for fname in files:
254 | arcname = os.path.join(os.path.relpath(root, dirname), fname)
255 | zipf.write(os.path.join(root, fname), arcname)
256 | zipf.close()
257 |
258 |
259 | def secure_filename(filename):
260 | """
261 | Similar to #werkzeug.secure_filename(), but preserves leading dots in
262 | the filename.
263 | """
264 |
265 | while True:
266 | filename = filename.lstrip('/').lstrip('\\')
267 | if filename.startswith('..') and filename[2:3] in '/\\':
268 | filename = filename[3:]
269 | elif filename.startswith('.') and filename[1:2] in '/\\':
270 | filename = filename[2:]
271 | else:
272 | break
273 |
274 | has_dot = filename.startswith('.')
275 | filename = werkzeug.secure_filename(filename)
276 | if has_dot:
277 | filename = '.' + filename
278 | return filename
279 |
280 |
281 | def quote(s, for_ninja=False):
282 | """
283 | Enhanced implementation of #shlex.quote().
284 | Does not generate single-quotes on Windows.
285 | """
286 |
287 | if os.name == 'nt' and os.sep == '\\':
288 | s = s.replace('"', '\\"')
289 | if re.search('\s', s) or any(c in s for c in '<>'):
290 | s = '"' + s + '"'
291 | else:
292 | s = shlex.quote(s)
293 | return s
294 |
295 |
296 | def run(command, logger, cwd=None, env=None, shell=False, return_stdout=False,
297 | inherit_env=True):
298 | """
299 | Run a subprocess with the specified command. The command and output of is
300 | logged to logger. The command will automatically be converted to a string
301 | or list of command arguments based on the *shell* parameter.
302 |
303 | # Parameters
304 | command (str, list): A command-string or list of command arguments.
305 | logger (logging.Logger): A logger that will receive the command output.
306 | cwd (str, None): The current working directory.
307 | env (dict, None): The environment for the subprocess.
308 | shell (bool): If set to #True, execute the command via the system shell.
309 | return_stdout (bool): Return the output of the command (including stderr)
310 | to the caller. The result will be a tuple of (returncode, output).
311 | inherit_env (bool): Inherit the current process' environment.
312 |
313 | # Return
314 | int, tuple of (int, str): The return code, or the returncode and the
315 | output of the command.
316 | """
317 |
318 | if shell:
319 | if not isinstance(command, str):
320 | command = ' '.join(quote(x) for x in command)
321 | if logger:
322 | logger.info('$ ' + command)
323 | else:
324 | if isinstance(command, str):
325 | command = shlex.split(command)
326 | if logger:
327 | logger.info('$ ' + ' '.join(map(quote, command)))
328 |
329 | if env is None:
330 | env = {}
331 | if inherit_env:
332 | env = {**os.environ, **env}
333 |
334 | popen = subprocess.Popen(
335 | command, cwd=cwd, env=env, shell=shell, stdout=subprocess.PIPE,
336 | stderr=subprocess.STDOUT, stdin=None)
337 | stdout = popen.communicate()[0].decode()
338 | if stdout:
339 | if popen.returncode != 0 and logger:
340 | logger.error('\n' + stdout)
341 | else:
342 | if logger:
343 | logger.info('\n' + stdout)
344 | if return_stdout:
345 | return popen.returncode, stdout
346 | return popen.returncode
347 |
348 |
349 | def ssh_command(url, *args, no_ptty=False, identity_file=None,
350 | verbose=None, options=None):
351 | ''' Helper function to generate an SSH command. If not options are
352 | specified, the default option ``BatchMode=yes`` will be set. '''
353 |
354 | if options is None:
355 | options = {'BatchMode': 'yes'}
356 | if verbose is None:
357 | verbose = config.ssh_verbose
358 |
359 | command = ['ssh']
360 | if url is not None:
361 | command.append(url)
362 | command += ['-o{}={}'.format(k, v) for (k, v) in options.items()]
363 | if no_ptty:
364 | command.append('-T')
365 | if identity_file:
366 | command += ['-o', 'IdentitiesOnly=yes']
367 | # NOTE: Workaround for windows, as the backslashes are gone at the time
368 | # Git tries to use the GIT_SSH_COMMAND.
369 | command += ['-i', identity_file.replace('\\', '/')]
370 | if verbose:
371 | command.append('-v')
372 | if args:
373 | command.append('--')
374 | command += args
375 | return command
376 |
377 |
378 | def strip_url_path(url):
379 | ''' Strips that path part of the specified *url*. '''
380 |
381 | result = list(urllib.parse.urlparse(url))
382 | result[2] = ''
383 | return urllib.parse.urlunparse(result)
384 |
385 |
386 | def get_github_signature(secret, payload_data):
387 | ''' Generates the Github HMAC signature from the repository
388 | *secret* and the *payload_data*. The GitHub signature is sent
389 | with the ``X-Hub-Signature`` header. '''
390 |
391 | return hmac.new(secret.encode('utf8'), payload_data, hashlib.sha1).hexdigest()
392 |
393 |
394 | def get_bitbucket_signature(secret, payload_data):
395 | ''' Generates the Bitbucket HMAC signature from the repository
396 | *secret* and the *payload_data*. The Bitbucket signature is sent
397 | with the ``X-Hub-Signature`` header. '''
398 |
399 | return hmac.new(secret.encode('utf8'), payload_data, hashlib.sha256).hexdigest()
400 |
401 |
402 | def get_date_diff(date1, date2):
403 | if (not date1) or (not date2):
404 | if (not date1) and date2:
405 | date1 = datetime.now()
406 | else:
407 | return '00:00:00'
408 | diff = (date1 - date2) if date1 > date2 else (date2 - date1)
409 | seconds = int(diff.seconds % 60)
410 | minutes = int(((diff.seconds - seconds) / 60) % 60)
411 | hours = int((diff.seconds - seconds - minutes * 60) / 3600)
412 | return '{:02d}:{:02d}:{:02d}'.format(hours, minutes, seconds)
413 |
414 |
415 | def is_page_active(page, user):
416 | path = request.path
417 |
418 | if page == 'dashboard' and (not path or path == '/'):
419 | return True
420 | elif page == 'repositories' and (path.startswith('/repositories') or path.startswith('/repo') or path.startswith('/edit/repo') or path.startswith('/build') or path.startswith('/overrides')):
421 | return True
422 | elif page == 'users' and (path.startswith('/users') or (path.startswith('/user') and path != ('/user/' + str(user.id)))):
423 | return True
424 | elif page == 'profile' and path == ('/user/' + str(user.id)):
425 | return True
426 | elif page == 'integration' and path == '/integration':
427 | return True
428 | return False
429 |
430 |
431 | def ping_repo(repo_url, repo = None):
432 | if not repo_url or repo_url == '':
433 | return 1
434 |
435 | if repo and os.path.isfile(get_repo_private_key_path(repo)):
436 | identity_file = get_repo_private_key_path(repo)
437 | else:
438 | identity_file = config.ssh_identity_file
439 |
440 | ssh_cmd = ssh_command(None, identity_file=identity_file)
441 | env = {'GIT_SSH_COMMAND': ' '.join(map(quote, ssh_cmd))}
442 | ls_remote = ['git', 'ls-remote', '--exit-code', repo_url]
443 | res = run(ls_remote, app.logger, env=env)
444 | return res
445 |
446 |
447 | def get_customs_path(repo):
448 | return os.path.join(config.customs_dir, repo.name.replace('/', os.sep))
449 |
450 |
451 | def get_override_path(repo):
452 | return os.path.join(config.override_dir, repo.name.replace('/', os.sep))
453 |
454 |
455 | def get_override_build_script_path(repo):
456 | return os.path.join(get_override_path(repo), config.build_scripts[0])
457 |
458 |
459 | def read_override_build_script(repo):
460 | build_script_path = get_override_build_script_path(repo)
461 | if os.path.isfile(build_script_path):
462 | build_script_file = open(build_script_path, mode='r')
463 | build_script = build_script_file.read()
464 | build_script_file.close()
465 | return build_script
466 | return ''
467 |
468 |
469 | def write_override_build_script(repo, build_script):
470 | build_script_path = get_override_build_script_path(repo)
471 | if build_script.strip() == '':
472 | if os.path.isfile(build_script_path):
473 | os.remove(build_script_path)
474 | else:
475 | makedirs(os.path.dirname(build_script_path))
476 | build_script_file = open(build_script_path, mode='w')
477 | build_script_file.write(build_script.replace('\r', ''))
478 | build_script_file.close()
479 |
480 |
481 | def get_public_key():
482 | """
483 | Returns the servers SSH public key.
484 | """
485 |
486 | # XXX Support all valid options and eventually parse the config file?
487 | filename = config.ssh_identity_file or os.path.expanduser('~/.ssh/id_rsa')
488 | if not filename.endswith('.pub'):
489 | filename += '.pub'
490 | if os.path.isfile(filename):
491 | with open(filename) as fp:
492 | return fp.read()
493 | return None
494 |
495 |
496 | def generate_ssh_keypair(public_key_comment):
497 | """
498 | Generates new RSA ssh keypair.
499 |
500 | Return:
501 | tuple(str, str): generated private and public keys
502 | """
503 |
504 | key = rsa.generate_private_key(backend=default_backend(), public_exponent=65537, key_size=4096)
505 | private_key = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode('ascii')
506 | public_key = key.public_key().public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH).decode('ascii')
507 |
508 | if public_key_comment:
509 | public_key += ' ' + public_key_comment
510 |
511 | return private_key, public_key
512 |
513 |
514 | def get_repo_private_key_path(repo):
515 | """
516 | Returns path of private key for repository from Customs folder.
517 |
518 | Return:
519 | str: path to custom private SSH key
520 | """
521 |
522 | return os.path.join(get_customs_path(repo), 'id_rsa')
523 |
524 |
525 | def get_repo_public_key_path(repo):
526 | """
527 | Returns path of public key for repository from Customs folder.
528 |
529 | Return:
530 | str: path to custom public SSH key
531 | """
532 |
533 | return os.path.join(get_customs_path(repo), 'id_rsa.pub')
534 |
--------------------------------------------------------------------------------
/flux/static/flux/css/style.css:
--------------------------------------------------------------------------------
1 | html {
2 | background-color: #FFFFFF;
3 | color: #263238;
4 | font-size: 100%;
5 | font-family: 'Open Sans', sans-serif;
6 | letter-spacing: -0.03125rem;
7 | margin: 0;
8 | min-height: 100%;
9 | position: relative;
10 | }
11 |
12 | body {
13 | margin: 0 0 2.75rem 0;
14 | overflow-x: hidden;
15 | }
16 |
17 | a {
18 | color: #0D47A1;
19 | text-decoration: none;
20 | outline: 0;
21 | }
22 |
23 | a:hover {
24 | color: #1565C0;
25 | text-decoration: underline;
26 | }
27 |
28 | button, .btn {
29 | background-color: #FFFFFF;
30 | border: 0.0625rem solid #CFD8DC;
31 | border-radius: 0.25rem;
32 | color: #263238;
33 | cursor: pointer;
34 | display: inline-block;
35 | font-size: .875rem;
36 | line-height: 1rem;
37 | outline: 0;
38 | padding: .5rem .75rem;
39 | text-decoration: none;
40 | }
41 |
42 | button::-moz-focus-inner {
43 | border: 0;
44 | }
45 |
46 | button:hover, .btn:hover {
47 | background-color: #ECEFF1;
48 | border-color: #B0BEC5;
49 | text-decoration: none;
50 | }
51 |
52 | button:active, button:focus, .btn:active, .btn:focus {
53 | background-color: #CFD8DC;
54 | border-color: #90A4AE;
55 | outline: 0;
56 | }
57 |
58 | button .fa, .btn .fa {
59 | margin-right: .25rem;
60 | }
61 |
62 | button.btn-primary, .btn.btn-primary {
63 | background-color: #455A64;
64 | border-color: #37474F;
65 | color: #ECEFF1;
66 | }
67 |
68 | button.btn-primary:hover, .btn.btn-primary:hover {
69 | background-color: #37474F;
70 | border-color: #263238;
71 | }
72 |
73 | button.btn-primary:active, button.btn-primary:focus, .btn.btn-primary:active, .btn.btn-primary:focus {
74 | background-color: #263238;
75 | border-color: #242E33;
76 | }
77 |
78 | button.btn-danger, .btn.btn-danger {
79 | background-color: #D32F2F;
80 | border-color: #C62828;
81 | color: #FFEBEE;
82 | }
83 |
84 | button.btn-danger:hover, .btn.btn-danger:hover {
85 | background-color: #C62828;
86 | border-color: #B71C1C;
87 | }
88 |
89 | button.btn-danger:active, button.btn-danger:focus, .btn.btn-danger:active, .btn.btn-danger:focus {
90 | background-color: #B71C1C;
91 | border-color: #A61919;
92 | }
93 |
94 | h3 {
95 | margin-bottom: .5rem;
96 | }
97 |
98 | input[type="text"], input[type="password"], textarea {
99 | background-color: #FFFFFF;
100 | border: 0.0625rem solid #CFD8DC;
101 | box-sizing: border-box;
102 | display: block;
103 | color: #263238;
104 | font-size: 1rem;
105 | padding: .375rem .5rem;
106 | width: 100%;
107 | }
108 |
109 | input[type="text"]:focus, input[type="password"]:focus, textarea:focus {
110 | border-color: #90A4AE;
111 | outline: 0;
112 | }
113 |
114 | input[type="text"]:disabled, input[type="password"]:disabled, textarea:disabled {
115 | background-color: #ECEFF1;
116 | }
117 |
118 | input[type="checkbox"] {
119 | outline: 0;
120 | }
121 |
122 | .upload-form .block {
123 | position: relative;
124 | z-index: 0;
125 | }
126 |
127 | .upload-form .fa {
128 | color: #CFD8DC;
129 | font-size: 8rem;
130 | left: calc(50% - 4rem);
131 | position: absolute;
132 | top: 1rem;
133 | z-index: -1;
134 | }
135 |
136 | .upload-form input[type=file] {
137 | cursor: pointer;
138 | height: 100%;
139 | left: 0;
140 | opacity: 0;
141 | position: absolute;
142 | top: 0;
143 | width: 100%;
144 | }
145 |
146 | .upload-form span {
147 | display: block;
148 | font-weight: bold;
149 | padding: 5rem 0;
150 | text-align: center;
151 | }
152 |
153 | textarea {
154 | resize: vertical;
155 | }
156 |
157 | ::placeholder {
158 | color: #90A4AE;
159 | }
160 |
161 | pre {
162 | background-color: #ECEFF1;
163 | border: 0.0625rem solid #CFD8DC;
164 | margin: .5rem 0;
165 | overflow: hidden;
166 | padding: .75rem;
167 | white-space: pre-wrap;
168 | word-wrap: break-word;
169 | }
170 |
171 | .form-field {
172 | margin: 0 0 .5rem 0;
173 | }
174 |
175 | dl, .infobox {
176 | color: #455A64;
177 | font-size: .875rem;
178 | }
179 |
180 | dl dt {
181 | float: left;
182 | font-weight: bold;
183 | width: 5rem;
184 | }
185 |
186 | dl dt::after {
187 | content: ':';
188 | display: inline-block;
189 | }
190 |
191 | dl dd {
192 | margin: 0;
193 | }
194 |
195 | @media (max-width: 991.8px) {
196 | body {
197 | overflow-x: hidden;
198 | }
199 |
200 | header {
201 | position: fixed;
202 | left: 0;
203 | top: 0;
204 | width: 100%;
205 | }
206 |
207 | header nav, .container {
208 | display: block;
209 | width: 100%;
210 | }
211 |
212 | header nav .brand {
213 | display: block;
214 | float: left;
215 | margin-left: 1rem;
216 | }
217 |
218 | header nav ul {
219 | display: none;
220 | clear: both;
221 | margin: 0 0 0.9375rem 0;
222 | padding: 0;
223 | }
224 |
225 | header nav ul li {
226 | display: block;
227 | border-bottom: 0.0625rem solid #37474F;
228 | }
229 |
230 | header nav ul li:last-child {
231 | border-bottom: 0;
232 | }
233 |
234 | header nav ul li a {
235 | font-size: 1rem;
236 | line-height: 1rem;
237 | padding: 0.5rem 1rem;
238 | }
239 |
240 | header nav .collapse-button {
241 | background: none;
242 | border: 0;
243 | display: block;
244 | float: right;
245 | margin-right: 1rem;
246 | }
247 |
248 | #confirm-dialog, #input-dialog {
249 | left: 1rem;
250 | right: 1rem;
251 | }
252 |
253 | main {
254 | margin-top: 3.4375rem;
255 | padding: 0 1.5rem 1.5rem 1.5rem;
256 | overflow: auto;
257 | }
258 |
259 | .block .left-side {
260 | display: block;
261 | margin-bottom: 1rem;
262 | }
263 |
264 | .block .right-side {
265 | display: block;
266 | }
267 |
268 | .block .right-side::after {
269 | clear: both;
270 | content: '';
271 | display: block;
272 | }
273 |
274 | .block-item {
275 | display: table-cell;
276 | vertical-align: top;
277 | }
278 |
279 | .block-icon {
280 | width: 1.5rem;
281 | font-size: 1.5rem;
282 | padding-right: .25rem;
283 | }
284 |
285 | .block .left-side .block-top-item {
286 | max-width: 21.875rem;
287 | }
288 |
289 | .block .right-side .block-item:first-child {
290 | float: left;
291 | }
292 |
293 | .block .right-side .block-item:last-child {
294 | float: right;
295 | }
296 |
297 | footer .container {
298 | padding: 0 1.5rem;
299 | }
300 | }
301 |
302 | @media (min-width: 992px) {
303 | nav, .container {
304 | width: 900px;
305 | margin: 0 auto;
306 | }
307 |
308 | header nav .brand {
309 | display: inline-block;
310 | float: left;
311 | }
312 |
313 | header nav ul {
314 | display: inline-block;
315 | float: right;
316 | margin-right: -.5rem;
317 | margin: 0;
318 | padding: 0;
319 | }
320 |
321 | header nav ul li {
322 | display: inline-block;
323 | }
324 |
325 | header nav ul li a {
326 | font-size: 1rem;
327 | line-height: 1rem;
328 | padding: 1.1875rem .5rem;
329 | }
330 |
331 | header nav .collapse-button {
332 | display: none;
333 | }
334 |
335 | #confirm-dialog, #input-dialog {
336 | left: calc(50% - 12.5rem);
337 | width: 25rem;
338 | }
339 |
340 | .block::after {
341 | content: '';
342 | display: block;
343 | clear: both;
344 | }
345 |
346 | .block .left-side {
347 | display: table;
348 | float: left;
349 | }
350 |
351 | .block .right-side {
352 | display: table;
353 | float: right;
354 | }
355 |
356 | .block .block-item {
357 | display: table-cell;
358 | vertical-align: middle;
359 | }
360 |
361 | .block .left-side .block-top-item {
362 | max-width: 21.875rem;
363 | }
364 |
365 | .block-icon {
366 | width: 2rem;
367 | font-size: 2rem;
368 | padding-right: .5rem;
369 | }
370 |
371 | .login-form {
372 | border: 0.0625rem solid #CFD8DC;
373 | margin: 0 auto;
374 | padding: .5rem;
375 | width: 20rem;
376 | }
377 | }
378 |
379 | @media (max-width: 575.8px) {
380 | .block .left-side .block-top-item {
381 | max-width: 10.625rem;
382 | }
383 | }
384 |
385 | /* header */
386 | header {
387 | background-color: #263238;
388 | color: #ECEFF1;
389 | z-index: 1000;
390 | }
391 |
392 | header nav::after {
393 | content: '';
394 | display: block;
395 | clear: both;
396 | }
397 |
398 | header nav .brand a {
399 | color: #ECEFF1;
400 | display: inline-block;
401 | font-size: 1.5rem;
402 | font-weight: 700;
403 | line-height: 1.5rem;
404 | padding: 0.9375rem 0;
405 | text-decoration: none;
406 | }
407 |
408 | header nav .brand img {
409 | height: 2.25rem;
410 | width: auto;
411 | margin-bottom: -.5625rem;
412 | margin-right: .125rem;
413 | margin-top: -.375rem;
414 | }
415 |
416 | header nav ul li {
417 | list-style: none;
418 | }
419 |
420 | header nav ul li.active a, header nav ul li.active a:hover {
421 | color: #FF9800;
422 | }
423 |
424 | header nav ul li a {
425 | color: #ECEFF1;
426 | display: block;
427 | text-decoration: none;
428 | }
429 |
430 | header nav ul li a:hover {
431 | background-color: #37474F;
432 | color: #B0BEC5;
433 | text-decoration: none;
434 | }
435 |
436 | header nav .collapse-button {
437 | color: #ECEFF1;
438 | font-size: 1.5rem;
439 | line-height: 1.5rem;
440 | outline: 0;
441 | padding: 0.9375rem 0 0.9375rem 0;
442 | }
443 |
444 | header nav ul li .fa {
445 | margin-right: .25rem;
446 | }
447 |
448 | /* main */
449 | .blur {
450 | filter: blur(2px);
451 | }
452 |
453 | main {
454 | background-color: #FFFFFF;
455 | margin-bottom: 3.75rem;
456 | }
457 |
458 | #confirm-dialog, #input-dialog {
459 | background-color: #FFFFFF;
460 | border: 0.0625rem solid #CFD8DC;
461 | box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
462 | display: none;
463 | padding: .5rem;
464 | position: fixed;
465 | top: 4rem;
466 | z-index: 9999;
467 | }
468 |
469 | #confirm-dialog .confirm-icon, #input-dialog .input-icon {
470 | color: #FF9800;
471 | float: left;
472 | font-size: 2rem;
473 | margin-right: .3125rem;
474 | }
475 |
476 | #input-dialog .input-icon {
477 | color: #33AAFF;
478 | }
479 |
480 | #confirm-dialog .confirm-message, #input-dialog .input-message {
481 | font-weight: 700;
482 | padding: .5rem 0 1rem 0;
483 | }
484 |
485 | #confirm-dialog .confirm-buttons, #input-dialog .input-buttons {
486 | border-top: 0.0625rem solid #CFD8DC;
487 | padding-top: .5rem;
488 | text-align: right;
489 | }
490 |
491 | #input-dialog .input-text {
492 | margin: 1rem 0;
493 | }
494 |
495 | #confirm-overlay, #input-overlay {
496 | background-color: rgba(38, 50, 56, 0.3);
497 | bottom: 0;
498 | display: none;
499 | left: 0;
500 | position: fixed;
501 | right: 0;
502 | top: 0;
503 | z-index: 9000;
504 | }
505 |
506 | #toolbar {
507 | margin: 0;
508 | padding: 0;
509 | float: right;
510 | }
511 |
512 | #toolbar li {
513 | display: inline-block;
514 | list-style: none;
515 | margin: 0;
516 | position: relative;
517 | }
518 |
519 | #toolbar li a {
520 | display: block;
521 | padding: .25rem .5rem;
522 | }
523 |
524 | #toolbar li a:hover {
525 | background-color: #ECEFF1;
526 | display: block;
527 | text-decoration: none;
528 | }
529 |
530 | #toolbar li:last-child {
531 | margin-right: 0;
532 | }
533 |
534 | #toolbar li .fa {
535 | color: #455A64;
536 | margin-right: .25rem;
537 | min-width: 0.6875rem;
538 | }
539 |
540 | #toolbar li .fa.fa-clone {
541 | font-size: .8125rem;
542 | }
543 |
544 | #toolbar li .dropdown-menu {
545 | background: #FFFFFF;
546 | border: 0.0625rem solid #CFD8DC;
547 | box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
548 | display: none;
549 | margin-top: .5rem;
550 | min-width: 12rem;
551 | padding: .25rem 0;
552 | position: absolute;
553 | right: 0;
554 | z-index: 10;
555 | }
556 |
557 | #toolbar li .dropdown-menu a {
558 | display: block;
559 | font-size: .9375rem;
560 | padding: .25rem .5rem;
561 | }
562 |
563 | #toolbar li .dropdown-menu a:hover {
564 | background-color: #ECEFF1;
565 | display: block;
566 | text-decoration: none;
567 | }
568 |
569 | #toolbar li .dropdown-menu::before, #toolbar li .dropdown-menu::after {
570 | border: .5rem solid transparent;
571 | bottom: 100%;
572 | content: "";
573 | display: block;
574 | height: 0;
575 | position: absolute;
576 | right: .5rem;
577 | width: 0;
578 | }
579 |
580 | #toolbar li .dropdown-menu::before {
581 | border-bottom-color: #CFD8DC;
582 | }
583 |
584 | #toolbar li .dropdown-menu::after {
585 | border-bottom-color: #FFFFFF;
586 | margin-bottom: -1px;
587 | }
588 |
589 | .block-link, .block-link:hover, .block a, .block a:hover {
590 | color: #263238;
591 | text-decoration: none;
592 | }
593 |
594 | .block {
595 | border: 0.0625rem solid #CFD8DC;
596 | display: block;
597 | margin-bottom: .5rem;
598 | padding: .5rem;
599 | font-size: 1rem;
600 | line-height: 1rem;
601 | }
602 |
603 | a .block:hover, .block-link .block:hover {
604 | background-color: #ECEFF1;
605 | }
606 |
607 | .block-icon .fa.fa-check-circle {
608 | color: #4CAF50;
609 | }
610 |
611 | .block-icon .fa.fa-times-circle {
612 | color: #F44336;
613 | }
614 |
615 | .block-icon .fa.fa-stop-circle {
616 | color: #FF9800;
617 | }
618 |
619 | .block-icon .fa.fa-refresh {
620 | color: #2196F3;
621 | -webkit-animation: spin 2s linear infinite;
622 | -moz-animation: spin 2s linear infinite;
623 | animation: spin 2s linear infinite;
624 | }
625 |
626 | .block-icon .fa.fa-clock-o {
627 | color: #263238;
628 | }
629 |
630 | .block-icon .fa.fa-share-alt, .block-icon .fa.fa-user, .block-icon .fa.fa-file-o, .block-icon .fa.fa-folder-o {
631 | color: #B0BEC5;
632 | }
633 |
634 | .block .block-build-number {
635 | width: 4rem;
636 | }
637 |
638 | .block .left-side .block-top-item {
639 | display: block;
640 | font-weight: 700;
641 | margin-bottom: .5rem;
642 | text-overflow: ellipsis;
643 | white-space: nowrap;
644 | }
645 |
646 | .block .left-side .block-bottom-item.additional {
647 | padding-left: 1rem;
648 | }
649 |
650 | .block .right-side .block-top-item {
651 | display: block;
652 | font-size: 0.875rem;
653 | margin-bottom: .5rem;
654 | }
655 |
656 | .block-bottom-item {
657 | display: block;
658 | font-size: 0.875rem;
659 | }
660 |
661 | .block .right-side .block-item:first-child {
662 | width: 12rem;
663 | }
664 |
665 | .block .block-fa .fa {
666 | margin-right: .25rem;
667 | }
668 |
669 | .btn-newer {
670 | float: left;
671 | }
672 |
673 | .btn-newer .fa {
674 | margin: 0 .25rem 0 0;
675 | }
676 |
677 | .btn-older {
678 | float: right;
679 | }
680 |
681 | .btn-older .fa {
682 | margin: 0 0 0 .25rem;
683 | }
684 |
685 | .block-buttons {
686 | text-align: right;
687 | font-size: 0;
688 | }
689 |
690 | .float-right {
691 | float: right;
692 | }
693 |
694 | .float-left {
695 | float: left;
696 | }
697 |
698 | .block-buttons .btn:first-child {
699 | margin-left: 0;
700 | }
701 |
702 | .block-buttons .btn {
703 | margin-left: 0.25rem;
704 | }
705 |
706 | .block-buttons .btn .fa {
707 | margin-right: 0;
708 | }
709 |
710 | .paging::after {
711 | content: "";
712 | display: block;
713 | clear: both;
714 | }
715 |
716 | .messages {
717 | background-color: #ECEFF1;
718 | border: 0.0625rem solid #B0BEC5;
719 | clear: both;
720 | display: block;
721 | margin-bottom: 1rem;
722 | padding: .5rem;
723 | }
724 |
725 | .messages::after {
726 | content: '';
727 | display: block;
728 | clear: both;
729 | }
730 |
731 | .messages .icon {
732 | float: left;
733 | }
734 |
735 | .messages .close {
736 | float: right;
737 | font-size: .8725rem;
738 | opacity: 0.3;
739 | }
740 |
741 | .messages .close:hover {
742 | opacity: 0.6;
743 | }
744 |
745 | .messages div {
746 | display: block;
747 | margin: 0 1.5rem;
748 | }
749 |
750 | .messages.error {
751 | background-color: #FFEBEE;
752 | border-color: #EF9A9A;
753 | color: #C62828;
754 | }
755 |
756 | .messages.error a {
757 | color: #C62828;
758 | }
759 |
760 | .messages.info {
761 | background-color: #E3F2FD;
762 | border-color: #90CAF9;
763 | color: #1565C0;
764 | }
765 |
766 | .messages.info a {
767 | color: #1565C0;
768 | }
769 |
770 | .messages.success {
771 | background-color: #E8F5E9;
772 | border-color: #A5D6A7;
773 | color: #2E7D32;
774 | }
775 |
776 | .messages.success a {
777 | color: #2E7D32;
778 | }
779 |
780 | .login-form .login-header {
781 | border-bottom: 0.0625rem solid #CFD8DC;
782 | display: block;
783 | font-weight: 700;
784 | margin-bottom: .75rem;
785 | padding-bottom: .75rem;
786 | }
787 |
788 | .login-session {
789 | padding: 0.5em;
790 | border: 1px solid grey;
791 | border-bottom: none;
792 | background-color: #e4f4ff;
793 | }
794 | .login-session:last-child {
795 | border-bottom: 1px solid grey;
796 | }
797 | .login-session.expired {
798 | background-color: #ddd;
799 | color: gray;
800 | }
801 | .login-session-ip {
802 | font-weight: bold;
803 | }
804 | .login-session-date {
805 | font-size: 80%;
806 | }
807 |
808 | .field {
809 | margin: .5rem 0;
810 | }
811 |
812 | .field.required label::after {
813 | color: #C62828;
814 | content: '*';
815 | display: inline-block;
816 | font-size: .875rem;
817 | margin-left: .25rem;
818 | }
819 |
820 | .field label {
821 | display: block;
822 | font-weight: 700;
823 | margin-bottom: .25rem;
824 | }
825 |
826 | .field label.checkbox {
827 | font-weight: normal;
828 | }
829 |
830 | .field .infobox {
831 | margin-bottom: .5rem;
832 | }
833 |
834 | .fa.fa-wait-spin {
835 | -webkit-animation: spin 1s linear infinite;
836 | -moz-animation: spin 1s linear infinite;
837 | animation: spin 1s linear infinite;
838 | }
839 |
840 | #repo_check_result {
841 | display: block;
842 | margin-left: .25rem;
843 | }
844 |
845 | #repo_check_result .fa {
846 | margin-right: .25rem;
847 | }
848 |
849 | #repo_check_result .fa.fa-check {
850 | color: #4CAF50;
851 | }
852 |
853 | #repo_check_result .fa.fa-times {
854 | color: #F44336;
855 | }
856 |
857 | #repo_check_result .fa.fa-wait-spin {
858 | color: #2196F3;
859 | }
860 |
861 | #repo_build_script {
862 | min-height: 10rem;
863 | }
864 |
865 | #override_content {
866 | min-height: 20rem;
867 | }
868 |
869 | .toggable {
870 | display: none;
871 | }
872 |
873 | /* footer */
874 | footer {
875 | background-color: #ECEFF1;
876 | bottom: 0;
877 | color: #455A64;
878 | font-size: 0.75rem;
879 | left: 0;
880 | line-height: 0.75rem;
881 | overflow: hidden;
882 | padding: 1rem 0;
883 | position: absolute;
884 | width: 100%;
885 | z-index: 1000;
886 | }
887 |
888 | @-moz-keyframes spin {
889 | 100% {
890 | -moz-transform: rotate(360deg);
891 | }
892 | }
893 |
894 | @-webkit-keyframes spin {
895 | 100% {
896 | -webkit-transform: rotate(360deg);
897 | }
898 | }
899 |
900 | @keyframes spin {
901 | 100% {
902 | -webkit-transform: rotate(360deg);
903 | transform:rotate(360deg);
904 | }
905 | }
906 |
907 | @font-face {
908 | font-family: 'fontello';
909 | src: url('../fonts/fontello.eot?5736516');
910 | src: url('../fonts/fontello.eot?5736516#iefix') format('embedded-opentype'),
911 | url('../fonts/fontello.woff2?5736516') format('woff2'),
912 | url('../fonts/fontello.woff?5736516') format('woff'),
913 | url('../fonts/fontello.ttf?5736516') format('truetype'),
914 | url('../fonts/fontello.svg?5736516#fontello') format('svg');
915 | font-weight: normal;
916 | font-style: normal;
917 | }
918 | .fa {
919 | display: inline-block;
920 | font: normal normal normal .875rem/1 fontello;
921 | font-size: inherit;
922 | text-rendering: auto;
923 | -webkit-font-smoothing: antialiased;
924 | -moz-osx-font-smoothing: grayscale;
925 | }
926 |
927 | .fa-check-circle:before { content: '\e800'; }
928 | .fa-cog:before { content: '\e801'; }
929 | .fa-exclamation-triangle:before { content: '\e802'; }
930 | .fa-clock-o:before { content: '\e803'; }
931 | .fa-refresh:before { content: '\e804'; }
932 | .fa-info-circle:before { content: '\e805'; }
933 | .fa-sign-out:before { content: '\e806'; }
934 | .fa-chevron-left:before { content: '\e807'; }
935 | .fa-download:before { content: '\e808'; }
936 | .fa-plus:before { content: '\e809'; }
937 | .fa-pencil:before { content: '\e80a'; }
938 | .fa-tag:before { content: '\e80b'; }
939 | .fa-times-circle:before { content: '\e80c'; }
940 | .fa-check:before { content: '\e80d'; }
941 | .fa-times:before { content: '\e80e'; }
942 | .fa-question-circle:before { content: '\e80f'; }
943 | .fa-flag:before { content: '\e810'; }
944 | .fa-user:before { content: '\e811'; }
945 | .fa-chevron-right:before { content: '\e812'; }
946 | .fa-bars:before { content: '\f0c9'; }
947 | .fa-code-fork:before { content: '\f126'; }
948 | .fa-calendar-o:before { content: '\f133'; }
949 | .fa-share-alt:before { content: '\f1e0'; }
950 | .fa-trash:before { content: '\f1f8'; }
951 | .fa-calendar-check-o:before { content: '\f274'; }
952 | .fa-stop-circle:before { content: '\f28d'; }
953 | .fa-stop-circle-o:before { content: '\f28e'; }
954 | .fa-wait-spin:before { content: '\e834'; }
955 | .fa-folder-o:before { content: '\f114'; }
956 | .fa-file-o:before { content: '\e813'; }
957 | .fa-upload:before { content: '\e814'; }
958 | .fa-clone:before { content: '\f24d'; }
959 | .fa-key:before { content: '\e815'; }
--------------------------------------------------------------------------------
/flux/views.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2016 Niklas Rosenstein
2 | #
3 | # Permission is hereby granted, free of charge, to any person obtaining a copy
4 | # of this software and associated documentation files (the "Software"), to deal
5 | # in the Software without restriction, including without limitation the rights
6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | # copies of the Software, and to permit persons to whom the Software is
8 | # furnished to do so, subject to the following conditions:
9 | #
10 | # The above copyright notice and this permission notice shall be included in
11 | # all copies or substantial portions of the Software.
12 | #
13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | # THE SOFTWARE.
20 |
21 | from flux import app, config, file_utils, models, utils
22 | from flux.build import enqueue, terminate_build
23 | from flux.models import User, LoginToken, Repository, Build, get_target_for, select, desc
24 | from flux.utils import secure_filename
25 | from flask import request, session, redirect, url_for, render_template, abort
26 | from datetime import datetime
27 |
28 | import json
29 | import os
30 | import uuid
31 |
32 | API_GOGS = 'gogs'
33 | API_GITHUB = 'github'
34 | API_GITEA = 'gitea'
35 | API_GITBUCKET = 'gitbucket'
36 | API_BITBUCKET = 'bitbucket'
37 | API_BITBUCKET_CLOUD = 'bitbucket-cloud'
38 | API_GITLAB = 'gitlab'
39 | API_BARE = 'bare'
40 |
41 | @app.route('/hook/push', methods=['POST'])
42 | @utils.with_io_response(mimetype='text/plain')
43 | @utils.with_logger()
44 | @models.session
45 | def hook_push(logger):
46 | ''' PUSH event webhook. The URL parameter ``api`` must be specified
47 | for Flux to expect the correct JSON payload format. Supported values
48 | for ``api`` are
49 |
50 | * ``gogs``
51 | * ``github``
52 | * ``gitea``
53 | * ``gitbucket``
54 | * ``bitbucket``
55 | * ``bitbucket-cloud``
56 | * ``gitlab``
57 | * ``bare``
58 |
59 | If no or an invalid value is specified for this parameter, a 400
60 | Invalid Request response is generator. '''
61 |
62 | api = request.args.get('api')
63 | if api not in (API_GOGS, API_GITHUB, API_GITEA, API_GITBUCKET, API_BITBUCKET, API_BITBUCKET_CLOUD, API_GITLAB, API_BARE):
64 | logger.error('invalid `api` URL parameter: {!r}'.format(api))
65 | return 400
66 |
67 | logger.info('PUSH event received. Processing JSON payload.')
68 | try:
69 | # XXX Determine encoding from Request Headers, if possible.
70 | data = json.loads(request.data.decode('utf8'))
71 | except (UnicodeDecodeError, ValueError) as exc:
72 | logger.error('Invalid JSON data received: {}'.format(exc))
73 | return 400
74 |
75 | if api == API_GOGS:
76 | owner = utils.get(data, 'repository.owner.username', str)
77 | name = utils.get(data, 'repository.name', str)
78 | ref = utils.get(data, 'ref', str)
79 | commit = utils.get(data, 'after', str)
80 | secret = utils.get(data, 'secret', str)
81 | get_repo_secret = lambda r: r.secret
82 | elif api == API_GITHUB:
83 | event = request.headers.get('X-Github-Event')
84 | if event != 'push':
85 | logger.error("Payload rejected (expected 'push' event, got {!r})".format(event))
86 | return 400
87 | owner = utils.get(data, 'repository.owner.name', str)
88 | name = utils.get(data, 'repository.name', str)
89 | ref = utils.get(data, 'ref', str)
90 | commit = utils.get(data, 'after', str)
91 | secret = request.headers.get('X-Hub-Signature', '').replace('sha1=', '')
92 | get_repo_secret = lambda r: utils.get_github_signature(r.secret, request.data)
93 | elif api == API_GITEA:
94 | event = request.headers.get('X-Gitea-Event')
95 | if event != 'push':
96 | logger.error("Payload rejected (expected 'push' event, got {!r})".format(event))
97 | return 400
98 | owner = utils.get(data, 'repository.owner.username', str)
99 | name = utils.get(data, 'repository.name', str)
100 | ref = utils.get(data, 'ref', str)
101 | commit = utils.get(data, 'after', str)
102 | secret = utils.get(data, 'secret', str)
103 | get_repo_secret = lambda r: r.secret
104 | elif api == API_GITBUCKET:
105 | event = request.headers.get('X-Github-Event')
106 | if event != 'push':
107 | logger.error("Payload rejected (expected 'push' event, got {!r})".format(event))
108 | return 400
109 | owner = utils.get(data, 'repository.owner.login', str)
110 | name = utils.get(data, 'repository.name', str)
111 | ref = utils.get(data, 'ref', str)
112 | commit = utils.get(data, 'after', str)
113 | secret = request.headers.get('X-Hub-Signature', '').replace('sha1=', '')
114 | if secret:
115 | get_repo_secret = lambda r: utils.get_github_signature(r.secret, request.data)
116 | else:
117 | get_repo_secret = lambda r: r.secret
118 | elif api == API_BITBUCKET:
119 | event = request.headers.get('X-Event-Key')
120 | if event != 'repo:refs_changed':
121 | logger.error("Payload rejected (expected 'repo:refs_changed' event, got {!r})".format(event))
122 | return 400
123 | owner = utils.get(data, 'repository.project.name', str)
124 | name = utils.get(data, 'repository.name', str)
125 | ref = utils.get(data, 'changes.0.refId', str)
126 | commit = utils.get(data, 'changes.0.toHash', str)
127 | secret = request.headers.get('X-Hub-Signature', '').replace('sha256=', '')
128 | if secret:
129 | get_repo_secret = lambda r: utils.get_bitbucket_signature(r.secret, request.data)
130 | else:
131 | get_repo_secret = lambda r: r.secret
132 | elif api == API_BITBUCKET_CLOUD:
133 | event = request.headers.get('X-Event-Key')
134 | if event != 'repo:push':
135 | logger.error("Payload rejected (expected 'repo:push' event, got {!r})".format(event))
136 | return 400
137 | owner = utils.get(data, 'repository.project.project', str)
138 | name = utils.get(data, 'repository.name', str)
139 |
140 | ref_type = utils.get(data, 'push.changes.0.new.type', str)
141 | ref_name = utils.get(data, 'push.changes.0.new.name', str)
142 | ref = "refs/" + ("heads/" if ref_type == "branch" else "tags/") + ref_name
143 |
144 | commit = utils.get(data, 'push.changes.0.new.target.hash', str)
145 | secret = None
146 | get_repo_secret = lambda r: r.secret
147 | elif api == API_GITLAB:
148 | event = utils.get(data, 'object_kind', str)
149 | if event != 'push' and event != 'tag_push':
150 | logger.error("Payload rejected (expected 'push' or 'tag_push' event, got {!r})".format(event))
151 | return 400
152 | owner = utils.get(data, 'project.namespace', str)
153 | name = utils.get(data, 'project.name', str)
154 | ref = utils.get(data, 'ref', str)
155 | commit = utils.get(data, 'checkout_sha', str)
156 | secret = request.headers.get('X-Gitlab-Token')
157 | get_repo_secret = lambda r: r.secret
158 | elif api == API_BARE:
159 | owner = utils.get(data, 'owner', str)
160 | name = utils.get(data, 'name', str)
161 | ref = utils.get(data, 'ref', str)
162 | commit = utils.get(data, 'commit', str)
163 | secret = utils.get(data, 'secret', str)
164 | get_repo_secret = lambda r: r.secret
165 | else:
166 | assert False, "unreachable"
167 |
168 | if not name:
169 | logger.error('invalid JSON: no repository name received')
170 | return 400
171 | if not owner:
172 | logger.error('invalid JSON: no repository owner received')
173 | return 400
174 | if not ref:
175 | logger.error('invalid JSON: no Git ref received')
176 | return 400
177 | if not commit:
178 | logger.error('invalid JSON: no commit SHA received')
179 | return 400
180 | if len(commit) != 40:
181 | logger.error('invalid JSON: commit SHA has invalid length')
182 | return 400
183 | if secret == None:
184 | secret = ''
185 |
186 | name = owner + '/' + name
187 |
188 | repo = Repository.get(name=name)
189 | if not repo:
190 | logger.error('PUSH event rejected (unknown repository)')
191 | return 400
192 | if get_repo_secret(repo) != secret:
193 | logger.error('PUSH event rejected (invalid secret)')
194 | return 400
195 | if not repo.check_accept_ref(ref):
196 | logger.info('Git ref {!r} not whitelisted. No build dispatched'.format(ref))
197 | return 200
198 |
199 | build = Build(
200 | repo=repo,
201 | commit_sha=commit,
202 | num=repo.build_count,
203 | ref=ref,
204 | status=Build.Status_Queued,
205 | date_queued=datetime.now(),
206 | date_started=None,
207 | date_finished=None)
208 | repo.build_count += 1
209 |
210 | models.commit()
211 | enqueue(build)
212 | logger.info('Build #{} for repository {} queued'.format(build.num, repo.name))
213 | logger.info(utils.strip_url_path(config.app_url) + build.url())
214 | return 200
215 |
216 |
217 | @app.route('/')
218 | @models.session
219 | @utils.requires_auth
220 | def dashboard():
221 | context = {}
222 | context['builds'] = select(x for x in Build).order_by(desc(Build.date_queued)).limit(10)
223 | context['user'] = request.user
224 | return render_template('dashboard.html', **context)
225 |
226 |
227 | @app.route('/repositories')
228 | @models.session
229 | @utils.requires_auth
230 | def repositories():
231 | repositories = select(x for x in Repository).order_by(Repository.name)
232 | return render_template('repositories.html', user=request.user, repositories=repositories)
233 |
234 |
235 | @app.route('/users')
236 | @models.session
237 | @utils.requires_auth
238 | def users():
239 | if not request.user.can_manage:
240 | return abort(403)
241 | users = select(x for x in User)
242 | return render_template('users.html', user=request.user, users=users)
243 |
244 |
245 | @app.route('/integration')
246 | @models.session
247 | @utils.requires_auth
248 | def integration():
249 | if not request.user.can_manage:
250 | return abort(403)
251 | return render_template('integration.html', user=request.user, public_key=utils.get_public_key())
252 |
253 |
254 | @app.route('/login', methods=['GET', 'POST'])
255 | @models.session
256 | def login():
257 | errors = []
258 | if request.method == 'POST':
259 | user_name = request.form['user_name']
260 | user_password = request.form['user_password']
261 | if user_name and user_password:
262 | user = User.get(name=user_name, passhash=utils.hash_pw(user_password))
263 | if user:
264 | token = LoginToken.create(request.remote_addr, user)
265 | session['flux_login_token'] = token.token
266 | return redirect(url_for('dashboard'))
267 | errors.append('Username or password invalid.')
268 | return render_template('login.html', errors=errors)
269 |
270 |
271 | @app.route('/logout')
272 | @models.session
273 | @utils.requires_auth
274 | def logout():
275 | if request.login_token:
276 | request.login_token.delete()
277 | session.pop('flux_login_token')
278 | return redirect(url_for('dashboard'))
279 |
280 |
281 | @app.route('/repo/')
282 | @models.session
283 | @utils.requires_auth
284 | def view_repo(path):
285 | repo = get_target_for(path)
286 | if not isinstance(repo, Repository):
287 | return abort(404)
288 |
289 | context = {}
290 | page_size = 10
291 |
292 | try:
293 | context['page_number'] = int(request.args.get('page', 1))
294 | except ValueError:
295 | context['page_number'] = 1
296 |
297 | page_from = (context['page_number'] - 1) * page_size
298 | page_to = page_from + page_size
299 |
300 | context['next_page'] = None if context['page_number'] <= 1 else context['page_number'] - 1
301 | context['previous_page'] = None if len(repo.builds) <= page_to else context['page_number'] + 1
302 | context['builds'] = repo.builds.select().order_by(desc(Build.date_queued))[page_from:page_to]
303 | return render_template('view_repo.html', user=request.user, repo=repo, **context)
304 |
305 |
306 | @app.route('/repo/generate-keypair/')
307 | @models.session
308 | @utils.requires_auth
309 | def generate_keypair(path):
310 | if not request.user.can_manage:
311 | return abort(403)
312 |
313 | repo = get_target_for(path)
314 | if not isinstance(repo, Repository):
315 | return abort(404)
316 |
317 | session['errors'] = []
318 |
319 | utils.makedirs(utils.get_customs_path(repo))
320 |
321 | try:
322 | private_key, public_key = utils.generate_ssh_keypair(repo.name + '@FluxCI')
323 | private_key_path = utils.get_repo_private_key_path(repo)
324 | public_key_path = utils.get_repo_public_key_path(repo)
325 |
326 | try:
327 | file_utils.create_file_path(private_key_path)
328 | file_utils.create_file_path(public_key_path)
329 |
330 | file_utils.write_file(private_key_path, private_key)
331 | file_utils.write_file(public_key_path, public_key)
332 |
333 | try:
334 | os.chmod(private_key_path, 0o600)
335 | utils.flash('SSH keypair generated.')
336 | except BaseException as exc:
337 | app.logger.info(exc)
338 | session['errors'].append('Could not set permissions to newly generated private key.')
339 | except BaseException as exc:
340 | app.logger.info(exc)
341 | session['errors'].append('Could not save generated SSH keypair.')
342 | except BaseException as exc:
343 | app.logger.info(exc)
344 | session['errors'].append('Could not generate new SSH keypair.')
345 |
346 | return redirect(url_for('edit_repo', repo_id = repo.id))
347 |
348 |
349 | @app.route('/repo/remove-keypair/')
350 | @models.session
351 | @utils.requires_auth
352 | def remove_keypair(path):
353 | if not request.user.can_manage:
354 | return abort(403)
355 |
356 | repo = get_target_for(path)
357 | if not isinstance(repo, Repository):
358 | return abort(404)
359 |
360 | session['errors'] = []
361 |
362 | private_key_path = utils.get_repo_private_key_path(repo)
363 | public_key_path = utils.get_repo_public_key_path(repo)
364 |
365 | try:
366 | file_utils.delete(private_key_path)
367 | file_utils.delete(public_key_path)
368 | utils.flash('SSH keypair removed.')
369 | except BaseException as exc:
370 | app.logger.info(exc)
371 | session['errors'].append('Could not remove SSH keypair.')
372 |
373 | return redirect(url_for('edit_repo', repo_id = repo.id))
374 |
375 |
376 | @app.route('/build/')
377 | @models.session
378 | @utils.requires_auth
379 | def view_build(path):
380 | build = get_target_for(path)
381 | if not isinstance(build, Build):
382 | return abort(404)
383 |
384 | restart = request.args.get('restart', '').strip().lower() == 'true'
385 | if restart:
386 | if build.status != Build.Status_Building:
387 | build.delete_build()
388 | build.status = Build.Status_Queued
389 | build.date_started = None
390 | build.date_finished = None
391 | models.commit()
392 | enqueue(build)
393 | return redirect(build.url())
394 |
395 | stop = request.args.get('stop', '').strip().lower() == 'true'
396 | if stop:
397 | if build.status == Build.Status_Queued:
398 | build.status = Build.Status_Stopped
399 | elif build.status == Build.Status_Building:
400 | terminate_build(build)
401 | return redirect(build.url())
402 |
403 | return render_template('view_build.html', user=request.user, build=build)
404 |
405 |
406 | @app.route('/edit/repo', methods=['GET', 'POST'], defaults={'repo_id': None})
407 | @app.route('/edit/repo/', methods=['GET', 'POST'])
408 | @models.session
409 | @utils.requires_auth
410 | def edit_repo(repo_id):
411 | if not request.user.can_manage:
412 | return abort(403)
413 | if repo_id is not None:
414 | repo = Repository.get(id=repo_id)
415 | else:
416 | repo = None
417 |
418 | errors = []
419 |
420 | context = {}
421 | if repo and repo.name:
422 | if os.path.isfile(utils.get_repo_public_key_path(repo)):
423 | try:
424 | context['public_key'] = file_utils.read_file(utils.get_repo_public_key_path(repo))
425 | except BaseException as exc:
426 | app.logger.info(exc)
427 | errors.append('Could not read public key for this repository.')
428 |
429 | if request.method == 'POST':
430 | secret = request.form.get('repo_secret', '')
431 | clone_url = request.form.get('repo_clone_url', '')
432 | repo_name = request.form.get('repo_name', '').strip()
433 | ref_whitelist = request.form.get('repo_ref_whitelist', '')
434 | build_script = request.form.get('repo_build_script', '')
435 | if len(repo_name) < 3 or repo_name.count('/') != 1:
436 | errors.append('Invalid repository name. Format must be owner/repo')
437 | if not clone_url:
438 | errors.append('No clone URL specified')
439 | other = Repository.get(name=repo_name)
440 | if (other and not repo) or (other and other.id != repo.id):
441 | errors.append('Repository {!r} already exists'.format(repo_name))
442 | if not errors:
443 | if not repo:
444 | repo = Repository(
445 | name=repo_name,
446 | clone_url=clone_url,
447 | secret=secret,
448 | build_count=0,
449 | ref_whitelist=ref_whitelist)
450 | else:
451 | repo.name = repo_name
452 | repo.clone_url = clone_url
453 | repo.secret = secret
454 | repo.ref_whitelist = ref_whitelist
455 | try:
456 | utils.write_override_build_script(repo, build_script)
457 | except BaseException as exc:
458 | app.logger.info(exc)
459 | errors.append('Could not make change on build script')
460 | if not errors:
461 | return redirect(repo.url())
462 |
463 | return render_template('edit_repo.html', user=request.user, repo=repo, errors=errors, **context)
464 |
465 |
466 | @app.route('/user/new', defaults={'user_id': None}, methods=['GET', 'POST'])
467 | @app.route('/user/', methods=['GET', 'POST'])
468 | @models.session
469 | @utils.requires_auth
470 | def edit_user(user_id):
471 | cuser = None
472 | if user_id is not None:
473 | cuser = User.get(id=user_id)
474 | if not cuser:
475 | return abort(404)
476 | if cuser.id != request.user.id and not request.user.can_manage:
477 | return abort(403)
478 | elif not request.user.can_manage:
479 | return abort(403)
480 |
481 | errors = []
482 | if request.method == 'POST':
483 | if not cuser and not request.user.can_manage:
484 | return abort(403)
485 |
486 | user_name = request.form.get('user_name')
487 | password = request.form.get('user_password')
488 | can_manage = request.form.get('user_can_manage') == 'on'
489 | can_view_buildlogs = request.form.get('user_can_view_buildlogs') == 'on'
490 | can_download_artifacts = request.form.get('user_can_download_artifacts') == 'on'
491 |
492 | if not cuser: # Create a new user
493 | assert request.user.can_manage
494 | other = User.get(name=user_name)
495 | if other:
496 | errors.append('User {!r} already exists'.format(user_name))
497 | elif len(user_name) == 0:
498 | errors.append('Username is empty')
499 | elif len(password) == 0:
500 | errors.append('Password is empty')
501 | else:
502 | cuser = User(name=user_name, passhash=utils.hash_pw(password),
503 | can_manage=can_manage, can_view_buildlogs=can_view_buildlogs,
504 | can_download_artifacts=can_download_artifacts)
505 | else: # Update user settings
506 | if password:
507 | cuser.passhash = utils.hash_pw(password)
508 | # The user can only update privileges if he has managing privileges.
509 | if request.user.can_manage:
510 | cuser.can_manage = can_manage
511 | cuser.can_view_buildlogs = can_view_buildlogs
512 | cuser.can_download_artifacts = can_download_artifacts
513 | if not errors:
514 | return redirect(cuser.url())
515 | models.rollback()
516 |
517 | return render_template('edit_user.html', user=request.user, cuser=cuser,
518 | errors=errors)
519 |
520 |
521 | @app.route('/download//')
522 | @models.session
523 | @utils.requires_auth
524 | def download(build_id, data):
525 | if data not in (Build.Data_Artifact, Build.Data_Log):
526 | return abort(404)
527 | build = Build.get(id=build_id)
528 | if not build:
529 | return abort(404)
530 | if not build.check_download_permission(data, request.user):
531 | return abort(403)
532 | if not build.exists(data):
533 | return abort(404)
534 | mime = 'application/zip' if data == Build.Data_Artifact else 'text/plain'
535 | download_name = "{}-{}.{}".format(build.repo.name.replace("/", "_"), build.num, "zip" if data == Build.Data_Artifact else 'log')
536 | return utils.stream_file(build.path(data), name=download_name, mime=mime)
537 |
538 |
539 | @app.route('/delete')
540 | @models.session
541 | @utils.requires_auth
542 | def delete():
543 | repo_id = request.args.get('repo_id', '')
544 | build_id = request.args.get('build_id', '')
545 | user_id = request.args.get('user_id', '')
546 |
547 | delete_target = None
548 | return_to = 'dashboard'
549 | if build_id:
550 | delete_target = Build.get(id=build_id)
551 | return_to = delete_target.repo.url()
552 | if not request.user.can_manage:
553 | return abort(403)
554 | elif repo_id:
555 | delete_target = Repository.get(id=repo_id)
556 | return_to = url_for('repositories')
557 | if not request.user.can_manage:
558 | return abort(403)
559 | elif user_id:
560 | delete_target = User.get(id=user_id)
561 | return_to = url_for('users')
562 | if delete_target and delete_target.id != request.user.id and not request.user.can_manage:
563 | return abort(403)
564 |
565 | if not delete_target:
566 | return abort(404)
567 |
568 | try:
569 | delete_target.delete()
570 | except Build.CanNotDelete as exc:
571 | models.rollback()
572 | utils.flash(str(exc))
573 | referer = request.headers.get('Referer', return_to)
574 | return redirect(referer)
575 |
576 | utils.flash('{} deleted'.format(type(delete_target).__name__))
577 | return redirect(return_to)
578 |
579 |
580 | @app.route('/build')
581 | @models.session
582 | @utils.requires_auth
583 | def build():
584 | repo_id = request.args.get('repo_id', '')
585 | ref_name = request.args.get('ref', '')
586 | if not repo_id or not ref_name:
587 | return abort(400)
588 | if not request.user.can_manage:
589 | return abort(403)
590 |
591 | commit = '0' * 32
592 | repo = Repository.get(id=repo_id)
593 | build = Build(
594 | repo=repo,
595 | commit_sha=commit,
596 | num=repo.build_count,
597 | ref=ref_name,
598 | status=Build.Status_Queued,
599 | date_queued=datetime.now(),
600 | date_started=None,
601 | date_finished=None)
602 | repo.build_count += 1
603 |
604 | models.commit()
605 | enqueue(build)
606 | return redirect(repo.url())
607 |
608 | @app.route('/ping-repo', methods=['POST'])
609 | @models.session
610 | @utils.requires_auth
611 | def ping_repo():
612 | repo_url = request.form.get('url')
613 | repo_name = request.form.get('repo')
614 |
615 | repo = type('',(object,),{'name' : repo_name})()
616 |
617 | res = utils.ping_repo(repo_url, repo)
618 | if (res == 0):
619 | return 'ok', 200
620 | else:
621 | return 'fail', 404
622 |
623 |
624 | @app.route('/overrides/list/')
625 | @models.session
626 | @utils.requires_auth
627 | def overrides_list(path):
628 | if not request.user.can_manage:
629 | return abort(403)
630 |
631 | separator = "/"
632 | errors = []
633 | errors = errors + session.pop('errors', [])
634 | context = {}
635 |
636 | repo_path, context['overrides_path'] = file_utils.split_url_path(path)
637 | context['repo'] = get_target_for(repo_path)
638 | context['list_path'] = url_for('overrides_list', path = path)
639 | context['edit_path'] = url_for('overrides_edit', path = path)
640 | context['delete_path'] = url_for('overrides_delete', path = path)
641 | context['download_path'] = url_for('overrides_download', path = path)
642 | context['upload_path'] = url_for('overrides_upload', path = path)
643 | if context['overrides_path'] != '' and context['overrides_path'] != None:
644 | context['parent_name'] = separator.join(context['overrides_path'].split(separator)[:-1])
645 | context['parent_path'] = url_for('overrides_list', path = separator.join(path.split(separator)[:-1]))
646 |
647 | if not isinstance(context['repo'], Repository):
648 | return abort(404)
649 |
650 | try:
651 | cwd = os.path.join(utils.get_override_path(context['repo']), context['overrides_path'].replace('/', os.sep))
652 | utils.makedirs(os.path.dirname(cwd))
653 | context['files'] = file_utils.list_folder(cwd)
654 | except BaseException as exc:
655 | app.logger.info(exc)
656 | errors.append('Could not read overrides for this repository.')
657 |
658 | return render_template('overrides_list.html', user=request.user, **context, errors=errors)
659 |
660 |
661 | @app.route('/overrides/edit/', methods=['GET', 'POST'])
662 | @models.session
663 | @utils.requires_auth
664 | def overrides_edit(path):
665 | if not request.user.can_manage:
666 | return abort(403)
667 |
668 | separator = "/"
669 | context = {}
670 | errors = []
671 |
672 | repo_path, context['overrides_path'] = file_utils.split_url_path(path)
673 | context['repo'] = get_target_for(repo_path)
674 | if not isinstance(context['repo'], Repository):
675 | return abort(404)
676 |
677 | file_path = os.path.join(utils.get_override_path(context['repo']), context['overrides_path'].replace('/', os.sep))
678 |
679 | if request.method == 'POST':
680 | override_content = request.form.get('override_content')
681 | try:
682 | file_utils.write_file(file_path, override_content)
683 | utils.flash('Changes in file was saved.')
684 | except BaseException as exc:
685 | app.logger.info(exc)
686 | errors.append('Could not write into file.')
687 |
688 | dir_path_parts = path.split("/")
689 | context['dir_path'] = separator.join(dir_path_parts[:-1])
690 |
691 | try:
692 | context['content'] = file_utils.read_file(file_path)
693 | except BaseException as exc:
694 | app.logger.info(exc)
695 | errors.append('Could not read file.')
696 |
697 | return render_template('overrides_edit.html', user=request.user, **context, errors=errors)
698 |
699 |
700 | OVERRIDES_ACTION_CREATEFOLDER = 'createNewFolder'
701 | OVERRIDES_ACTION_CREATEFILE = 'createNewFile'
702 | OVERRIDES_ACTION_RENAME = 'rename'
703 |
704 |
705 | @app.route('/overrides/delete/')
706 | @models.session
707 | @utils.requires_auth
708 | def overrides_delete(path):
709 | if not request.user.can_manage:
710 | return abort(403)
711 |
712 | separator = "/"
713 |
714 | repo_path, overrides_path = file_utils.split_url_path(path)
715 | repo = get_target_for(repo_path)
716 | if not isinstance(repo, Repository):
717 | return abort(404)
718 |
719 | return_path_parts = path.split(separator)
720 | return_path = separator.join(return_path_parts[:-1])
721 | cwd = os.path.join(utils.get_override_path(repo), overrides_path.replace('/', os.sep))
722 |
723 | session['errors'] = []
724 | try:
725 | file_utils.delete(cwd)
726 | utils.flash('Object was deleted.')
727 | except BaseException as exc:
728 | app.logger.info(exc)
729 | session['errors'].append('Could not delete \'' + return_path_parts[-1] + '\'.')
730 |
731 | return redirect(url_for('overrides_list', path = return_path))
732 |
733 |
734 | @app.route('/overrides/download/')
735 | @models.session
736 | @utils.requires_auth
737 | def overrides_download(path):
738 | if not request.user.can_manage:
739 | return abort(403)
740 |
741 | repo_path, overrides_path = file_utils.split_url_path(path)
742 | repo = get_target_for(repo_path)
743 | if not isinstance(repo, Repository):
744 | return abort(404)
745 |
746 | file_path = os.path.join(utils.get_override_path(repo), overrides_path.replace('/', os.sep))
747 | return utils.stream_file(file_path, mime='application/octet-stream')
748 |
749 |
750 | @app.route('/overrides/upload/', methods=['GET', 'POST'])
751 | @models.session
752 | @utils.requires_auth
753 | def overrides_upload(path):
754 | if not request.user.can_manage:
755 | return abort(403)
756 |
757 | separator = "/"
758 | context = {}
759 | errors = [] + session.pop('errors', [])
760 |
761 | repo_path, context['overrides_path'] = file_utils.split_url_path(path)
762 | repo = get_target_for(repo_path)
763 | if not isinstance(repo, Repository):
764 | return abort(404)
765 |
766 | context['list_path'] = url_for('overrides_list', path = path)
767 | cwd = os.path.join(utils.get_override_path(repo), context['overrides_path'].replace('/', os.sep))
768 |
769 | if request.method == 'POST':
770 | session['errors'] = []
771 | files = request.files.getlist('upload_file')
772 | if not files:
773 | utils.flash('No file was uploaded.')
774 | else:
775 | file_uploads = []
776 | for file in files:
777 | filepath = os.path.join(cwd, secure_filename(file.filename))
778 | try:
779 | file.save(filepath)
780 | file_uploads.append("File '{}' was uploaded.".format(file.filename))
781 | except BaseException as exc:
782 | app.logger.info(exc)
783 | session['errors'].append("Could not upload '{}'.".format(file.filename))
784 | utils.flash(" ".join(file_uploads))
785 | if not session['errors']:
786 | return redirect(url_for('overrides_list', path = path))
787 |
788 | dir_path_parts = path.split("/")
789 | context['dir_path'] = separator.join(dir_path_parts[:-1])
790 |
791 | return render_template('overrides_upload.html', user=request.user, **context, errors=errors)
792 |
793 |
794 | @app.route('/overrides/')
795 | @models.session
796 | @utils.requires_auth
797 | def overrides_actions(action):
798 | if not request.user.can_manage:
799 | return abort(403)
800 |
801 | separator = "/"
802 | session['errors'] = []
803 | repo_id = request.args.get('repo_id', '')
804 | path = request.args.get('path', '')
805 |
806 | repo = Repository.get(id=repo_id)
807 | if not repo:
808 | return abort(404)
809 |
810 | if action == OVERRIDES_ACTION_CREATEFOLDER:
811 | name = secure_filename(request.args.get('name', ''))
812 | try:
813 | file_utils.create_folder(os.path.join(utils.get_override_path(repo), path.replace('/', os.sep)), name)
814 | utils.flash('Folder was created.')
815 | return redirect(url_for('overrides_list', path = separator.join([repo.name, path, name]).replace('//', '/')))
816 | except BaseException as exc:
817 | app.logger.info(exc)
818 | session['errors'].append('Could not create folder.')
819 | return redirect(url_for('overrides_list', path = separator.join([repo.name, path]).replace('//', '/')))
820 | elif action == OVERRIDES_ACTION_CREATEFILE:
821 | name = secure_filename(request.args.get('name', ''))
822 | try:
823 | file_utils.create_file(os.path.join(utils.get_override_path(repo), path.replace('/', os.sep)), name)
824 | utils.flash('File was created.')
825 | return redirect(url_for('overrides_edit', path = separator.join([repo.name, path, name]).replace('//', '/')))
826 | except BaseException as exc:
827 | app.logger.info(exc)
828 | session['errors'].append('Could not create file.')
829 | return redirect(url_for('overrides_list', path = separator.join([repo.name, path]).replace('//', '/')))
830 | elif action == OVERRIDES_ACTION_RENAME:
831 | name = request.args.get('name', '').replace('../', '')
832 | original_name = request.args.get('original_name', '').replace('../', '')
833 | new_path = os.path.join(utils.get_override_path(repo), path.replace('/', os.sep), name)
834 | original_path = os.path.join(utils.get_override_path(repo), path.replace('/', os.sep), original_name)
835 |
836 | try:
837 | file_utils.rename(original_path, new_path)
838 | utils.flash('Object was renamed.')
839 | except BaseException as exc:
840 | app.logger.info(exc)
841 | session['errors'].append('Could not rename \'' + original_name + '\'.')
842 | return redirect(url_for('overrides_list', path = separator.join([repo.name, path]).replace('//', '/')))
843 |
844 | return abort(404)
845 |
846 |
847 | @app.errorhandler(403)
848 | def error_403(e):
849 | return render_template('403.html'), 403
850 |
851 |
852 | @app.errorhandler(404)
853 | def error_404(e):
854 | return render_template('404.html'), 404
855 |
856 |
857 | @app.errorhandler(500)
858 | def error_500(e):
859 | return render_template('500.html'), 500
860 |
--------------------------------------------------------------------------------