pygitweb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pygitweb/__init__.py +22 -0
- pygitweb/__meta__.py +9 -0
- pygitweb/actions.py +1303 -0
- pygitweb/addproject_permissions_test.py +114 -0
- pygitweb/api/__init__.py +0 -0
- pygitweb/api/action.py +20 -0
- pygitweb/api/plugins/__init__.py +0 -0
- pygitweb/api/plugins/project_zip.py +71 -0
- pygitweb/api/project.py +67 -0
- pygitweb/api/subpage.py +22 -0
- pygitweb/auth.py +387 -0
- pygitweb/auth.schema.json +106 -0
- pygitweb/auth_config.py +243 -0
- pygitweb/auth_oauth.py +343 -0
- pygitweb/auth_test.py +390 -0
- pygitweb/boards_permissions_test.py +113 -0
- pygitweb/change_queue.py +110 -0
- pygitweb/change_queue_routes_test.py +146 -0
- pygitweb/change_queue_test.py +91 -0
- pygitweb/comments_permissions_test.py +140 -0
- pygitweb/config.py +185 -0
- pygitweb/conftest.py +43 -0
- pygitweb/dependencies.py +87 -0
- pygitweb/formatting.py +182 -0
- pygitweb/formatting_test.py +68 -0
- pygitweb/git_helpers.py +454 -0
- pygitweb/git_helpers_test.py +223 -0
- pygitweb/gravatar.py +16 -0
- pygitweb/gravatar_test.py +10 -0
- pygitweb/hooks_install.py +278 -0
- pygitweb/hooks_install_test.py +363 -0
- pygitweb/hooks_permissions_test.py +116 -0
- pygitweb/main.py +887 -0
- pygitweb/merge_requests.py +230 -0
- pygitweb/merge_requests_test.py +85 -0
- pygitweb/mr_permissions_test.py +148 -0
- pygitweb/password_hash.py +74 -0
- pygitweb/password_hash_test.py +20 -0
- pygitweb/permissions.py +209 -0
- pygitweb/permissions_test.py +86 -0
- pygitweb/plugin_loader.py +83 -0
- pygitweb/projects.py +213 -0
- pygitweb/projects_test.py +186 -0
- pygitweb/sessions.py +73 -0
- pygitweb/settings.py +412 -0
- pygitweb/settings.schema.json +73 -0
- pygitweb/settings_config.py +122 -0
- pygitweb/settings_config_test.py +101 -0
- pygitweb/settings_permissions_test.py +76 -0
- pygitweb/static/blob-view.js +133 -0
- pygitweb/static/board.js +839 -0
- pygitweb/static/hook-install.js +53 -0
- pygitweb/static/icons/cloud-down.svg +1 -0
- pygitweb/static/icons/cloud-exclamation.svg +1 -0
- pygitweb/static/icons/cloud-filled.svg +1 -0
- pygitweb/static/icons/cloud-storm.svg +1 -0
- pygitweb/static/icons/cloud-up.svg +1 -0
- pygitweb/static/icons/cloud.svg +1 -0
- pygitweb/static/logo.svg +44 -0
- pygitweb/static/main.css +1210 -0
- pygitweb/static/navbar-auth.js +82 -0
- pygitweb/static/readme-render.js +174 -0
- pygitweb/static/search-page.js +139 -0
- pygitweb/static/summary-ref-switcher.js +130 -0
- pygitweb/static/themes/solarized.css +34 -0
- pygitweb/tasks.py +595 -0
- pygitweb/tasks_permissions_test.py +137 -0
- pygitweb/tasks_routes_test.py +430 -0
- pygitweb/templates/addproject.html +174 -0
- pygitweb/templates/blobdiff.html +57 -0
- pygitweb/templates/board.html +31 -0
- pygitweb/templates/board_card.html +44 -0
- pygitweb/templates/commitdiff.html +57 -0
- pygitweb/templates/hooks_summary_cell.html +37 -0
- pygitweb/templates/login.html +37 -0
- pygitweb/templates/merge_request_summary_cell.html +18 -0
- pygitweb/templates/merge_request_tag.html +90 -0
- pygitweb/templates/pagination.html +44 -0
- pygitweb/templates/preamble.html +89 -0
- pygitweb/templates/project_settings.html +13 -0
- pygitweb/templates/pygit2_settings.html +12 -0
- pygitweb/templates/pygitweb_settings.html +12 -0
- pygitweb/templates/search.html +77 -0
- pygitweb/templates/settings_form_field.html +27 -0
- pygitweb/templates/table.html +15 -0
- pygitweb/templates/user_profile.html +10 -0
- pygitweb/templates_env.py +21 -0
- pygitweb/timeline_cache.py +243 -0
- pygitweb/validation.py +75 -0
- pygitweb/validation_test.py +167 -0
- pygitweb-0.1.0.dist-info/METADATA +165 -0
- pygitweb-0.1.0.dist-info/RECORD +95 -0
- pygitweb-0.1.0.dist-info/WHEEL +5 -0
- pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
- pygitweb-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<!DOCTYPE html><html><head><title>{{title | default('PyGitWeb')}}</title>
|
|
2
|
+
<script>document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e){ document.documentElement.setAttribute('data-bs-theme', e.matches ? 'dark' : 'light'); });</script>
|
|
3
|
+
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
|
4
|
+
<script id="Highlight-script" async src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
5
|
+
<script id="MathJax-script" async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-chtml.min.js"></script>
|
|
6
|
+
<script id="GraphViz-script" async src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/1.8.2/viz.js"></script>
|
|
7
|
+
<script id="diff2html-script" async src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js"></script>
|
|
8
|
+
<script id="Showdown-script" async src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.js"></script>
|
|
9
|
+
<script id="tabler-script" async src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/js/tabler.min.js"></script>
|
|
10
|
+
<link href=" https://cdn.jsdelivr.net/npm/diff2html@3.4.56/bundles/css/diff2html.min.css " rel="stylesheet">
|
|
11
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
12
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
13
|
+
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fira+Mono:wght@400;500;700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
|
14
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/css/tabler.min.css">
|
|
15
|
+
<link rel="stylesheet" href="/static/main.css"/>
|
|
16
|
+
<script src="/static/navbar-auth.js" defer></script>
|
|
17
|
+
</head><body>
|
|
18
|
+
<div class="page">
|
|
19
|
+
<aside class="navbar navbar-vertical navbar-expand-sm position-fixed">
|
|
20
|
+
<div class="container-fluid d-flex flex-column vh-100 overflow-y-auto">
|
|
21
|
+
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
|
22
|
+
<span class="navbar-toggler-icon"></span>
|
|
23
|
+
</button>
|
|
24
|
+
<h1 class="navbar-brand navbar-brand-autodark py-3">
|
|
25
|
+
<a href="/" class="d-flex align-items-center text-reset">
|
|
26
|
+
<img src="/static/logo.svg" width="32" height="32" alt="Logo" class="navbar-brand-image me-2" style="object-fit: contain;">
|
|
27
|
+
<span class="nav-link-title">{{ site_name | default('PyGitWeb') }}</span>
|
|
28
|
+
</a>
|
|
29
|
+
</h1>
|
|
30
|
+
<div class="collapse navbar-collapse flex-grow-1" id="sidebar-menu">
|
|
31
|
+
<ul class="navbar-nav pt-lg-3">
|
|
32
|
+
<li class="nav-item">
|
|
33
|
+
<a class="nav-link" href="/">
|
|
34
|
+
<span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-folder" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2z" /></svg></span>
|
|
35
|
+
<span class="nav-link-title">Projects</span>
|
|
36
|
+
</a>
|
|
37
|
+
</li>
|
|
38
|
+
<li class="nav-item">
|
|
39
|
+
<a class="nav-link" href="/addproject">
|
|
40
|
+
<span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-plus" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg></span>
|
|
41
|
+
<span class="nav-link-title">Add project</span>
|
|
42
|
+
</a>
|
|
43
|
+
</li>
|
|
44
|
+
<li class="nav-item">
|
|
45
|
+
<a class="nav-link" href="/activity">
|
|
46
|
+
<span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-activity"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l3 8l4 -16l3 8h4" /></svg></span>
|
|
47
|
+
<span class="nav-link-title">Activity</span>
|
|
48
|
+
</a>
|
|
49
|
+
</li>
|
|
50
|
+
<li class="nav-item">
|
|
51
|
+
<a class="nav-link" href="#">
|
|
52
|
+
<span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-messages"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M21 14l-3 -3h-7a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1h9a1 1 0 0 1 1 1v10" /><path d="M14 15v2a1 1 0 0 1 -1 1h-7l-3 3v-10a1 1 0 0 1 1 -1h2" /></svg></span>
|
|
53
|
+
<span class="nav-link-title">Discussions</span>
|
|
54
|
+
</a>
|
|
55
|
+
</li>
|
|
56
|
+
<li class="nav-item">
|
|
57
|
+
<a class="nav-link" href="#">
|
|
58
|
+
<span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-checklist" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 11l3 3l8 -8" /><path d="M20 12v6a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h9" /></svg></span>
|
|
59
|
+
<span class="nav-link-title">Tasks</span>
|
|
60
|
+
</a>
|
|
61
|
+
</li>
|
|
62
|
+
<li class="nav-item dropdown">
|
|
63
|
+
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" role="button">
|
|
64
|
+
<span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-settings" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /></svg></span>
|
|
65
|
+
<span class="nav-link-title">Settings</span>
|
|
66
|
+
</a>
|
|
67
|
+
<div class="dropdown-menu dropdown-menu-column dropdown-menu-column-1">
|
|
68
|
+
<a class="dropdown-item" href="/settings/pygitweb">PyGitWeb</a>
|
|
69
|
+
<a class="dropdown-item" href="/settings/pygit2">PyGit2</a>
|
|
70
|
+
</div>
|
|
71
|
+
</li>
|
|
72
|
+
</ul>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="navbar-user mt-auto py-3 px-3 border-top" id="navbar-user" data-logged-in="false">
|
|
75
|
+
<a class="d-flex align-items-center text-reset text-decoration-none w-100" id="navbar-user-link" href="/login" aria-label="Account">
|
|
76
|
+
<span class="avatar avatar-sm bg-secondary me-2 d-flex align-items-center justify-content-center" id="navbar-user-avatar">
|
|
77
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-user" width="22" height="22" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
|
|
78
|
+
</span>
|
|
79
|
+
<div class="d-none d-xl-block text-start">
|
|
80
|
+
<div class="navbar-user-name text-body">Guest</div>
|
|
81
|
+
<div class="navbar-user-meta text-muted small">Not signed in</div>
|
|
82
|
+
</div>
|
|
83
|
+
</a>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</aside>
|
|
87
|
+
<div class="page-wrapper">
|
|
88
|
+
<div class="page-body">
|
|
89
|
+
<div class="container-xl">
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<h1 class="page-title">Project settings: {{ project_title }}</h1>
|
|
2
|
+
<p class="text-muted">Repository config (local and worktree only).</p>
|
|
3
|
+
<div class="card">
|
|
4
|
+
<div class="card-body">
|
|
5
|
+
<form action="{{ submit_url }}" method="post" class="needs-validation" novalidate>
|
|
6
|
+
{{ table_html | safe }}
|
|
7
|
+
<div class="form-footer mt-3">
|
|
8
|
+
<button type="submit" class="btn btn-primary">Save</button>
|
|
9
|
+
<a href="{{ cancel_url }}" class="btn btn-ghost-secondary">Cancel</a>
|
|
10
|
+
</div>
|
|
11
|
+
</form>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<h1 class="page-title">PyGit2 settings</h1>
|
|
2
|
+
<div class="card">
|
|
3
|
+
<div class="card-body">
|
|
4
|
+
<form action="/settings/pygit2/submit" method="post" class="needs-validation" novalidate>
|
|
5
|
+
{{ form_body | safe }}
|
|
6
|
+
<div class="form-footer">
|
|
7
|
+
<button type="submit" class="btn btn-primary">Save</button>
|
|
8
|
+
<a href="/" class="btn btn-ghost-secondary">Cancel</a>
|
|
9
|
+
</div>
|
|
10
|
+
</form>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<h1 class="page-title">PyGitWeb settings</h1>
|
|
2
|
+
<div class="card">
|
|
3
|
+
<div class="card-body">
|
|
4
|
+
<form action="/settings/pygitweb/submit" method="post" class="needs-validation" novalidate>
|
|
5
|
+
{{ form_body | safe }}
|
|
6
|
+
<div class="form-footer">
|
|
7
|
+
<button type="submit" class="btn btn-primary">Save</button>
|
|
8
|
+
<a href="/" class="btn btn-ghost-secondary">Cancel</a>
|
|
9
|
+
</div>
|
|
10
|
+
</form>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{# Search UI page. Expects: project, project_enc, project_url, search_api_url. #}
|
|
2
|
+
|
|
3
|
+
<h1 class="page-title d-flex align-items-center gap-2">
|
|
4
|
+
<a href="{{ project_url }}" class="text-reset">{{ project }}</a>
|
|
5
|
+
<span class="text-muted">/</span>
|
|
6
|
+
<span>search</span>
|
|
7
|
+
</h1>
|
|
8
|
+
|
|
9
|
+
<div class="card mb-3">
|
|
10
|
+
<div class="card-body">
|
|
11
|
+
<form id="search-form" class="search-container" autocomplete="off" onsubmit="return false;">
|
|
12
|
+
<input
|
|
13
|
+
id="search-patterns"
|
|
14
|
+
class="form-control search"
|
|
15
|
+
type="text"
|
|
16
|
+
spellcheck="false"
|
|
17
|
+
autocomplete="off"
|
|
18
|
+
placeholder="Search regex (e.g. TODO|FIXME)..."
|
|
19
|
+
aria-label="Search pattern"
|
|
20
|
+
>
|
|
21
|
+
|
|
22
|
+
<details id="search-advanced" class="mt-2">
|
|
23
|
+
<summary>Advanced options</summary>
|
|
24
|
+
<div class="row g-2 mt-1">
|
|
25
|
+
<div class="col-md-6">
|
|
26
|
+
<label class="form-label" for="search-paths">Paths (relative, comma separated)</label>
|
|
27
|
+
<input id="search-paths" class="form-control" type="text" placeholder="src,docs">
|
|
28
|
+
<div class="form-hint">Restricted to the project tree.</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="col-md-6">
|
|
31
|
+
<label class="form-label" for="search-globs">Globs (comma separated)</label>
|
|
32
|
+
<input id="search-globs" class="form-control" type="text" placeholder="*.py,*.md,!**/vendor/**">
|
|
33
|
+
</div>
|
|
34
|
+
<div class="col-md-3">
|
|
35
|
+
<label class="form-label" for="search-sort">Sort</label>
|
|
36
|
+
<select id="search-sort" class="form-select">
|
|
37
|
+
<option value="">(none)</option>
|
|
38
|
+
<option value="Path">Path</option>
|
|
39
|
+
<option value="-Path">Path (reverse)</option>
|
|
40
|
+
<option value="LastModified">Last modified</option>
|
|
41
|
+
<option value="-LastModified">Last modified (reverse)</option>
|
|
42
|
+
<option value="LastAccessed">Last accessed</option>
|
|
43
|
+
<option value="-LastAccessed">Last accessed (reverse)</option>
|
|
44
|
+
<option value="Created">Created</option>
|
|
45
|
+
<option value="-Created">Created (reverse)</option>
|
|
46
|
+
</select>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="col-md-3">
|
|
49
|
+
<label class="form-label" for="search-max-count">Max matches per file</label>
|
|
50
|
+
<input id="search-max-count" class="form-control" type="number" min="1" value="25">
|
|
51
|
+
</div>
|
|
52
|
+
<div class="col-md-3 align-self-end">
|
|
53
|
+
<label class="form-check">
|
|
54
|
+
<input id="search-heading" class="form-check-input" type="checkbox" checked>
|
|
55
|
+
<span class="form-check-label">Heading (group by file)</span>
|
|
56
|
+
</label>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="col-md-3 align-self-end">
|
|
59
|
+
<label class="form-check">
|
|
60
|
+
<input id="search-multiline" class="form-check-input" type="checkbox">
|
|
61
|
+
<span class="form-check-label">Multiline</span>
|
|
62
|
+
</label>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</details>
|
|
66
|
+
</form>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div id="search-status" class="search-status text-muted mb-2" aria-live="polite"></div>
|
|
71
|
+
<div id="search-results" class="search-results" role="region" aria-label="Search results"></div>
|
|
72
|
+
|
|
73
|
+
<script>
|
|
74
|
+
window.SEARCH_PROJECT = {{ project | tojson }};
|
|
75
|
+
window.SEARCH_API_URL = {{ search_api_url | tojson }};
|
|
76
|
+
</script>
|
|
77
|
+
<script src="/static/search-page.js"></script>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{% if kind == "bool" %}
|
|
2
|
+
<div class="mb-3">
|
|
3
|
+
<div class="form-check">
|
|
4
|
+
<input type="checkbox" class="form-check-input" id="{{ safe_name }}" name="{{ field_name }}" value="on"{% if checked %} checked{% endif %}>
|
|
5
|
+
<label class="form-check-label" for="{{ safe_name }}">{{ name }}</label>
|
|
6
|
+
</div>
|
|
7
|
+
<small class="form-hint text-muted">{{ hint_text }}</small>
|
|
8
|
+
</div>
|
|
9
|
+
{% elif kind == "number" %}
|
|
10
|
+
<div class="mb-3">
|
|
11
|
+
<label class="form-label" for="{{ safe_name }}">{{ name }}</label>
|
|
12
|
+
<input type="number" {% if number_step %}step="{{ number_step }}" {% endif %}class="form-control" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val }}">
|
|
13
|
+
<small class="form-hint text-muted">{{ hint_text }}</small>
|
|
14
|
+
</div>
|
|
15
|
+
{% elif kind == "readonly" %}
|
|
16
|
+
<div class="mb-3">
|
|
17
|
+
<label class="form-label" for="{{ safe_name }}">{{ name }}</label>
|
|
18
|
+
<input type="text" class="form-control bg-secondary" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val }}" readonly>
|
|
19
|
+
<small class="form-hint text-muted">{{ hint_text }}</small>
|
|
20
|
+
</div>
|
|
21
|
+
{% else %}
|
|
22
|
+
<div class="mb-3">
|
|
23
|
+
<label class="form-label" for="{{ safe_name }}">{{ name }}</label>
|
|
24
|
+
<input type="text" class="form-control" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val }}">
|
|
25
|
+
<small class="form-hint text-muted">{{ hint_text }}</small>
|
|
26
|
+
</div>
|
|
27
|
+
{% endif %}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<table class="table table-vcenter card-table">
|
|
2
|
+
<thead>
|
|
3
|
+
<tr>{% for col in cols %}
|
|
4
|
+
<th>{{col}}</th>
|
|
5
|
+
{% endfor %}
|
|
6
|
+
</tr>
|
|
7
|
+
</thead>
|
|
8
|
+
<tbody>{% for row in rows %}
|
|
9
|
+
<tr>{% for cell in row %}
|
|
10
|
+
<td>{{ cell | safe }}</td>
|
|
11
|
+
{% endfor %}
|
|
12
|
+
</tr>
|
|
13
|
+
{% endfor %}
|
|
14
|
+
</tbody>
|
|
15
|
+
</table>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<h1 class="page-title">Account</h1>
|
|
2
|
+
<div class="card">
|
|
3
|
+
<div class="card-body">
|
|
4
|
+
<p class="mb-1"><strong>Username</strong></p>
|
|
5
|
+
<p class="text-muted mb-4">{{ username | e }}</p>
|
|
6
|
+
<p class="mb-0">
|
|
7
|
+
<a class="btn btn-outline-secondary" href="/logout?next=/">Sign out</a>
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared Jinja2 environment and preamble/postamble for pygitweb templates."""
|
|
2
|
+
|
|
3
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
4
|
+
|
|
5
|
+
env = Environment(
|
|
6
|
+
loader=PackageLoader("pygitweb", "templates"),
|
|
7
|
+
autoescape=select_autoescape(enabled_extensions=("html", "xml"), default_for_string=True),
|
|
8
|
+
trim_blocks=True,
|
|
9
|
+
lstrip_blocks=True,
|
|
10
|
+
)
|
|
11
|
+
PREAMBLE = env.get_template("preamble.html")
|
|
12
|
+
POSTAMBLE = """</div></div></div></body></html>"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_ESCAPE_TEMPLATE = env.from_string("{{ value|e }}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def jinja_escape(value: str | None) -> str | None:
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
return _ESCAPE_TEMPLATE.render(value=value)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-project timeline cache for recent repository activity.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Literal, TypedDict
|
|
10
|
+
|
|
11
|
+
import pygit2
|
|
12
|
+
|
|
13
|
+
from pygitweb.config import settings
|
|
14
|
+
from pygitweb.projects import git_get_projects_list
|
|
15
|
+
|
|
16
|
+
TIMELINE_CACHE_SIZE: int = 10
|
|
17
|
+
TIMELINE_CACHE_TOTAL_EVENTS: int = 200
|
|
18
|
+
TIMELINE_CACHE_BATCH_SIZE: int = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TimelineEvent(TypedDict):
|
|
22
|
+
timestamp: int
|
|
23
|
+
oid: str
|
|
24
|
+
kind: Literal["commit", "ref"]
|
|
25
|
+
description: str | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TimelineProjectEvent(TypedDict):
|
|
29
|
+
project: str
|
|
30
|
+
event: TimelineEvent
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PROJECT_TIMELINE_CACHE: dict[str, list[TimelineEvent]] = {}
|
|
34
|
+
_TIMELINE_CACHE_LOCK = threading.Lock()
|
|
35
|
+
_TIMELINE_WARMING: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _commit_description(commit: pygit2.Commit) -> str | None:
|
|
39
|
+
if not commit.message:
|
|
40
|
+
return None
|
|
41
|
+
first_line: str = commit.message.splitlines()[0].strip()
|
|
42
|
+
return first_line or None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _collect_commit_events(repo: pygit2.Repository) -> list[TimelineEvent]:
|
|
46
|
+
start_points: list[pygit2.Oid | str] = []
|
|
47
|
+
for ref_name in repo.references:
|
|
48
|
+
if not ref_name.startswith("refs/heads/"):
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
resolved: pygit2.Reference = repo.references[ref_name].resolve()
|
|
52
|
+
oid: pygit2.Oid | str = resolved.target
|
|
53
|
+
start_points.append(oid)
|
|
54
|
+
except (KeyError, pygit2.GitError, ValueError):
|
|
55
|
+
continue
|
|
56
|
+
if not start_points:
|
|
57
|
+
try:
|
|
58
|
+
head_ref: pygit2.Reference = repo.head
|
|
59
|
+
start_points.append(head_ref.target)
|
|
60
|
+
except (pygit2.GitError, ValueError, KeyError):
|
|
61
|
+
pass
|
|
62
|
+
if not start_points:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
walker: pygit2.Walker = repo.walk(
|
|
66
|
+
start_points[0],
|
|
67
|
+
pygit2.enums.SortMode(pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL),
|
|
68
|
+
)
|
|
69
|
+
for oid in start_points[1:]:
|
|
70
|
+
try:
|
|
71
|
+
walker.push(oid)
|
|
72
|
+
except (pygit2.GitError, ValueError):
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
events: list[TimelineEvent] = []
|
|
76
|
+
seen: set[str] = set()
|
|
77
|
+
for commit in walker:
|
|
78
|
+
commit_oid: str = str(commit.id)
|
|
79
|
+
if commit_oid in seen:
|
|
80
|
+
continue
|
|
81
|
+
seen.add(commit_oid)
|
|
82
|
+
events.append({
|
|
83
|
+
"timestamp": commit.commit_time,
|
|
84
|
+
"oid": commit_oid,
|
|
85
|
+
"kind": "commit",
|
|
86
|
+
"description": _commit_description(commit),
|
|
87
|
+
})
|
|
88
|
+
return events
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _collect_ref_events(repo: pygit2.Repository) -> list[TimelineEvent]:
|
|
92
|
+
events: list[TimelineEvent] = []
|
|
93
|
+
for ref_name in repo.references:
|
|
94
|
+
if not ref_name.startswith("refs/tags/"):
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
ref_obj: pygit2.Reference = repo.references[ref_name].resolve()
|
|
98
|
+
oid_str: str = str(ref_obj.target)
|
|
99
|
+
obj = repo[ref_obj.target]
|
|
100
|
+
except (KeyError, pygit2.GitError, ValueError):
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
if isinstance(obj, pygit2.Tag):
|
|
104
|
+
tag_tagger = obj.tagger
|
|
105
|
+
timestamp: int = tag_tagger.time if tag_tagger else 0
|
|
106
|
+
description: str | None = ref_name.replace("refs/tags/", "", 1)
|
|
107
|
+
elif isinstance(obj, pygit2.Commit):
|
|
108
|
+
timestamp = obj.commit_time
|
|
109
|
+
description = ref_name.replace("refs/tags/", "", 1)
|
|
110
|
+
else:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
events.append({
|
|
114
|
+
"timestamp": timestamp,
|
|
115
|
+
"oid": oid_str,
|
|
116
|
+
"kind": "ref",
|
|
117
|
+
"description": description or None,
|
|
118
|
+
})
|
|
119
|
+
return events
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_project_timeline_cache(project: str, max_events: int = TIMELINE_CACHE_SIZE) -> list[TimelineEvent]:
|
|
123
|
+
try:
|
|
124
|
+
repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
|
|
125
|
+
except (pygit2.GitError, OSError, ValueError):
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
commit_events: list[TimelineEvent] = _collect_commit_events(repo)
|
|
129
|
+
ref_events: list[TimelineEvent] = _collect_ref_events(repo)
|
|
130
|
+
all_events: list[TimelineEvent] = commit_events + ref_events
|
|
131
|
+
all_events.sort(key=lambda event: (event["timestamp"], event["oid"]), reverse=True)
|
|
132
|
+
return all_events[:max_events]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def warm_timeline_cache() -> None:
|
|
136
|
+
global PROJECT_TIMELINE_CACHE, _TIMELINE_WARMING
|
|
137
|
+
with _TIMELINE_CACHE_LOCK:
|
|
138
|
+
_TIMELINE_WARMING = True
|
|
139
|
+
try:
|
|
140
|
+
new_cache: dict[str, list[TimelineEvent]] = {}
|
|
141
|
+
projects = git_get_projects_list(
|
|
142
|
+
filter_path="",
|
|
143
|
+
paranoid=settings.STRICT_EXPORT,
|
|
144
|
+
export_ok=settings.EXPORT_OK,
|
|
145
|
+
)
|
|
146
|
+
project_names: list[str] = []
|
|
147
|
+
project_event_pool: dict[str, list[TimelineEvent]] = {}
|
|
148
|
+
for project_entry in projects:
|
|
149
|
+
project_name = project_entry.get("path")
|
|
150
|
+
if not isinstance(project_name, str) or not project_name:
|
|
151
|
+
continue
|
|
152
|
+
project_names.append(project_name)
|
|
153
|
+
try:
|
|
154
|
+
project_event_pool[project_name] = build_project_timeline_cache(
|
|
155
|
+
project_name,
|
|
156
|
+
max_events=TIMELINE_CACHE_TOTAL_EVENTS,
|
|
157
|
+
)
|
|
158
|
+
except (pygit2.GitError, OSError, ValueError, KeyError):
|
|
159
|
+
project_event_pool[project_name] = []
|
|
160
|
+
|
|
161
|
+
for project_name in project_names:
|
|
162
|
+
new_cache[project_name] = []
|
|
163
|
+
|
|
164
|
+
selected_events: list[TimelineProjectEvent] = []
|
|
165
|
+
next_index_by_project: dict[str, int] = {project_name: 0 for project_name in project_names}
|
|
166
|
+
|
|
167
|
+
for project_name in project_names:
|
|
168
|
+
if len(selected_events) >= TIMELINE_CACHE_TOTAL_EVENTS:
|
|
169
|
+
break
|
|
170
|
+
project_events = project_event_pool.get(project_name, [])
|
|
171
|
+
if not project_events:
|
|
172
|
+
continue
|
|
173
|
+
selected_events.append({"project": project_name, "event": project_events[0]})
|
|
174
|
+
next_index_by_project[project_name] = 1
|
|
175
|
+
|
|
176
|
+
while len(selected_events) < TIMELINE_CACHE_TOTAL_EVENTS:
|
|
177
|
+
candidate_projects: list[str] = [
|
|
178
|
+
project_name
|
|
179
|
+
for project_name in project_names
|
|
180
|
+
if next_index_by_project[project_name] < len(project_event_pool.get(project_name, []))
|
|
181
|
+
]
|
|
182
|
+
if not candidate_projects:
|
|
183
|
+
break
|
|
184
|
+
best_project: str = max(
|
|
185
|
+
candidate_projects,
|
|
186
|
+
key=lambda project_name: (
|
|
187
|
+
project_event_pool[project_name][next_index_by_project[project_name]]["timestamp"],
|
|
188
|
+
project_event_pool[project_name][next_index_by_project[project_name]]["oid"],
|
|
189
|
+
project_name,
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
start_index = next_index_by_project[best_project]
|
|
193
|
+
remaining_global = TIMELINE_CACHE_TOTAL_EVENTS - len(selected_events)
|
|
194
|
+
remaining_project = len(project_event_pool[best_project]) - start_index
|
|
195
|
+
take_count = min(TIMELINE_CACHE_BATCH_SIZE, remaining_global, remaining_project)
|
|
196
|
+
for offset in range(take_count):
|
|
197
|
+
selected_events.append({
|
|
198
|
+
"project": best_project,
|
|
199
|
+
"event": project_event_pool[best_project][start_index + offset],
|
|
200
|
+
})
|
|
201
|
+
next_index_by_project[best_project] = start_index + take_count
|
|
202
|
+
|
|
203
|
+
for item in selected_events:
|
|
204
|
+
new_cache[item["project"]].append(item["event"])
|
|
205
|
+
with _TIMELINE_CACHE_LOCK:
|
|
206
|
+
PROJECT_TIMELINE_CACHE = new_cache
|
|
207
|
+
finally:
|
|
208
|
+
with _TIMELINE_CACHE_LOCK:
|
|
209
|
+
_TIMELINE_WARMING = False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def warm_timeline_cache_async() -> bool:
|
|
213
|
+
global _TIMELINE_WARMING
|
|
214
|
+
with _TIMELINE_CACHE_LOCK:
|
|
215
|
+
if _TIMELINE_WARMING:
|
|
216
|
+
return False
|
|
217
|
+
_TIMELINE_WARMING = True
|
|
218
|
+
thread = threading.Thread(target=warm_timeline_cache, daemon=True)
|
|
219
|
+
thread.start()
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def clear_timeline_cache_async() -> bool:
|
|
224
|
+
global PROJECT_TIMELINE_CACHE
|
|
225
|
+
with _TIMELINE_CACHE_LOCK:
|
|
226
|
+
PROJECT_TIMELINE_CACHE = {}
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_all_timeline_events() -> list[TimelineProjectEvent]:
|
|
231
|
+
events: list[TimelineProjectEvent] = []
|
|
232
|
+
with _TIMELINE_CACHE_LOCK:
|
|
233
|
+
cache_snapshot = {
|
|
234
|
+
project_name: list(project_events) for project_name, project_events in PROJECT_TIMELINE_CACHE.items()
|
|
235
|
+
}
|
|
236
|
+
for project_name, project_events in cache_snapshot.items():
|
|
237
|
+
for event in project_events:
|
|
238
|
+
events.append({"project": project_name, "event": event})
|
|
239
|
+
events.sort(
|
|
240
|
+
key=lambda item: (item["event"]["timestamp"], item["event"]["oid"], item["project"]),
|
|
241
|
+
reverse=True,
|
|
242
|
+
)
|
|
243
|
+
return events
|
pygitweb/validation.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation: pathname, ref format, refname, project, action; repo discovery via pygit2.
|
|
3
|
+
Ported from gitweb/gitweb.perl (is_valid_pathname, is_valid_ref_format, is_valid_refname,
|
|
4
|
+
is_valid_project, is_valid_action). Repo check uses pygit2.discover_repository..
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
import pygit2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_valid_pathname(input_path: str | None) -> bool:
|
|
17
|
+
"""No '.', '..' as path elements, no null, no doubled slashes. Port of is_valid_pathname."""
|
|
18
|
+
if input_path is None:
|
|
19
|
+
return False
|
|
20
|
+
if "\0" in input_path:
|
|
21
|
+
return False
|
|
22
|
+
parts = input_path.strip("/").split("/")
|
|
23
|
+
return all(p not in ("", ".", "..") for p in parts)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_valid_ref_format(input_ref: str | None) -> bool:
|
|
27
|
+
"""Git-check-ref-format rules: no /., no .., no control/space/special at start/end. Port of is_valid_ref_format."""
|
|
28
|
+
if input_ref is None:
|
|
29
|
+
return False
|
|
30
|
+
if "/." in input_ref or input_ref.startswith(".") or ".." in input_ref:
|
|
31
|
+
return False
|
|
32
|
+
if input_ref.endswith("/") or input_ref.endswith(".lock"):
|
|
33
|
+
return False
|
|
34
|
+
# No ASCII control, space, ~^:?*[
|
|
35
|
+
return not re.search(r"[\x00-\x20\x7f ~^:?*\[\]]", input_ref)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_export_ok(
|
|
39
|
+
git_dir: str,
|
|
40
|
+
export_ok: str = "",
|
|
41
|
+
export_auth_hook: Callable[[str], bool] | None = None,
|
|
42
|
+
across_fs: bool = False,
|
|
43
|
+
) -> bool:
|
|
44
|
+
"""True if path is a git repo (via pygit2.discover_repository) and optional export_ok file / auth hook pass."""
|
|
45
|
+
if not os.path.isdir(git_dir):
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
discovered = pygit2.discover_repository(git_dir, across_fs)
|
|
49
|
+
if not discovered:
|
|
50
|
+
return False
|
|
51
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
52
|
+
return False
|
|
53
|
+
if export_ok and not os.path.isfile(os.path.join(git_dir, export_ok)):
|
|
54
|
+
return False
|
|
55
|
+
return not export_auth_hook or export_auth_hook(git_dir)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_valid_project(
|
|
59
|
+
project: str | None,
|
|
60
|
+
projectroot: str,
|
|
61
|
+
export_ok: str,
|
|
62
|
+
strict_export: bool,
|
|
63
|
+
project_in_list: Callable[[str], bool],
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""Pathname valid, dir exists, export_ok, and (if strict) in project list. Port of is_valid_project."""
|
|
66
|
+
if project is None:
|
|
67
|
+
return False
|
|
68
|
+
if not is_valid_pathname(project):
|
|
69
|
+
return False
|
|
70
|
+
full = os.path.join(projectroot, project)
|
|
71
|
+
if not os.path.isdir(full):
|
|
72
|
+
return False
|
|
73
|
+
if not check_export_ok(full, export_ok):
|
|
74
|
+
return False
|
|
75
|
+
return not strict_export or project_in_list(project)
|