mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-05-15 06:30:26 +02:00
multiple fixes
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+482
-1
@@ -2780,6 +2780,477 @@ code {
|
|||||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='%23f5f5f5' stroke-width='2' stroke-linecap='round' d='M4 8h22M4 15h22M4 22h22'/%3E%3C/svg%3E") !important;
|
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='%23f5f5f5' stroke-width='2' stroke-linecap='round' d='M4 8h22M4 15h22M4 22h22'/%3E%3C/svg%3E") !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Status pill — page header indicator */
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--paper-bg-alt);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper progress bar */
|
||||||
|
.paper-progress {
|
||||||
|
position: relative;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--paper-bg-alt);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: visible;
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
}
|
||||||
|
.paper-progress .paper-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
min-width: 4px;
|
||||||
|
}
|
||||||
|
.paper-progress .paper-progress-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.paper-progress .paper-progress-pct { color: var(--color); }
|
||||||
|
.paper-progress.paper-progress-ready .paper-progress-bar { background: var(--color); }
|
||||||
|
|
||||||
|
/* Status error card */
|
||||||
|
.paper-error-card {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
background: var(--paper-bg-alt);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-left: 3px solid #C53030;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--color);
|
||||||
|
}
|
||||||
|
.dark-mode .paper-error-card { border-left-color: #FF8B7B; }
|
||||||
|
.paper-error-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.paper-error-head > i {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #C53030;
|
||||||
|
margin-top: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dark-mode .paper-error-head > i { color: #FF8B7B; }
|
||||||
|
.paper-error-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.paper-error-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color);
|
||||||
|
}
|
||||||
|
.paper-error-msg {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.paper-error-hints {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.paper-error-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail grid (status page, generic) */
|
||||||
|
.paper-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 10px 20px;
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.paper-detail-grid .detail-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.paper-detail-grid .detail-value { word-break: break-all; color: var(--color); }
|
||||||
|
.paper-detail-grid .detail-value a { color: var(--color); border-bottom: 1px solid var(--border-color); }
|
||||||
|
.paper-detail-grid .detail-value a:hover { border-bottom-color: var(--color); }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.paper-detail-grid { grid-template-columns: 1fr; gap: 4px 0; }
|
||||||
|
.paper-detail-grid .detail-label { padding-top: 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Support cards (Contribute / Feedback / Sponsor) */
|
||||||
|
.paper-support-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin: 18px 0 28px;
|
||||||
|
}
|
||||||
|
.paper-support-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.paper-support-card:hover {
|
||||||
|
border-color: var(--color);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color);
|
||||||
|
}
|
||||||
|
.paper-support-card .paper-support-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.paper-support-card p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.paper-support-card .paper-support-cta {
|
||||||
|
margin-top: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.paper-support-card:hover .paper-support-cta i { transform: translateX(2px); }
|
||||||
|
.paper-support-card .paper-support-cta i { transition: transform 0.15s ease; }
|
||||||
|
|
||||||
|
/* Ko-fi embed wrapper */
|
||||||
|
.paper-kofi-wrap {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--paper-card);
|
||||||
|
}
|
||||||
|
.paper-kofi-wrap iframe {
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 650px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.paper-kofi-wrap iframe { height: 720px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Pull request page (paper) ===== */
|
||||||
|
.pr-page {
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--canvas-bg-color);
|
||||||
|
}
|
||||||
|
.pr-page-inner { padding-top: 24px; padding-bottom: 60px; }
|
||||||
|
|
||||||
|
.pr-header {
|
||||||
|
margin: 6px 0 18px;
|
||||||
|
}
|
||||||
|
.pr-title {
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
font-size: clamp(1.6rem, 3vw, 2.4rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.pr-header-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.pr-header-meta .pr-meta-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.pr-header-meta .pr-meta-item i { color: var(--ink-muted); }
|
||||||
|
|
||||||
|
.pr-body-card {
|
||||||
|
margin: 18px 0 24px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.pr-body-card .paper-section-eyebrow { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* Paper-style tabs */
|
||||||
|
.paper-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin: 18px 0 0;
|
||||||
|
}
|
||||||
|
.paper-tabs .paper-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.paper-tabs .paper-tab:hover { color: var(--color); }
|
||||||
|
.paper-tabs .paper-tab.active {
|
||||||
|
color: var(--color);
|
||||||
|
border-bottom-color: var(--color);
|
||||||
|
}
|
||||||
|
.paper-tabs .paper-tab i { color: inherit; opacity: 0.85; }
|
||||||
|
.paper-tabs .paper-tab:focus,
|
||||||
|
.paper-tabs .paper-tab:focus-visible,
|
||||||
|
.paper-tabs .paper-tab:active {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.paper-tabs .paper-tab:focus-visible {
|
||||||
|
color: var(--color);
|
||||||
|
background: var(--hover-bg-color);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Diff (file-grouped, gutter line numbers) ===== */
|
||||||
|
.pr-diff {
|
||||||
|
margin: 16px 0 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.diff-file-block {
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.diff-file-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--paper-bg-alt);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--color);
|
||||||
|
}
|
||||||
|
.diff-file-icon { color: var(--ink-muted); flex-shrink: 0; }
|
||||||
|
.diff-file-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.diff-file-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
background: var(--paper-card);
|
||||||
|
}
|
||||||
|
.diff-file-status-added { color: #2F6B3E; border-color: rgba(47,107,62,0.35); background: rgba(47,107,62,0.08); }
|
||||||
|
.diff-file-status-deleted { color: #A13A2E; border-color: rgba(161,58,46,0.35); background: rgba(161,58,46,0.08); }
|
||||||
|
.diff-file-status-renamed { color: #8A6B1E; border-color: rgba(138,107,30,0.35); background: rgba(138,107,30,0.08); }
|
||||||
|
.dark-mode .diff-file-status-added { color: #A7E2A7; border-color: rgba(167,226,167,0.35); background: rgba(167,226,167,0.08); }
|
||||||
|
.dark-mode .diff-file-status-deleted { color: #FF8B7B; border-color: rgba(255,139,123,0.35); background: rgba(255,139,123,0.08); }
|
||||||
|
.dark-mode .diff-file-status-renamed { color: #FFD37A; border-color: rgba(255,211,122,0.35); background: rgba(255,211,122,0.08); }
|
||||||
|
|
||||||
|
.diff-file-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.diff-file-table tr.diff-row { vertical-align: top; }
|
||||||
|
.diff-file-table .diff-gutter {
|
||||||
|
width: 48px;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
background: var(--paper-bg-alt);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
user-select: none;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.diff-file-table .diff-gutter-new { border-right: 1px solid var(--border-color); }
|
||||||
|
.diff-file-table .diff-sign {
|
||||||
|
width: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
background: var(--paper-bg-alt);
|
||||||
|
user-select: none;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.diff-file-table .diff-code {
|
||||||
|
padding: 1px 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hunk header row */
|
||||||
|
.diff-row-hunk td {
|
||||||
|
background: var(--paper-bg-alt) !important;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
.diff-row-hunk .diff-code { color: var(--ink-muted); }
|
||||||
|
|
||||||
|
/* Add / remove rows */
|
||||||
|
.diff-row-add .diff-code {
|
||||||
|
background: rgba(47,107,62,0.10);
|
||||||
|
color: #1F4A2A;
|
||||||
|
}
|
||||||
|
.diff-row-add .diff-sign,
|
||||||
|
.diff-row-add .diff-gutter {
|
||||||
|
background: rgba(47,107,62,0.06);
|
||||||
|
color: #2F6B3E;
|
||||||
|
}
|
||||||
|
.diff-row-remove .diff-code {
|
||||||
|
background: rgba(161,58,46,0.10);
|
||||||
|
color: #6E1F1A;
|
||||||
|
}
|
||||||
|
.diff-row-remove .diff-sign,
|
||||||
|
.diff-row-remove .diff-gutter {
|
||||||
|
background: rgba(161,58,46,0.06);
|
||||||
|
color: #A13A2E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .diff-row-add .diff-code { background: rgba(167,226,167,0.10); color: #C9F0C9; }
|
||||||
|
.dark-mode .diff-row-add .diff-sign,
|
||||||
|
.dark-mode .diff-row-add .diff-gutter { background: rgba(167,226,167,0.06); color: #A7E2A7; }
|
||||||
|
.dark-mode .diff-row-remove .diff-code { background: rgba(255,139,123,0.10); color: #FFC9C0; }
|
||||||
|
.dark-mode .diff-row-remove .diff-sign,
|
||||||
|
.dark-mode .diff-row-remove .diff-gutter { background: rgba(255,139,123,0.06); color: #FF8B7B; }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.diff-file-table { font-size: 11.5px; }
|
||||||
|
.diff-file-table .diff-gutter { width: 36px; padding: 0 6px; }
|
||||||
|
.diff-file-table .diff-sign { width: 14px; padding: 0 4px; }
|
||||||
|
.diff-file-table .diff-code { padding: 1px 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments */
|
||||||
|
.pr-comments {
|
||||||
|
list-style: none;
|
||||||
|
margin: 16px 0 28px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pr-comment {
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: var(--paper-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.pr-comment-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.pr-comment-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color);
|
||||||
|
font-size: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.pr-comment-author i { color: var(--ink-muted); font-size: 12px; }
|
||||||
|
.pr-comment-date {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.pr-comment-body { color: var(--color); font-size: 14px; line-height: 1.6; }
|
||||||
|
.pr-comment-body :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.pr-page-inner { padding-top: 14px; padding-bottom: 40px; }
|
||||||
|
.pr-body-card { padding: 14px 14px; border-radius: 8px; }
|
||||||
|
.pr-comment { padding: 12px 14px; }
|
||||||
|
.paper-tabs .paper-tab { padding: 10px 12px; font-size: 13px; }
|
||||||
|
.pr-diff pre { font-size: 11.5px; padding: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Toasts — paper style, dark-mode aware */
|
/* Toasts — paper style, dark-mode aware */
|
||||||
.toast {
|
.toast {
|
||||||
background-color: var(--paper-card) !important;
|
background-color: var(--paper-card) !important;
|
||||||
@@ -3327,7 +3798,9 @@ code {
|
|||||||
.paper-table .cell-anon .anon-sub a { color: var(--ink-muted); border-bottom: 1px dotted var(--border-color); }
|
.paper-table .cell-anon .anon-sub a { color: var(--ink-muted); border-bottom: 1px dotted var(--border-color); }
|
||||||
.paper-table .cell-anon .anon-sub a:hover { color: var(--color); }
|
.paper-table .cell-anon .anon-sub a:hover { color: var(--color); }
|
||||||
.paper-table .cell-conf { font-family: var(--font-mono); font-size: 13px; color: var(--color); }
|
.paper-table .cell-conf { font-family: var(--font-mono); font-size: 13px; color: var(--color); }
|
||||||
.paper-table .cell-status { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--color); }
|
.paper-table .cell-status { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; font-size: 14px; color: var(--color); }
|
||||||
|
.paper-table .cell-status .status-line { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.paper-table .cell-status .status-sub { flex-basis: 100%; font-size: 11px; line-height: 1.2; color: var(--ink-muted); }
|
||||||
.paper-table .cell-views { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--color); }
|
.paper-table .cell-views { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--color); }
|
||||||
.paper-table .cell-expires { font-size: 13px; color: var(--ink-soft); }
|
.paper-table .cell-expires { font-size: 13px; color: var(--ink-soft); }
|
||||||
.paper-table .empty-dash { color: var(--ink-muted); opacity: 0.5; }
|
.paper-table .empty-dash { color: var(--ink-muted); opacity: 0.5; }
|
||||||
@@ -3720,4 +4193,12 @@ code {
|
|||||||
.paper-footer-inner {
|
.paper-footer-inner {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.file.folder.truncated > .truncated-warning {
|
||||||
|
color: #d39e00;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.file.folder.truncated > a {
|
||||||
|
color: #d39e00;
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"user_not_found": "The requested user could not be found.",
|
"user_not_found": "The requested user could not be found.",
|
||||||
"repo_access_limited": "Access to repository limited by org.",
|
"repo_access_limited": "Access to repository limited by org.",
|
||||||
"repo_not_found": "The repository is not found.",
|
"repo_not_found": "The repository is not found.",
|
||||||
|
"repo_empty": "The source repository is empty on GitHub.",
|
||||||
"repo_not_accessible": "Anonymous GitHub is unable to or is forbidden to access the repository.",
|
"repo_not_accessible": "Anonymous GitHub is unable to or is forbidden to access the repository.",
|
||||||
"repository_expired": "The repository is expired.",
|
"repository_expired": "The repository is expired.",
|
||||||
"repository_not_ready": "Anonymous GitHub is still processing the repository, it can take several minutes.",
|
"repository_not_ready": "Anonymous GitHub is still processing the repository, it can take several minutes.",
|
||||||
@@ -56,8 +57,8 @@
|
|||||||
"stats_unsupported": "Statistics are only supported in download mode.",
|
"stats_unsupported": "Statistics are only supported in download mode.",
|
||||||
"branches_not_found": "The requested branch is not found.",
|
"branches_not_found": "The requested branch is not found.",
|
||||||
"readme_not_available": "No README for the repository is found.",
|
"readme_not_available": "No README for the repository is found.",
|
||||||
"page_not_supported_on_different_branch": "Anonymized GitHub pages are only supported on the same branch.",
|
"page_not_supported_on_different_branch": "GitHub Pages is served from a different branch than the one selected. Pick the branch that GitHub Pages is configured to use.",
|
||||||
"page_not_activated": "Anonymized GitHub page is not enabled.",
|
"page_not_activated": "GitHub Pages is not enabled on this repository. Enable it in the repository settings on GitHub before anonymizing.",
|
||||||
"is_removed": "This resource has been removed and is no longer available.",
|
"is_removed": "This resource has been removed and is no longer available.",
|
||||||
"conf_name_missing": "A conference name is required.",
|
"conf_name_missing": "A conference name is required.",
|
||||||
"conf_id_missing": "A conference ID is required.",
|
"conf_id_missing": "A conference ID is required.",
|
||||||
@@ -80,5 +81,11 @@
|
|||||||
"queue_not_found": "The specified queue could not be found.",
|
"queue_not_found": "The specified queue could not be found.",
|
||||||
"job_not_found": "The specified job could not be found in the queue.",
|
"job_not_found": "The specified job could not be found in the queue.",
|
||||||
"error_retrying_job": "An error occurred while retrying the job."
|
"error_retrying_job": "An error occurred while retrying the job."
|
||||||
|
},
|
||||||
|
"WARNINGS": {
|
||||||
|
"page_not_enabled_on_repo": "GitHub Pages is not enabled on this repository. Enable it in the repository's Settings → Pages on GitHub, then refresh.",
|
||||||
|
"page_branch_mismatch": "GitHub Pages on this repository is served from the '{{pageBranch}}' branch, but you selected '{{selectedBranch}}'. Switch the branch above to '{{pageBranch}}' to anonymize the Pages site.",
|
||||||
|
"folder_truncated": "This folder has more than 10,000 entries; only a partial listing is shown.",
|
||||||
|
"repo_truncated": "Some folders in this repository have too many files to be fully listed. Affected folders are marked with a warning icon."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-section-header" ng-if="userInfo && userInfo.isAdmin && user && user.username == userInfo.username">
|
||||||
|
<h2><i class="fas fa-key"></i> API tokens</h2>
|
||||||
|
<span class="section-count">{{tokens.length}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-if="userInfo && userInfo.isAdmin && user && user.username == userInfo.username" class="user-detail-card">
|
||||||
|
<p class="paper-page-lede">Personal API tokens for this admin account. Send as <code>Authorization: Bearer <token></code> to authenticate without GitHub OAuth (useful for development).</p>
|
||||||
|
|
||||||
|
<form ng-submit="createToken()" class="d-flex" style="gap: 8px; margin-bottom: 12px;">
|
||||||
|
<input type="text" class="form-control" ng-model="newTokenName" placeholder="Token name (e.g. dev-laptop)" required />
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-plus"></i> Generate</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div ng-if="newTokenPlaintext" class="alert alert-warning" role="alert">
|
||||||
|
<strong>Copy this token now — it will not be shown again:</strong>
|
||||||
|
<pre style="white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; font-family: var(--font-mono); font-size: 0.85rem;">{{newTokenPlaintext}}</pre>
|
||||||
|
<button class="btn btn-sm" ng-click="newTokenPlaintext = null">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paper-table w-100" ng-if="tokens.length">
|
||||||
|
<div class="paper-table-head" role="row" style="grid-template-columns: 1fr 200px 200px 80px;">
|
||||||
|
<div role="columnheader">Name</div>
|
||||||
|
<div role="columnheader">Created</div>
|
||||||
|
<div role="columnheader">Last used</div>
|
||||||
|
<div role="columnheader" aria-label="Actions"></div>
|
||||||
|
</div>
|
||||||
|
<div class="paper-table-row" role="row" ng-repeat="t in tokens" style="grid-template-columns: 1fr 200px 200px 80px;">
|
||||||
|
<div role="cell" ng-bind="t.name"></div>
|
||||||
|
<div role="cell" ng-bind="t.createdAt | humanTime"></div>
|
||||||
|
<div role="cell"><span ng-if="t.lastUsedAt">{{t.lastUsedAt | humanTime}}</span><span ng-if="!t.lastUsedAt" class="text-muted">never</span></div>
|
||||||
|
<div role="cell">
|
||||||
|
<button class="btn btn-sm text-danger" ng-click="revokeToken(t)" title="Revoke"><i class="fas fa-trash-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="paper-table-empty" ng-if="!tokens.length">
|
||||||
|
<i class="fas fa-inbox"></i>
|
||||||
|
<span>No tokens yet.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="admin-section-header">
|
<div class="admin-section-header">
|
||||||
<h2><i class="fas fa-code-branch"></i> Anonymized repositories</h2>
|
<h2><i class="fas fa-code-branch"></i> Anonymized repositories</h2>
|
||||||
<span class="section-count">{{repositories.length}}</span>
|
<span class="section-count">{{repositories.length}}</span>
|
||||||
|
|||||||
@@ -175,8 +175,14 @@
|
|||||||
<label class="form-check-label" for="notebook">Display Notebooks</label>
|
<label class="form-check-label" for="notebook">Display Notebooks</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.page" ng-disabled="!details.hasPage" />
|
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.page" ng-disabled="!details.hasPage || (details.pageSource && details.pageSource.branch !== source.branch)" />
|
||||||
<label class="form-check-label" for="page">GitHub Pages</label>
|
<label class="form-check-label" for="page">GitHub Pages</label>
|
||||||
|
<small class="form-text text-muted d-block" ng-show="!details.hasPage">
|
||||||
|
{{ 'WARNINGS.page_not_enabled_on_repo' | translate }}
|
||||||
|
</small>
|
||||||
|
<small class="form-text text-muted d-block" ng-show="details.hasPage && details.pageSource && details.pageSource.branch !== source.branch">
|
||||||
|
{{ 'WARNINGS.page_branch_mismatch' | translate:{ pageBranch: details.pageSource.branch, selectedBranch: source.branch } }}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -264,7 +270,8 @@
|
|||||||
<div class="anonymize-preview-body markdown-body body" ng-bind-html="html_readme"></div>
|
<div class="anonymize-preview-body markdown-body body" ng-bind-html="html_readme"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="anonymize-preview-col" ng-if="detectedType === 'pr' && details">
|
<div class="anonymize-preview-col" ng-if="detectedType === 'pr' && details"
|
||||||
|
ng-init="prTabState = { active: options.diff ? 'diff' : 'comments' }">
|
||||||
<div class="anonymize-preview-head">
|
<div class="anonymize-preview-head">
|
||||||
<span class="paper-eyebrow">Live preview</span>
|
<span class="paper-eyebrow">Live preview</span>
|
||||||
<span class="anonymize-preview-sub">Pull request with redactions applied</span>
|
<span class="anonymize-preview-sub">Pull request with redactions applied</span>
|
||||||
@@ -283,32 +290,45 @@
|
|||||||
<div class="pr-body shadow-sm p-3 mb-4 rounded" style="background: var(--paper-bg-alt)" ng-if="options.body">
|
<div class="pr-body shadow-sm p-3 mb-4 rounded" style="background: var(--paper-bg-alt)" ng-if="options.body">
|
||||||
<markdown content="anonymizePrContent(details.pullRequest.body)" options="options" terms="terms"></markdown>
|
<markdown content="anonymizePrContent(details.pullRequest.body)" options="options" terms="terms"></markdown>
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav nav-tabs" id="prTabs" role="tablist">
|
<nav class="paper-tabs" ng-if="options.diff || options.comments" role="tablist">
|
||||||
<li class="nav-item" role="presentation" ng-if="options.diff">
|
<button
|
||||||
<button class="nav-link active" id="pills-diff-tab" data-toggle="pill" data-target="#pills-diff" type="button" role="tab">Diff</button>
|
class="paper-tab"
|
||||||
</li>
|
ng-if="options.diff"
|
||||||
<li class="nav-item" role="presentation" ng-if="options.comments">
|
ng-class="{'active': prTabState.active == 'diff'}"
|
||||||
<button class="nav-link" ng-class="{'active':!options.diff}" id="pills-comments-tab" data-toggle="pill" data-target="#pills-comments" type="button" role="tab">
|
ng-click="prTabState.active = 'diff'"
|
||||||
<ng-pluralize count="details.pullRequest.comments.length" when="{'0': 'No comment', 'one': 'One Comment', 'other': '{} Comments'}"></ng-pluralize>
|
type="button"
|
||||||
</button>
|
role="tab"
|
||||||
</li>
|
>
|
||||||
</ul>
|
<i class="fas fa-code"></i> Diff
|
||||||
<div class="tab-content" id="pills-tabContent">
|
</button>
|
||||||
<div class="tab-pane show active" id="pills-diff" role="tabpanel" ng-if="options.diff">
|
<button
|
||||||
<div class="pr-diff shadow-sm p-3 mb-4 rounded" style="background: var(--paper-bg-alt)">
|
class="paper-tab"
|
||||||
<pre style="overflow-x: auto"><code ng-bind-html="anonymizePrContent(details.pullRequest.diff) | diff"></code></pre>
|
ng-if="options.comments"
|
||||||
</div>
|
ng-class="{'active': prTabState.active == 'comments'}"
|
||||||
|
ng-click="prTabState.active = 'comments'"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
<i class="far fa-comment-dots"></i>
|
||||||
|
<ng-pluralize count="details.pullRequest.comments.length" when="{'0': 'No comments', 'one': '1 comment', 'other': '{} comments'}"></ng-pluralize>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div class="paper-tab-content">
|
||||||
|
<div ng-if="options.diff && prTabState.active == 'diff'">
|
||||||
|
<div class="pr-diff" ng-bind-html="anonymizePrContent(details.pullRequest.diff) | diff"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane" ng-class="{'show active':!options.diff}" id="pills-comments" role="tabpanel" ng-if="options.comments">
|
<div ng-if="options.comments && prTabState.active == 'comments'">
|
||||||
<ul class="pr-comments list-group">
|
<ul class="pr-comments">
|
||||||
<li class="pr-comment list-group-item" ng-repeat="comment in details.pullRequest.comments">
|
<li class="pr-comment" ng-repeat="comment in details.pullRequest.comments">
|
||||||
<div class="d-flex w-100 justify-content-between flex-wrap">
|
<div class="pr-comment-head">
|
||||||
<h5 class="mb-1" ng-if="options.username">@{{anonymizePrContent(comment.author)}}</h5>
|
<span class="pr-comment-author" ng-if="options.username">
|
||||||
<small ng-bind="comment.updatedDate | date" ng-if="options.date"></small>
|
<i class="far fa-user"></i> @<span ng-bind="anonymizePrContent(comment.author)"></span>
|
||||||
|
</span>
|
||||||
|
<span class="pr-comment-date" ng-if="options.date" ng-bind="comment.updatedDate | date"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pr-comment-body" ng-if="options.body">
|
||||||
|
<markdown content="anonymizePrContent(comment.body)" options="options" terms="terms"></markdown>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-1">
|
|
||||||
<markdown class="pr-comment-body" ng-if="options.body" content="anonymizePrContent(comment.body)" options="options" terms="terms"></markdown>
|
|
||||||
</p>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
<div class="anon-text">
|
<div class="anon-text">
|
||||||
<a ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a>
|
<a ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a>
|
||||||
<div class="anon-sub">
|
<div class="anon-sub">
|
||||||
<a ng-if="item._type === 'repo'" href="https://github.com/{{item.source.fullName}}/" ng-bind="item.source.fullName"></a><span ng-if="item._type === 'repo' && item.options.update"> · <a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.branch}}" ng-bind="item.source.branch"></a></span><span ng-if="item._type === 'repo' && !item.options.update"> · @<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" ng-bind="item.source.commit.substring(0, 8)"></a></span>
|
<a ng-if="item._type === 'repo'" href="https://github.com/{{item.source.fullName}}/" ng-bind="item.source.fullName"></a><span ng-if="item._type === 'repo' && item.options.update"> · <a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.branch}}" ng-bind="item.source.branch"></a><span ng-if="item.source.commit"> · @<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" ng-bind="item.source.commit.substring(0, 8)"></a></span></span><span ng-if="item._type === 'repo' && !item.options.update"> · @<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" ng-bind="item.source.commit.substring(0, 8)"></a></span>
|
||||||
<a ng-if="item._type === 'pr'" href="https://github.com/{{item.source.repositoryFullName}}/pull/{{item.source.pullRequestId}}" ng-bind="item._source"></a>
|
<a ng-if="item._type === 'pr'" href="https://github.com/{{item.source.repositoryFullName}}/pull/{{item.source.pullRequestId}}" ng-bind="item._source"></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,11 +196,14 @@
|
|||||||
<span class="empty-dash" ng-if="!item.conference">—</span>
|
<span class="empty-dash" ng-if="!item.conference">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell-status" role="cell">
|
<div class="cell-status" role="cell">
|
||||||
<span
|
<div class="status-line">
|
||||||
class="status-dot"
|
<span
|
||||||
ng-class="{'status-removed': item.status == 'removed' || item.status == 'expired' || item.status == 'removing' || item.status == 'expiring', 'status-preparing': item.status == 'preparing' || item.status == 'download', 'status-ready': item.status == 'ready', 'status-error': item.status == 'error'}"
|
class="status-dot"
|
||||||
></span>
|
ng-class="{'status-removed': item.status == 'removed' || item.status == 'expired' || item.status == 'removing' || item.status == 'expiring', 'status-preparing': item.status == 'preparing' || item.status == 'download', 'status-ready': item.status == 'ready', 'status-error': item.status == 'error'}"
|
||||||
<span ng-bind="item.status | title"></span>
|
></span>
|
||||||
|
<span ng-bind="item.status | title"></span>
|
||||||
|
</div>
|
||||||
|
<div class="status-sub" ng-if="item.anonymizeDate" title="Last anonymized {{item.anonymizeDate | humanTime}}" ng-bind="item.anonymizeDate | humanTime"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell-views num" role="cell" ng-bind="item.pageView | number"></div>
|
<div class="cell-views num" role="cell" ng-bind="item.pageView | number"></div>
|
||||||
<div class="cell-expires" role="cell">
|
<div class="cell-expires" role="cell">
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
ng-show="files.length"
|
ng-show="files.length"
|
||||||
ng-class="{'collapsed': sidebarCollapsed}"
|
ng-class="{'collapsed': sidebarCollapsed}"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
ng-if="options.truncatedFolders.length > 0"
|
||||||
|
class="alert alert-warning small p-2 mb-2"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
{{ 'WARNINGS.repo_truncated' | translate }}
|
||||||
|
</div>
|
||||||
<tree class="files" file="files"></tree>
|
<tree class="files" file="files"></tree>
|
||||||
<div class="bottom column">
|
<div class="bottom column">
|
||||||
<div
|
<div
|
||||||
|
|||||||
+80
-105
@@ -1,112 +1,87 @@
|
|||||||
<div class="container-fluid h-100">
|
<div class="pr-page" ng-init="tabState = { active: (details && details.diff) ? 'diff' : 'comments' }">
|
||||||
<div class="row h-100">
|
<div class="container paper-page pr-page-inner">
|
||||||
<div class="col-md h-100 overflow-auto p-0 d-flex flex-column">
|
<div class="paper-crumbs">
|
||||||
<div class="d-flex align-content-between status-bar shadow">
|
<a href="/dashboard">Reviewer</a> /
|
||||||
<div class="last-update">
|
<span class="here">Pull request</span>
|
||||||
Anonymization Date: {{details.anonymizeDate|date}}
|
</div>
|
||||||
</div>
|
|
||||||
|
<header class="pr-header">
|
||||||
|
<h1 class="paper-page-title pr-title">
|
||||||
|
<span ng-if="details.title" ng-bind="details.title"></span>
|
||||||
|
<span ng-if="!details.title" class="text-muted">Untitled pull request</span>
|
||||||
|
</h1>
|
||||||
|
<div class="pr-header-meta">
|
||||||
|
<span class="paper-pill" ng-class="{'good': details.merged, 'warn': details.state == 'open', 'bad': details.state == 'closed' && !details.merged}">
|
||||||
|
<span class="status-dot" ng-class="{'status-ready': details.merged, 'status-error': details.state == 'closed' && !details.merged}"></span>
|
||||||
|
{{ details.merged ? 'Merged' : (details.state | title) }}
|
||||||
|
</span>
|
||||||
|
<span class="pr-meta-item" ng-if="details.baseRepositoryFullName">
|
||||||
|
<i class="fab fa-github"></i> <span ng-bind="details.baseRepositoryFullName"></span>
|
||||||
|
</span>
|
||||||
|
<span class="pr-meta-item" ng-if="details.updatedDate">
|
||||||
|
<i class="far fa-clock"></i> <span ng-bind="details.updatedDate | date"></span>
|
||||||
|
</span>
|
||||||
|
<span class="pr-meta-item" ng-if="details.anonymizeDate">
|
||||||
|
<i class="fas fa-user-secret"></i> Anonymized <span ng-bind="details.anonymizeDate | date"></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-auto paper-page" style="padding-top: 18px;">
|
</header>
|
||||||
<div class="paper-crumbs">Reviewer / <span class="here">Pull request</span></div>
|
|
||||||
<div class="d-flex w-100 justify-content-between align-items-end flex-wrap" style="gap: 12px;">
|
<section class="pr-body-card" ng-if="details.body">
|
||||||
<h1 class="paper-page-title pr-title" style="margin: 6px 0;">
|
<div class="paper-section-eyebrow">Description</div>
|
||||||
<span ng-if="details.title">{{details.title}}</span>
|
<markdown content="details.body"></markdown>
|
||||||
<span class="paper-pill" ng-class="{'good':details.merged, 'warn':details.state=='open', 'bad':details.state=='closed' && !details.merged}">
|
</section>
|
||||||
{{details.merged?"merged":details.state | title}}
|
|
||||||
</span>
|
<nav class="paper-tabs" ng-if="details.diff || details.comments" role="tablist">
|
||||||
</h1>
|
<button
|
||||||
<small class="paper-pill" ng-if="details.updatedDate" ng-bind="details.updatedDate | date"></small>
|
class="paper-tab"
|
||||||
</div>
|
ng-if="details.diff"
|
||||||
<div class="paper-meta-rule" ng-if="details.baseRepositoryFullName">
|
ng-class="{'active': tabState.active == 'diff'}"
|
||||||
<span>on <b>{{details.baseRepositoryFullName}}</b></span>
|
ng-click="tabState.active = 'diff'"
|
||||||
</div>
|
type="button"
|
||||||
<div
|
role="tab"
|
||||||
class="pr-body shadow-sm p-3 mb-4 rounded border"
|
>
|
||||||
ng-if="details.body"
|
<i class="fas fa-code"></i> Diff
|
||||||
>
|
</button>
|
||||||
<markdown content="details.body"></markdown>
|
<button
|
||||||
</div>
|
class="paper-tab"
|
||||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
ng-if="details.comments"
|
||||||
<li class="nav-item" role="presentation" ng-if="details.diff">
|
ng-class="{'active': tabState.active == 'comments'}"
|
||||||
<button
|
ng-click="tabState.active = 'comments'"
|
||||||
class="nav-link active"
|
type="button"
|
||||||
id="pills-diff-tab"
|
role="tab"
|
||||||
data-toggle="pill"
|
>
|
||||||
data-target="#pills-diff"
|
<i class="far fa-comment-dots"></i>
|
||||||
type="button"
|
<ng-pluralize
|
||||||
role="tab"
|
count="details.comments.length"
|
||||||
aria-controls="pills-diff"
|
when="{'0': 'No comments', 'one': '1 comment', 'other': '{} comments'}"
|
||||||
aria-selected="true"
|
></ng-pluralize>
|
||||||
>
|
</button>
|
||||||
Diff
|
</nav>
|
||||||
</button>
|
|
||||||
|
<div class="paper-tab-content">
|
||||||
|
<div ng-if="details.diff && tabState.active =='diff'">
|
||||||
|
<div class="pr-diff" ng-bind-html="details.diff | diff"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-if="details.comments && tabState.active =='comments'">
|
||||||
|
<ul class="pr-comments">
|
||||||
|
<li class="pr-comment" ng-repeat="comment in details.comments">
|
||||||
|
<div class="pr-comment-head">
|
||||||
|
<span class="pr-comment-author" ng-if="comment.author">
|
||||||
|
<i class="far fa-user"></i> @<span ng-bind="comment.author"></span>
|
||||||
|
</span>
|
||||||
|
<span class="pr-comment-date" ng-if="comment.updatedDate" ng-bind="comment.updatedDate | date"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pr-comment-body" ng-if="comment.body">
|
||||||
|
<markdown content="comment.body"></markdown>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation" ng-if="details.comments">
|
<li class="paper-table-empty" ng-if="!details.comments.length">
|
||||||
<button
|
<i class="far fa-comment-dots"></i>
|
||||||
class="nav-link"
|
<span>No comments on this pull request.</span>
|
||||||
ng-class="{'active':!details.diff}"
|
|
||||||
id="pills-comments-tab"
|
|
||||||
data-toggle="pill"
|
|
||||||
data-target="#pills-comments"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-controls="pills-comments"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
<ng-pluralize
|
|
||||||
count="details.comments.length"
|
|
||||||
when="{'0': 'No comment',
|
|
||||||
'one': 'One Comment',
|
|
||||||
'other': '{} Comments'}"
|
|
||||||
>
|
|
||||||
</ng-pluralize>
|
|
||||||
</button>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="tab-content" id="pills-tabContent">
|
|
||||||
<div
|
|
||||||
class="tab-pane show active"
|
|
||||||
id="pills-diff"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="pills-diff-tab"
|
|
||||||
ng-if="details.diff"
|
|
||||||
>
|
|
||||||
<div class="pr-diff shadow-sm p-3 mb-5 bg-white rounded">
|
|
||||||
<pre><code ng-bind-html="details.diff | diff"></code></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="tab-pane"
|
|
||||||
ng-class="{'show active':!details.diff}"
|
|
||||||
id="pills-comments"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="pills-comments-tab"
|
|
||||||
ng-if="details.comments"
|
|
||||||
>
|
|
||||||
<ul class="pr-comments list-group">
|
|
||||||
<li
|
|
||||||
class="pr-comment list-group-item"
|
|
||||||
ng-repeat="comment in details.comments"
|
|
||||||
>
|
|
||||||
<div class="d-flex w-100 justify-content-between">
|
|
||||||
<h5 class="mb-1" ng-if="comment.author">
|
|
||||||
@{{comment.author}}
|
|
||||||
</h5>
|
|
||||||
<small
|
|
||||||
ng-bind="comment.updatedDate | date"
|
|
||||||
ng-if="comment.updatedDate"
|
|
||||||
></small>
|
|
||||||
</div>
|
|
||||||
<p class="mb-1" ng-if="comment.body">
|
|
||||||
<markdown
|
|
||||||
class="pr-comment-body"
|
|
||||||
content="comment.body"
|
|
||||||
></markdown>
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+88
-118
@@ -1,137 +1,107 @@
|
|||||||
<div class="container paper-page">
|
<div class="container paper-page">
|
||||||
<div class="paper-crumbs">Anonymization / <span class="here">Status</span></div>
|
<div class="paper-crumbs">Anonymization / <span class="here">Status</span></div>
|
||||||
<h1 class="paper-page-title">Status of <em>{{repoId}}</em></h1>
|
<div class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
|
||||||
<p class="paper-page-lede">Track progress as your anonymization is prepared.</p>
|
<div>
|
||||||
<div class="paper-meta-rule"></div>
|
<h1 class="paper-page-title">Status of <em>{{repoId}}</em></h1>
|
||||||
|
<p class="paper-page-lede">Track progress as your anonymization is prepared.</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill" ng-class="{'status-pill-ready': repo.status == 'ready', 'status-pill-error': repo.status == 'error', 'status-pill-removed': repo.status == 'removed' || repo.status == 'expired'}">
|
||||||
|
<span class="status-dot" ng-class="{'status-ready': repo.status == 'ready', 'status-error': repo.status == 'error', 'status-removed': repo.status == 'removed' || repo.status == 'expired'}"></span>
|
||||||
|
<span ng-bind="repo.status | title"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="py-4">
|
<section class="paper-settings-section">
|
||||||
<div class="paper-section-eyebrow">Progress</div>
|
<div class="paper-section-eyebrow">Progress</div>
|
||||||
|
|
||||||
<p>
|
<p class="paper-section-copy">
|
||||||
The current status of your repository. The repository will take few
|
The repository will take a few minutes to get ready, depending on its
|
||||||
minutes to get ready depending on the size of the repository. Visit the
|
size. Visit the <a href="/faq">FAQ</a> for more information.
|
||||||
<a href="/faq">FAQ</a> for more information.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="progress" style="height: 25px">
|
<div class="paper-progress" ng-if="repo.status != 'error'" role="progressbar" aria-valuenow="{{progress}}" aria-valuemin="0" aria-valuemax="100" ng-class="{'paper-progress-ready': repo.status == 'ready'}">
|
||||||
<div
|
<div class="paper-progress-bar" style="width: {{progress}}%;"></div>
|
||||||
class="progress-bar"
|
<div class="paper-progress-label">
|
||||||
role="progressbar"
|
<span ng-bind="repo.status | title"></span><span ng-if="repo.statusMessage"> · <span ng-bind="repo.statusMessage"></span></span>
|
||||||
style="width: {{progress}}%;"
|
<span class="paper-progress-pct">{{progress || 0}}%</span>
|
||||||
aria-valuenow="{{progress}}"
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="100"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{{repo.status | title}}
|
|
||||||
<span ng-if="repo.statusMessage"
|
|
||||||
>: {{repo.statusMessage | title}}</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<div class="paper-error-card" ng-if="repo.status == 'error'" role="alert">
|
||||||
Your repository will be available at
|
<div class="paper-error-head">
|
||||||
<a href="/r/{{repoId}}/" target="__self">/r/{{repoId}}/</a>.
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
</p>
|
<div>
|
||||||
<p ng-if="repo.options.page">
|
<div class="paper-error-eyebrow">Anonymization failed</div>
|
||||||
Your GitHub Page will be available at
|
<div class="paper-error-title">Something went wrong while preparing this repository.</div>
|
||||||
<a href="/w/{{repoId}}/" target="__self">/w/{{repoId}}/</a>.
|
</div>
|
||||||
</p>
|
</div>
|
||||||
|
<p class="paper-error-msg" ng-if="repo.statusMessage">{{ 'ERRORS.' + repo.statusMessage | translate }}</p>
|
||||||
|
<p class="paper-error-msg" ng-if="!repo.statusMessage">No additional details were reported. The most common causes are private repositories, missing branches, and rate limits.</p>
|
||||||
|
<ul class="paper-error-hints">
|
||||||
|
<li>Make sure the source URL points to a repository or pull request you can access.</li>
|
||||||
|
<li>Check that the chosen branch and commit still exist on GitHub.</li>
|
||||||
|
<li>If you just signed in, the access token may need a moment to propagate — try again.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="paper-error-actions">
|
||||||
|
<a class="btn btn-ink" ng-href="/anonymize/{{repoId}}"><i class="far fa-edit mr-1"></i> Edit anonymization</a>
|
||||||
|
<a class="btn" href="/faq"><i class="far fa-question-circle mr-1"></i> Read the FAQ</a>
|
||||||
|
<a class="btn" href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank" rel="noopener"><i class="fab fa-github mr-1"></i> Report an issue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-center">
|
<div class="paper-detail-grid">
|
||||||
<a
|
<div class="detail-label">Repository</div>
|
||||||
class="btn btn-ink"
|
<div class="detail-value">
|
||||||
href="/r/{{repoId}}/"
|
<a href="/r/{{repoId}}/" target="__self">/r/{{repoId}}/</a>
|
||||||
target="__self"
|
</div>
|
||||||
ng-if="repo.status == 'ready'"
|
<div class="detail-label" ng-if="repo.options.page">GitHub Page</div>
|
||||||
>Go to the anonymized repository</a
|
<div class="detail-value" ng-if="repo.options.page">
|
||||||
>
|
<a href="/w/{{repoId}}/" target="__self">/w/{{repoId}}/</a>
|
||||||
<a
|
</div>
|
||||||
class="btn"
|
</div>
|
||||||
href="/w/{{repoId}}/"
|
|
||||||
target="__self"
|
<div class="anonymize-submit-bar" ng-if="repo.status == 'ready'">
|
||||||
ng-if="repo.options.page && repo.status == 'ready'"
|
<a class="btn btn-ink" href="/r/{{repoId}}/" target="__self">
|
||||||
>Go to the anonymized Github page</a
|
<i class="far fa-eye mr-1"></i> Go to anonymized repository
|
||||||
>
|
</a>
|
||||||
</p>
|
<a class="btn" href="/w/{{repoId}}/" target="__self" ng-if="repo.options.page">
|
||||||
|
<i class="fas fa-globe mr-1"></i> Go to anonymized GitHub page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="py-4">
|
<section class="paper-settings-section">
|
||||||
<div class="paper-section-eyebrow">Support Anonymous GitHub</div>
|
<div class="paper-section-eyebrow">Support Anonymous GitHub</div>
|
||||||
|
<p class="paper-section-copy">
|
||||||
|
A small team keeps this running. If it helps you, please consider
|
||||||
|
contributing back — in code, ideas, or coffee.
|
||||||
|
</p>
|
||||||
|
|
||||||
<iframe
|
<div class="paper-support-grid">
|
||||||
id="kofiframe"
|
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/" target="_blank" rel="noopener">
|
||||||
src="https://ko-fi.com/tdurieux/?hidefeed=true&widget=true&embed=true&preview=true"
|
<div class="paper-support-eyebrow"><i class="fas fa-code-branch"></i> Contribute</div>
|
||||||
style="border: none; width: 100%"
|
<p>Collaborate by implementing new features and fixing bugs. Help with new file formats or deployment is welcome.</p>
|
||||||
height="650"
|
<span class="paper-support-cta">Open on GitHub <i class="fas fa-arrow-right"></i></span>
|
||||||
title="tdurieux"
|
</a>
|
||||||
></iframe>
|
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank" rel="noopener">
|
||||||
|
<div class="paper-support-eyebrow"><i class="far fa-comment-dots"></i> Feedback</div>
|
||||||
|
<p>Tell us about bugs and missing features. Your feedback shapes the project's priorities.</p>
|
||||||
|
<span class="paper-support-cta">File an issue <i class="fas fa-arrow-right"></i></span>
|
||||||
|
</a>
|
||||||
|
<a class="paper-support-card" href="https://github.com/sponsors/tdurieux/" target="_blank" rel="noopener">
|
||||||
|
<div class="paper-support-eyebrow"><i class="fas fa-heart"></i> Sponsor</div>
|
||||||
|
<p>Server costs hover around $25 a month. Any contribution helps keep the lights on.</p>
|
||||||
|
<span class="paper-support-cta">GitHub Sponsors <i class="fas fa-arrow-right"></i></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row text-center">
|
<div class="paper-kofi-wrap">
|
||||||
<div class="col-lg-4">
|
<iframe
|
||||||
<i class="rounded-circle fa fa-edit"></i>
|
id="kofiframe"
|
||||||
|
src="https://ko-fi.com/tdurieux/?hidefeed=true&widget=true&embed=true&preview=true"
|
||||||
<h2>Contribute</h2>
|
title="tdurieux"
|
||||||
<p>
|
loading="lazy"
|
||||||
Collaborate to the Anonymous GitHub by implementing new features and
|
></iframe>
|
||||||
fixing bugs. Contribution likes supporting new file format or
|
|
||||||
improving the deployment are more than welcome.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
class="btn btn-secondary"
|
|
||||||
href="https://github.com/tdurieux/anonymous_github/"
|
|
||||||
target="__self"
|
|
||||||
>Go to GitHub »</a
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<!-- /.col-lg-4 -->
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<i class="rounded-circle fa fa-comments"></i>
|
|
||||||
|
|
||||||
<h2>Feedback</h2>
|
|
||||||
<p>
|
|
||||||
Feedback is also really valuable for the project. It helps to project
|
|
||||||
to identify bugs, missing feature and define priorities for the
|
|
||||||
project.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
class="btn btn-secondary"
|
|
||||||
href="https://github.com/tdurieux/anonymous_github/issues/new"
|
|
||||||
target="__self"
|
|
||||||
>Create an issue »</a
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<!-- /.col-lg-4 -->
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<i class="rounded-circle fa fa-dollar-sign"></i>
|
|
||||||
|
|
||||||
<h2>Finance</h2>
|
|
||||||
<p>
|
|
||||||
You can also help the project by supporting financially the project.
|
|
||||||
The server costs around 25$ per month. Any help to support the cost
|
|
||||||
would be gladly appreciated.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
class="btn btn-secondary"
|
|
||||||
href="https://github.com/sponsors/tdurieux/"
|
|
||||||
target="__self"
|
|
||||||
>GitHub Sponsor »</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
class="btn btn-secondary"
|
|
||||||
href="https://ko-fi.com/tdurieux"
|
|
||||||
target="__self"
|
|
||||||
>Ko-fi »</a
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -198,6 +198,44 @@ angular
|
|||||||
getUser($routeParams.username);
|
getUser($routeParams.username);
|
||||||
getUserRepositories($routeParams.username);
|
getUserRepositories($routeParams.username);
|
||||||
|
|
||||||
|
$scope.tokens = [];
|
||||||
|
$scope.newTokenName = "";
|
||||||
|
$scope.newTokenPlaintext = null;
|
||||||
|
|
||||||
|
function loadTokens() {
|
||||||
|
$http.get("/api/admin/tokens").then(
|
||||||
|
(res) => {
|
||||||
|
$scope.tokens = res.data || [];
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (err.status !== 401 && err.status !== 403) console.error(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
loadTokens();
|
||||||
|
|
||||||
|
$scope.createToken = () => {
|
||||||
|
if (!$scope.newTokenName) return;
|
||||||
|
$http
|
||||||
|
.post("/api/admin/tokens", { name: $scope.newTokenName })
|
||||||
|
.then(
|
||||||
|
(res) => {
|
||||||
|
$scope.newTokenPlaintext = res.data.token;
|
||||||
|
$scope.newTokenName = "";
|
||||||
|
loadTokens();
|
||||||
|
},
|
||||||
|
(err) => console.error(err)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.revokeToken = (t) => {
|
||||||
|
if (!confirm(`Revoke token "${t.name}"?`)) return;
|
||||||
|
$http.delete("/api/admin/tokens/" + t.id).then(
|
||||||
|
() => loadTokens(),
|
||||||
|
(err) => console.error(err)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
$scope.removeCache = (repo) => {
|
$scope.removeCache = (repo) => {
|
||||||
$http.delete("/api/admin/repos/" + repo.repoId).then(
|
$http.delete("/api/admin/repos/" + repo.repoId).then(
|
||||||
(res) => {
|
(res) => {
|
||||||
|
|||||||
+179
-36
@@ -197,29 +197,136 @@ angular
|
|||||||
.filter("diff", [
|
.filter("diff", [
|
||||||
"$sce",
|
"$sce",
|
||||||
function ($sce) {
|
function ($sce) {
|
||||||
|
const esc = (s) =>
|
||||||
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
function flushFile(out, file) {
|
||||||
|
if (!file) return;
|
||||||
|
const headerName =
|
||||||
|
file.newPath && file.newPath !== "/dev/null"
|
||||||
|
? file.newPath
|
||||||
|
: file.oldPath || "";
|
||||||
|
const status =
|
||||||
|
file.oldPath === "/dev/null"
|
||||||
|
? "added"
|
||||||
|
: file.newPath === "/dev/null"
|
||||||
|
? "deleted"
|
||||||
|
: file.oldPath && file.newPath && file.oldPath !== file.newPath
|
||||||
|
? "renamed"
|
||||||
|
: "modified";
|
||||||
|
out.push('<div class="diff-file-block">');
|
||||||
|
out.push(
|
||||||
|
'<div class="diff-file-header"><span class="diff-file-icon"><i class="far fa-file-code"></i></span>' +
|
||||||
|
'<span class="diff-file-name">' +
|
||||||
|
esc(headerName) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="diff-file-status diff-file-status-' +
|
||||||
|
status +
|
||||||
|
'">' +
|
||||||
|
status +
|
||||||
|
"</span></div>"
|
||||||
|
);
|
||||||
|
if (file.lines.length) {
|
||||||
|
out.push('<table class="diff-file-table"><tbody>');
|
||||||
|
for (const line of file.lines) {
|
||||||
|
out.push(
|
||||||
|
'<tr class="diff-row diff-row-' +
|
||||||
|
line.kind +
|
||||||
|
'">' +
|
||||||
|
'<td class="diff-gutter diff-gutter-old">' +
|
||||||
|
(line.oldNo || "") +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="diff-gutter diff-gutter-new">' +
|
||||||
|
(line.newNo || "") +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="diff-sign">' +
|
||||||
|
(line.kind === "add"
|
||||||
|
? "+"
|
||||||
|
: line.kind === "remove"
|
||||||
|
? "-"
|
||||||
|
: line.kind === "hunk"
|
||||||
|
? "@"
|
||||||
|
: "") +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="diff-code">' +
|
||||||
|
esc(line.text) +
|
||||||
|
"</td>" +
|
||||||
|
"</tr>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.push("</tbody></table>");
|
||||||
|
}
|
||||||
|
out.push("</div>");
|
||||||
|
}
|
||||||
|
|
||||||
return function (str) {
|
return function (str) {
|
||||||
if (!str) return str;
|
if (!str) return str;
|
||||||
|
const out = [];
|
||||||
|
let file = null;
|
||||||
|
let oldNo = 0;
|
||||||
|
let newNo = 0;
|
||||||
|
const ensureFile = () => {
|
||||||
|
if (!file) file = { oldPath: "", newPath: "", lines: [] };
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
const startNewFileIfNeeded = () => {
|
||||||
|
if (file && (file.lines.length || file.oldPath || file.newPath)) {
|
||||||
|
flushFile(out, file);
|
||||||
|
file = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
const lines = str.split("\n");
|
const lines = str.split("\n");
|
||||||
const o = [];
|
for (let i = 0; i < lines.length; i++) {
|
||||||
for (let i = 1; i < lines.length; i++) {
|
const ln = lines[i];
|
||||||
lines[i] = lines[i].replace(/</g, "<").replace(/>/g, ">");
|
if (ln.startsWith("diff --git")) {
|
||||||
if (lines[i].startsWith("+++")) {
|
startNewFileIfNeeded();
|
||||||
o.push(`<span class="diff-file">${lines[i]}</span>`);
|
ensureFile();
|
||||||
} else if (lines[i].startsWith("---")) {
|
continue;
|
||||||
o.push(`<span class="diff-file">${lines[i]}</span>`);
|
}
|
||||||
} else if (lines[i].startsWith("@@")) {
|
if (ln.startsWith("--- ")) {
|
||||||
o.push(`<span class="diff-lines">${lines[i]}</span>`);
|
// New file boundary if the previous file already had lines.
|
||||||
} else if (lines[i].startsWith("index")) {
|
if (file && file.lines.length) startNewFileIfNeeded();
|
||||||
o.push(`<span class="diff-index">${lines[i]}</span>`);
|
ensureFile().oldPath = ln.replace(/^--- (a\/)?/, "").trim();
|
||||||
} else if (lines[i].startsWith("+")) {
|
continue;
|
||||||
o.push(`<span class="diff-add">${lines[i]}</span>`);
|
}
|
||||||
} else if (lines[i].startsWith("-")) {
|
if (ln.startsWith("+++ ")) {
|
||||||
o.push(`<span class="diff-remove">${lines[i]}</span>`);
|
ensureFile().newPath = ln.replace(/^\+\+\+ (b\/)?/, "").trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ln.startsWith("index ") ||
|
||||||
|
ln.startsWith("similarity index") ||
|
||||||
|
ln.startsWith("rename ") ||
|
||||||
|
ln.startsWith("new file mode") ||
|
||||||
|
ln.startsWith("deleted file mode") ||
|
||||||
|
ln.startsWith("Binary files")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ln.startsWith("@@")) {
|
||||||
|
const m = ln.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
|
||||||
|
if (m) {
|
||||||
|
oldNo = parseInt(m[1], 10);
|
||||||
|
newNo = parseInt(m[2], 10);
|
||||||
|
}
|
||||||
|
ensureFile().lines.push({ kind: "hunk", oldNo: "", newNo: "", text: ln });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!file) continue;
|
||||||
|
if (ln.startsWith("+")) {
|
||||||
|
file.lines.push({ kind: "add", oldNo: "", newNo: newNo, text: ln.slice(1) });
|
||||||
|
newNo++;
|
||||||
|
} else if (ln.startsWith("-")) {
|
||||||
|
file.lines.push({ kind: "remove", oldNo: oldNo, newNo: "", text: ln.slice(1) });
|
||||||
|
oldNo++;
|
||||||
} else {
|
} else {
|
||||||
o.push(`<span class="diff-line">${lines[i]}</span>`);
|
file.lines.push({ kind: "ctx", oldNo: oldNo, newNo: newNo, text: ln.startsWith(" ") ? ln.slice(1) : ln });
|
||||||
|
oldNo++;
|
||||||
|
newNo++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $sce.trustAsHtml(o.join("\n"));
|
flushFile(out, file);
|
||||||
|
return $sce.trustAsHtml(out.join(""));
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -311,6 +418,18 @@ angular
|
|||||||
return f1.name - f2.name;
|
return f1.name - f2.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isTruncated(folderPath) {
|
||||||
|
const truncated =
|
||||||
|
($scope.$parent.options &&
|
||||||
|
$scope.$parent.options.truncatedFolders) ||
|
||||||
|
[];
|
||||||
|
if (!truncated.length) return false;
|
||||||
|
const normalized = folderPath.startsWith("/")
|
||||||
|
? folderPath.substring(1)
|
||||||
|
: folderPath;
|
||||||
|
return truncated.indexOf(normalized) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
function generate(current, parentPath) {
|
function generate(current, parentPath) {
|
||||||
if (!current) return "";
|
if (!current) return "";
|
||||||
current = current.sort(sortFiles);
|
current = current.sort(sortFiles);
|
||||||
@@ -350,6 +469,10 @@ angular
|
|||||||
if ($scope.isActive(path)) {
|
if ($scope.isActive(path)) {
|
||||||
cssClasses.push("active");
|
cssClasses.push("active");
|
||||||
}
|
}
|
||||||
|
const truncated = dir && isTruncated(path);
|
||||||
|
if (truncated) {
|
||||||
|
cssClasses.push("truncated");
|
||||||
|
}
|
||||||
|
|
||||||
output += `<li class="${cssClasses.join(
|
output += `<li class="${cssClasses.join(
|
||||||
" "
|
" "
|
||||||
@@ -359,6 +482,9 @@ angular
|
|||||||
} else {
|
} else {
|
||||||
output += `<a href='/r/${$scope.repoId}${path}'>${name}</a>`;
|
output += `<a href='/r/${$scope.repoId}${path}'>${name}</a>`;
|
||||||
}
|
}
|
||||||
|
if (truncated) {
|
||||||
|
output += `<span class="truncated-warning" title="{{ 'WARNINGS.folder_truncated' | translate }}"><i class="fas fa-exclamation-triangle"></i></span>`;
|
||||||
|
}
|
||||||
if ($scope.opens[path] && f.child) {
|
if ($scope.opens[path] && f.child) {
|
||||||
if (f.child.length > 1) {
|
if (f.child.length > 1) {
|
||||||
output += generate(f.child, path);
|
output += generate(f.child, path);
|
||||||
@@ -1064,9 +1190,15 @@ angular
|
|||||||
$scope.html_readme = "";
|
$scope.html_readme = "";
|
||||||
$scope.detectedType = null;
|
$scope.detectedType = null;
|
||||||
|
|
||||||
|
let o;
|
||||||
|
try {
|
||||||
|
o = parseGithubUrl($scope.sourceUrl);
|
||||||
|
} catch (error) {
|
||||||
|
setValidity("sourceUrl", "github", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValidity("sourceUrl", "github", true);
|
||||||
try {
|
try {
|
||||||
const o = parseGithubUrl($scope.sourceUrl);
|
|
||||||
setValidity("sourceUrl", "github", true);
|
|
||||||
if (o.pullRequestId) {
|
if (o.pullRequestId) {
|
||||||
$scope.detectedType = "pr";
|
$scope.detectedType = "pr";
|
||||||
$scope.source = { repositoryFullName: o.owner + "/" + o.repo, pullRequestId: o.pullRequestId };
|
$scope.source = { repositoryFullName: o.owner + "/" + o.repo, pullRequestId: o.pullRequestId };
|
||||||
@@ -1077,7 +1209,6 @@ angular
|
|||||||
anonymizeReadme();
|
anonymizeReadme();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setValidity("sourceUrl", "github", false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$scope.$apply();
|
$scope.$apply();
|
||||||
@@ -1096,9 +1227,6 @@ angular
|
|||||||
$scope.$watch("source.branch", async () => {
|
$scope.$watch("source.branch", async () => {
|
||||||
if ($scope.detectedType !== "repo") return;
|
if ($scope.detectedType !== "repo") return;
|
||||||
const selected = $scope.branches.filter((f) => f.name == $scope.source.branch)[0];
|
const selected = $scope.branches.filter((f) => f.name == $scope.source.branch)[0];
|
||||||
if ($scope.details && $scope.details.hasPage && $scope.anonymize && $scope.anonymize.page) {
|
|
||||||
$scope.anonymize.page.$$element[0].disabled = $scope.details.pageSource.branch != $scope.source.branch;
|
|
||||||
}
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
$scope.source.commit = selected.commit;
|
$scope.source.commit = selected.commit;
|
||||||
$scope.readme = selected.readme;
|
$scope.readme = selected.readme;
|
||||||
@@ -1110,18 +1238,33 @@ angular
|
|||||||
|
|
||||||
$scope.getBranches = async (force) => {
|
$scope.getBranches = async (force) => {
|
||||||
const o = parseGithubUrl($scope.sourceUrl);
|
const o = parseGithubUrl($scope.sourceUrl);
|
||||||
const branches = await $http.get(`/api/repo/${o.owner}/${o.repo}/branches`, {
|
try {
|
||||||
params: { force: force === true ? "1" : "0", repositoryID: $scope.repositoryID },
|
const branches = await $http.get(`/api/repo/${o.owner}/${o.repo}/branches`, {
|
||||||
});
|
params: { force: force === true ? "1" : "0", repositoryID: $scope.repositoryID },
|
||||||
$scope.branches = branches.data;
|
});
|
||||||
if (!$scope.source.branch) {
|
$scope.branches = branches.data;
|
||||||
$scope.source.branch = $scope.details.defaultBranch;
|
$scope.sourceUnreachable = false;
|
||||||
}
|
if (!$scope.source.branch) {
|
||||||
const selected = $scope.branches.filter((b) => b.name == $scope.source.branch);
|
$scope.source.branch = $scope.details.defaultBranch;
|
||||||
if (selected.length > 0) {
|
}
|
||||||
$scope.source.commit = selected[0].commit;
|
const selected = $scope.branches.filter((b) => b.name == $scope.source.branch);
|
||||||
$scope.readme = selected[0].readme;
|
if (selected.length > 0) {
|
||||||
await getReadme(force);
|
$scope.source.commit = selected[0].commit;
|
||||||
|
$scope.readme = selected[0].readme;
|
||||||
|
await getReadme(force);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
$scope.branches = [];
|
||||||
|
$scope.sourceUnreachable = error && (error.status === 404 || (error.data && error.data.error === "repo_not_found"));
|
||||||
|
const code = (error && error.data && error.data.error) || (error && error.status === 404 ? "repo_not_found" : "unknown_error");
|
||||||
|
$translate("ERRORS." + code).then((translation) => {
|
||||||
|
$scope.toasts = $scope.toasts || [];
|
||||||
|
$scope.toasts.push({ title: "Error", date: new Date(), body: translation });
|
||||||
|
$scope.error = translation;
|
||||||
|
}, console.error);
|
||||||
|
if (typeof setValidity === "function") {
|
||||||
|
setValidity("sourceUrl", "missing", false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$scope.$apply();
|
$scope.$apply();
|
||||||
};
|
};
|
||||||
@@ -1458,7 +1601,7 @@ angular
|
|||||||
$scope.getFiles = async function (path) {
|
$scope.getFiles = async function (path) {
|
||||||
try {
|
try {
|
||||||
const res = await $http.get(
|
const res = await $http.get(
|
||||||
`/api/repo/${$scope.repoId}/files/?path=${path}&v=${$scope.options.lastUpdateDate}`
|
`/api/repo/${$scope.repoId}/files/?path=${encodeURIComponent(path)}&v=${$scope.options.lastUpdateDate}`
|
||||||
);
|
);
|
||||||
$scope.files.push(...res.data);
|
$scope.files.push(...res.data);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -128,7 +128,7 @@ function generateRandomId(length) {
|
|||||||
function parseGithubUrl(url) {
|
function parseGithubUrl(url) {
|
||||||
if (!url) throw "Invalid url";
|
if (!url) throw "Invalid url";
|
||||||
const matches = url
|
const matches = url
|
||||||
.replace(".git", "")
|
.replace(/\.git(\/|$)/, "$1")
|
||||||
.match(
|
.match(
|
||||||
/.*?github.com\/(?<owner>[\w-\._]+)\/(?<repo>[\w-\._]+)(\/pull\/(?<PR>[0-9]+))?/
|
/.*?github.com\/(?<owner>[\w-\._]+)\/(?<repo>[\w-\._]+)(\/pull\/(?<PR>[0-9]+))?/
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-16
@@ -157,8 +157,18 @@ export default class Repository {
|
|||||||
files.forEach((f) => (f.repoId = this.repoId));
|
files.forEach((f) => (f.repoId = this.repoId));
|
||||||
await FileModel.insertMany(files);
|
await FileModel.insertMany(files);
|
||||||
|
|
||||||
|
const sourceWithTruncation = this.source as unknown as {
|
||||||
|
truncatedFolderList?: string[];
|
||||||
|
};
|
||||||
|
if (Array.isArray(sourceWithTruncation.truncatedFolderList)) {
|
||||||
|
this._model.truncatedFolders = sourceWithTruncation.truncatedFolderList;
|
||||||
|
}
|
||||||
|
|
||||||
this._model.size = { storage: 0, file: 0 };
|
this._model.size = { storage: 0, file: 0 };
|
||||||
await this.computeSize();
|
await this.computeSize();
|
||||||
|
if (isConnected) {
|
||||||
|
await this._model.save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (opt.path?.includes(config.ANONYMIZATION_MASK)) {
|
if (opt.path?.includes(config.ANONYMIZATION_MASK)) {
|
||||||
const f = new AnonymizedFile({
|
const f = new AnonymizedFile({
|
||||||
@@ -304,22 +314,6 @@ export default class Repository {
|
|||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ghRepo.size) {
|
|
||||||
if (
|
|
||||||
ghRepo.size > config.AUTO_DOWNLOAD_REPO_SIZE &&
|
|
||||||
this.model.source.type == "GitHubDownload"
|
|
||||||
) {
|
|
||||||
this.model.source.type = "GitHubStream";
|
|
||||||
await this.model.save();
|
|
||||||
} else if (
|
|
||||||
ghRepo.size < config.AUTO_DOWNLOAD_REPO_SIZE &&
|
|
||||||
this.model.source.type == "GitHubStream"
|
|
||||||
) {
|
|
||||||
this.model.source.type = "GitHubDownload";
|
|
||||||
await this.model.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update the repository name if it has changed
|
// update the repository name if it has changed
|
||||||
this.model.source.repositoryName = ghRepo.fullName;
|
this.model.source.repositoryName = ghRepo.fullName;
|
||||||
const branches = await ghRepo.branches({
|
const branches = await ghRepo.branches({
|
||||||
|
|||||||
+61
-15
@@ -1,5 +1,6 @@
|
|||||||
import { basename } from "path";
|
import { basename } from "path";
|
||||||
import { Transform, Readable } from "stream";
|
import { Transform, Readable } from "stream";
|
||||||
|
import { StringDecoder } from "string_decoder";
|
||||||
import { isText } from "istextorbinary";
|
import { isText } from "istextorbinary";
|
||||||
|
|
||||||
import config from "../config";
|
import config from "../config";
|
||||||
@@ -30,8 +31,14 @@ export function isTextFile(filePath: string, content?: Buffer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AnonymizeTransformer extends Transform {
|
export class AnonymizeTransformer extends Transform {
|
||||||
public isText: boolean | null = null;
|
public isText: boolean;
|
||||||
anonimizer: ContentAnonimizer;
|
anonimizer: ContentAnonimizer;
|
||||||
|
private decoder = new StringDecoder("utf8");
|
||||||
|
// Trailing decoded text held back between chunks so that terms, URLs, or
|
||||||
|
// markdown image patterns straddling a stream chunk boundary still match.
|
||||||
|
// Must exceed the longest pattern we replace (terms + URLs + images).
|
||||||
|
private pending = "";
|
||||||
|
private static readonly OVERLAP = 4096;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly opt: {
|
readonly opt: {
|
||||||
@@ -39,7 +46,11 @@ export class AnonymizeTransformer extends Transform {
|
|||||||
} & ConstructorParameters<typeof ContentAnonimizer>[0]
|
} & ConstructorParameters<typeof ContentAnonimizer>[0]
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.isText = isTextFile(this.opt.filePath);
|
// isTextFile may return null for unknown extensions; treat unknown as
|
||||||
|
// binary. Sniffing from chunk content is unsafe — split archives,
|
||||||
|
// compressed blobs, etc. can have an ASCII-looking first 64 KB and get
|
||||||
|
// misclassified as text, which then UTF-8-round-trips and corrupts them.
|
||||||
|
this.isText = isTextFile(this.opt.filePath) === true;
|
||||||
this.anonimizer = new ContentAnonimizer(this.opt);
|
this.anonimizer = new ContentAnonimizer(this.opt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,23 +59,58 @@ export class AnonymizeTransformer extends Transform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_transform(chunk: Buffer, encoding: string, callback: () => void) {
|
_transform(chunk: Buffer, encoding: string, callback: () => void) {
|
||||||
if (this.isText === null) {
|
if (!this.isText) {
|
||||||
this.isText = isTextFile(this.opt.filePath, chunk);
|
this.emit("transform", {
|
||||||
|
isText: this.isText,
|
||||||
|
wasAnonimized: this.wasAnonimized,
|
||||||
|
chunk,
|
||||||
|
});
|
||||||
|
this.push(chunk);
|
||||||
|
return callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StringDecoder buffers trailing partial UTF-8 sequences across chunk
|
||||||
|
// boundaries so we never decode half a codepoint into U+FFFD.
|
||||||
|
this.pending += this.decoder.write(chunk);
|
||||||
|
|
||||||
|
if (this.pending.length > AnonymizeTransformer.OVERLAP) {
|
||||||
|
let split = this.pending.length - AnonymizeTransformer.OVERLAP;
|
||||||
|
// Avoid splitting a UTF-16 surrogate pair.
|
||||||
|
const code = this.pending.charCodeAt(split);
|
||||||
|
if (code >= 0xdc00 && code <= 0xdfff) {
|
||||||
|
split -= 1;
|
||||||
|
}
|
||||||
|
const toProcess = this.pending.slice(0, split);
|
||||||
|
this.pending = this.pending.slice(split);
|
||||||
|
|
||||||
|
const out = this.anonimizer.anonymize(toProcess);
|
||||||
|
const outChunk = Buffer.from(out, "utf8");
|
||||||
|
|
||||||
|
this.emit("transform", {
|
||||||
|
isText: this.isText,
|
||||||
|
wasAnonimized: this.wasAnonimized,
|
||||||
|
chunk: outChunk,
|
||||||
|
});
|
||||||
|
this.push(outChunk);
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
_flush(callback: () => void) {
|
||||||
if (this.isText) {
|
if (this.isText) {
|
||||||
const content = this.anonimizer.anonymize(chunk.toString());
|
this.pending += this.decoder.end();
|
||||||
if (this.anonimizer.wasAnonymized) {
|
if (this.pending) {
|
||||||
chunk = Buffer.from(content);
|
const out = this.anonimizer.anonymize(this.pending);
|
||||||
|
this.pending = "";
|
||||||
|
const outChunk = Buffer.from(out, "utf8");
|
||||||
|
this.emit("transform", {
|
||||||
|
isText: this.isText,
|
||||||
|
wasAnonimized: this.wasAnonimized,
|
||||||
|
chunk: outChunk,
|
||||||
|
});
|
||||||
|
this.push(outChunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit("transform", {
|
|
||||||
isText: this.isText,
|
|
||||||
wasAnonimized: this.wasAnonimized,
|
|
||||||
chunk,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.push(chunk);
|
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ const AnonymizedRepositorySchema = new Schema({
|
|||||||
repositoryName: String,
|
repositoryName: String,
|
||||||
accessToken: String,
|
accessToken: String,
|
||||||
},
|
},
|
||||||
truckedFileList: {
|
truncatedFolders: {
|
||||||
type: Boolean,
|
type: [String],
|
||||||
default: false,
|
default: [],
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
terms: [String],
|
terms: [String],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface IAnonymizedRepository {
|
|||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
};
|
};
|
||||||
owner: string;
|
owner: string;
|
||||||
truckedFileList: boolean;
|
truncatedFolders: string[];
|
||||||
conference: string;
|
conference: string;
|
||||||
options: {
|
options: {
|
||||||
terms: string[];
|
terms: string[];
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ const UserSchema = new Schema({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isAdmin: { type: Boolean, default: false },
|
isAdmin: { type: Boolean, default: false },
|
||||||
|
apiTokens: [
|
||||||
|
{
|
||||||
|
tokenHash: { type: String, index: true },
|
||||||
|
name: { type: String },
|
||||||
|
createdAt: { type: Date, default: Date.now },
|
||||||
|
lastUsedAt: { type: Date },
|
||||||
|
},
|
||||||
|
],
|
||||||
photo: String,
|
photo: String,
|
||||||
repositories: [
|
repositories: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export interface IUser {
|
|||||||
};
|
};
|
||||||
username: string;
|
username: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
apiTokens?: {
|
||||||
|
_id?: string;
|
||||||
|
tokenHash: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
lastUsedAt?: Date;
|
||||||
|
}[];
|
||||||
emails: {
|
emails: {
|
||||||
email: string;
|
email: string;
|
||||||
default: boolean;
|
default: boolean;
|
||||||
|
|||||||
@@ -209,8 +209,8 @@ export async function getRepositoryFromGitHub(opt: {
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (opt.repo.indexOf(".git") > -1) {
|
if (opt.repo.endsWith(".git")) {
|
||||||
opt.repo = opt.repo.replace(".git", "");
|
opt.repo = opt.repo.slice(0, -4);
|
||||||
}
|
}
|
||||||
let dbModel;
|
let dbModel;
|
||||||
if (opt.repositoryID) {
|
if (opt.repositoryID) {
|
||||||
|
|||||||
@@ -15,10 +15,16 @@ import { IFile } from "../model/files/files.types";
|
|||||||
export default class GitHubStream extends GitHubBase {
|
export default class GitHubStream extends GitHubBase {
|
||||||
type: "GitHubDownload" | "GitHubStream" | "Zip" = "GitHubStream";
|
type: "GitHubDownload" | "GitHubStream" | "Zip" = "GitHubStream";
|
||||||
|
|
||||||
|
private _truncatedFolders: string[] = [];
|
||||||
|
|
||||||
constructor(data: GitHubBaseData) {
|
constructor(data: GitHubBaseData) {
|
||||||
super(data);
|
super(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get truncatedFolderList(): string[] {
|
||||||
|
return this._truncatedFolders;
|
||||||
|
}
|
||||||
|
|
||||||
downloadFile(token: string, sha: string) {
|
downloadFile(token: string, sha: string) {
|
||||||
const oct = octokit(token);
|
const oct = octokit(token);
|
||||||
try {
|
try {
|
||||||
@@ -106,6 +112,7 @@ export default class GitHubStream extends GitHubBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getFiles(progress?: (status: string) => void) {
|
async getFiles(progress?: (status: string) => void) {
|
||||||
|
this._truncatedFolders = [];
|
||||||
return this.getTruncatedTree(this.data.commit, progress);
|
return this.getTruncatedTree(this.data.commit, progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,19 +156,32 @@ export default class GitHubStream extends GitHubBase {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (data.truncated) {
|
||||||
|
this._truncatedFolders.push(parentPath);
|
||||||
|
}
|
||||||
output.push(...this.tree2Tree(data.tree, parentPath));
|
output.push(...this.tree2Tree(data.tree, parentPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
if ((error as { status?: number }).status == 409 || (error as { status?: number }).status == 404) {
|
const status = (error as { status?: number }).status;
|
||||||
// empty repo
|
if (status === 409) {
|
||||||
data = { tree: [] };
|
throw new AnonymousError("repo_empty", {
|
||||||
} else {
|
httpStatus: 409,
|
||||||
throw new AnonymousError("repo_not_found", {
|
|
||||||
httpStatus: (error as { status?: number }).status || 404,
|
|
||||||
object: this.data,
|
object: this.data,
|
||||||
cause: error as Error,
|
cause: error as Error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (status === 404) {
|
||||||
|
throw new AnonymousError("repo_not_found", {
|
||||||
|
httpStatus: 404,
|
||||||
|
object: this.data,
|
||||||
|
cause: error as Error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new AnonymousError("repo_not_found", {
|
||||||
|
httpStatus: status || 500,
|
||||||
|
object: this.data,
|
||||||
|
cause: error as Error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const promises: ReturnType<GitHubStream["getGHTree"]>[] = [];
|
const promises: ReturnType<GitHubStream["getGHTree"]>[] = [];
|
||||||
const parentPaths: string[] = [];
|
const parentPaths: string[] = [];
|
||||||
@@ -183,7 +203,7 @@ export default class GitHubStream extends GitHubBase {
|
|||||||
}
|
}
|
||||||
(await Promise.all(promises)).forEach((data, i) => {
|
(await Promise.all(promises)).forEach((data, i) => {
|
||||||
if (data.truncated) {
|
if (data.truncated) {
|
||||||
// TODO: the tree is truncated
|
this._truncatedFolders.push(parentPaths[i]);
|
||||||
}
|
}
|
||||||
output.push(...this.tree2Tree(data.tree, parentPaths[i]));
|
output.push(...this.tree2Tree(data.tree, parentPaths[i]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import got from "got";
|
||||||
|
import { Parse } from "unzip-stream";
|
||||||
|
import archiver = require("archiver");
|
||||||
|
|
||||||
|
import GitHubDownload from "./source/GitHubDownload";
|
||||||
|
import { AnonymizeTransformer, anonymizePath } from "./anonymize-utils";
|
||||||
|
|
||||||
|
export interface StreamAnonymizedZipOptions {
|
||||||
|
repoId: string;
|
||||||
|
organization: string;
|
||||||
|
repoName: string;
|
||||||
|
commit: string;
|
||||||
|
getToken: () => string | Promise<string>;
|
||||||
|
anonymizerOptions: ConstructorParameters<typeof AnonymizeTransformer>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the GitHub source zip for a repository, anonymize each entry on the
|
||||||
|
* fly, and pipe the resulting archive into the provided writable response.
|
||||||
|
*
|
||||||
|
* No data is written to local storage — the zip flows GitHub → unzip → per
|
||||||
|
* file anonymizer → archiver → response.
|
||||||
|
*/
|
||||||
|
export async function streamAnonymizedZip(
|
||||||
|
opt: StreamAnonymizedZipOptions,
|
||||||
|
res: NodeJS.WritableStream & {
|
||||||
|
on(event: string, listener: (...args: unknown[]) => void): unknown;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const source = new GitHubDownload({
|
||||||
|
repoId: opt.repoId,
|
||||||
|
organization: opt.organization,
|
||||||
|
repoName: opt.repoName,
|
||||||
|
commit: opt.commit,
|
||||||
|
getToken: opt.getToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await source.getZipUrl();
|
||||||
|
const downloadStream = got.stream(response.url);
|
||||||
|
|
||||||
|
res.on("error", (error) => {
|
||||||
|
console.error(error);
|
||||||
|
downloadStream.destroy();
|
||||||
|
});
|
||||||
|
res.on("close", () => {
|
||||||
|
downloadStream.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const archive = archiver("zip", {});
|
||||||
|
downloadStream
|
||||||
|
.on("error", (error) => {
|
||||||
|
console.error(error);
|
||||||
|
try {
|
||||||
|
archive.finalize();
|
||||||
|
} catch {
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("close", () => {
|
||||||
|
try {
|
||||||
|
archive.finalize();
|
||||||
|
} catch {
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.pipe(Parse())
|
||||||
|
.on("entry", (entry: NodeJS.ReadableStream & { type: string; path: string; autodrain: () => void }) => {
|
||||||
|
if (entry.type === "File") {
|
||||||
|
try {
|
||||||
|
const fileName = anonymizePath(
|
||||||
|
entry.path.substring(entry.path.indexOf("/") + 1),
|
||||||
|
opt.anonymizerOptions.terms || []
|
||||||
|
);
|
||||||
|
const anonymizer = new AnonymizeTransformer(opt.anonymizerOptions);
|
||||||
|
anonymizer.opt.filePath = fileName;
|
||||||
|
const st = entry.pipe(anonymizer);
|
||||||
|
archive.append(st, { name: fileName });
|
||||||
|
} catch (error) {
|
||||||
|
entry.autodrain();
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry.autodrain();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("error", (error: Error) => {
|
||||||
|
console.error(error);
|
||||||
|
try {
|
||||||
|
archive.finalize();
|
||||||
|
} catch {
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("finish", () => {
|
||||||
|
try {
|
||||||
|
archive.finalize();
|
||||||
|
} catch {
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(res).on("error", (error) => {
|
||||||
|
console.error(error);
|
||||||
|
(res as { end?: () => void }).end?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import * as compression from "compression";
|
|||||||
import * as passport from "passport";
|
import * as passport from "passport";
|
||||||
import { connect } from "./database";
|
import { connect } from "./database";
|
||||||
import { initSession, router as connectionRouter } from "./routes/connection";
|
import { initSession, router as connectionRouter } from "./routes/connection";
|
||||||
|
import { bearerTokenAuth } from "./routes/token-auth";
|
||||||
import router from "./routes";
|
import router from "./routes";
|
||||||
import AnonymizedRepositoryModel from "../core/model/anonymizedRepositories/anonymizedRepositories.model";
|
import AnonymizedRepositoryModel from "../core/model/anonymizedRepositories/anonymizedRepositories.model";
|
||||||
import { conferenceStatusCheck, repositoryStatusCheck } from "./schedule";
|
import { conferenceStatusCheck, repositoryStatusCheck } from "./schedule";
|
||||||
@@ -56,6 +57,7 @@ export default async function start() {
|
|||||||
app.use(initSession());
|
app.use(initSession());
|
||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
app.use(passport.session());
|
app.use(passport.session());
|
||||||
|
app.use(bearerTokenAuth);
|
||||||
|
|
||||||
startWorker();
|
startWorker();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import * as express from "express";
|
||||||
|
import { handleError, getUser, isOwnerOrAdmin } from "./route-utils";
|
||||||
|
import UserModel from "../../core/model/users/users.model";
|
||||||
|
import { generateToken, hashToken } from "./token-auth";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await getUser(req);
|
||||||
|
isOwnerOrAdmin([], user);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await getUser(req);
|
||||||
|
const model = await UserModel.findById(user.model.id);
|
||||||
|
if (!model) return res.status(404).json({ error: "user_not_found" });
|
||||||
|
const tokens = (model.apiTokens || []).map((t) => ({
|
||||||
|
id: t._id,
|
||||||
|
name: t.name,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
lastUsedAt: t.lastUsedAt,
|
||||||
|
}));
|
||||||
|
res.json(tokens);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await getUser(req);
|
||||||
|
const name = (req.body?.name || "").toString().trim() || "unnamed";
|
||||||
|
const plaintext = generateToken();
|
||||||
|
const tokenHash = hashToken(plaintext);
|
||||||
|
|
||||||
|
const model = await UserModel.findById(user.model.id);
|
||||||
|
if (!model) return res.status(404).json({ error: "user_not_found" });
|
||||||
|
if (!model.apiTokens) model.apiTokens = [];
|
||||||
|
model.apiTokens.push({
|
||||||
|
tokenHash,
|
||||||
|
name,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await model.save();
|
||||||
|
|
||||||
|
const created = model.apiTokens[model.apiTokens.length - 1];
|
||||||
|
res.json({
|
||||||
|
id: created._id,
|
||||||
|
name: created.name,
|
||||||
|
createdAt: created.createdAt,
|
||||||
|
token: plaintext,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await getUser(req);
|
||||||
|
const result = await UserModel.updateOne(
|
||||||
|
{ _id: user.model.id },
|
||||||
|
{ $pull: { apiTokens: { _id: req.params.id } } }
|
||||||
|
);
|
||||||
|
res.json({ removed: result.modifiedCount });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -9,6 +9,7 @@ import Repository from "../../core/Repository";
|
|||||||
import User from "../../core/User";
|
import User from "../../core/User";
|
||||||
import { ensureAuthenticated } from "./connection";
|
import { ensureAuthenticated } from "./connection";
|
||||||
import { handleError, getUser, isOwnerOrAdmin, getRepo } from "./route-utils";
|
import { handleError, getUser, isOwnerOrAdmin, getRepo } from "./route-utils";
|
||||||
|
import adminTokensRouter from "./admin-tokens";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ router.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.use("/tokens", adminTokensRouter);
|
||||||
|
|
||||||
router.post("/queue/:name/:repo_id", async (req, res) => {
|
router.post("/queue/:name/:repo_id", async (req, res) => {
|
||||||
let queue: Queue<Repository, void>;
|
let queue: Queue<Repository, void>;
|
||||||
if (req.params.name == "download") {
|
if (req.params.name == "download") {
|
||||||
|
|||||||
@@ -31,13 +31,23 @@ const verify = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
let user: IUserDocument | null;
|
let user: IUserDocument | null;
|
||||||
try {
|
try {
|
||||||
|
const now = new Date();
|
||||||
user = await UserModel.findOne({ "externalIDs.github": profile.id });
|
user = await UserModel.findOne({ "externalIDs.github": profile.id });
|
||||||
if (user) {
|
if (user) {
|
||||||
user.accessTokens.github = accessToken;
|
await UserModel.updateOne(
|
||||||
|
{ _id: user._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
"accessTokens.github": accessToken,
|
||||||
|
"accessTokenDates.github": now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
await AnonymizedPullRequestModel.updateMany(
|
await AnonymizedPullRequestModel.updateMany(
|
||||||
{ owner: user._id },
|
{ owner: user._id },
|
||||||
{ "source.accessToken": accessToken }
|
{ "source.accessToken": accessToken }
|
||||||
);
|
);
|
||||||
|
user = await UserModel.findById(user._id);
|
||||||
} else {
|
} else {
|
||||||
// Check if a user with this username already exists (e.g. created
|
// Check if a user with this username already exists (e.g. created
|
||||||
// manually without externalIDs.github). Link the GitHub ID to the
|
// manually without externalIDs.github). Link the GitHub ID to the
|
||||||
@@ -45,8 +55,17 @@ const verify = async (
|
|||||||
// the isAdmin flag.
|
// the isAdmin flag.
|
||||||
user = await UserModel.findOne({ username: profile.username });
|
user = await UserModel.findOne({ username: profile.username });
|
||||||
if (user) {
|
if (user) {
|
||||||
user.externalIDs.github = profile.id;
|
await UserModel.updateOne(
|
||||||
user.accessTokens.github = accessToken;
|
{ _id: user._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
"externalIDs.github": profile.id,
|
||||||
|
"accessTokens.github": accessToken,
|
||||||
|
"accessTokenDates.github": now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
user = await UserModel.findById(user._id);
|
||||||
} else {
|
} else {
|
||||||
const photo = profile.photos ? profile.photos[0]?.value : null;
|
const photo = profile.photos ? profile.photos[0]?.value : null;
|
||||||
user = new UserModel({
|
user = new UserModel({
|
||||||
@@ -54,6 +73,9 @@ const verify = async (
|
|||||||
accessTokens: {
|
accessTokens: {
|
||||||
github: accessToken,
|
github: accessToken,
|
||||||
},
|
},
|
||||||
|
accessTokenDates: {
|
||||||
|
github: now,
|
||||||
|
},
|
||||||
externalIDs: {
|
externalIDs: {
|
||||||
github: profile.id,
|
github: profile.id,
|
||||||
},
|
},
|
||||||
@@ -63,16 +85,9 @@ const verify = async (
|
|||||||
photo,
|
photo,
|
||||||
});
|
});
|
||||||
if (user.emails?.length) user.emails[0].default = true;
|
if (user.emails?.length) user.emails[0].default = true;
|
||||||
|
await user.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!user.accessTokenDates) {
|
|
||||||
user.accessTokenDates = {
|
|
||||||
github: new Date(),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
user.accessTokenDates.github = new Date();
|
|
||||||
}
|
|
||||||
await user.save();
|
|
||||||
done(null, {
|
done(null, {
|
||||||
username: profile.username,
|
username: profile.username,
|
||||||
accessToken,
|
accessToken,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import User from "../../core/User";
|
|||||||
import { RepositoryStatus } from "../../core/types";
|
import { RepositoryStatus } from "../../core/types";
|
||||||
import { IUserDocument } from "../../core/model/users/users.types";
|
import { IUserDocument } from "../../core/model/users/users.types";
|
||||||
import { checkToken } from "../../core/GitHubUtils";
|
import { checkToken } from "../../core/GitHubUtils";
|
||||||
import config from "../../config";
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -404,20 +403,6 @@ router.post(
|
|||||||
httpStatus: 404,
|
httpStatus: 404,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (repository.size) {
|
|
||||||
if (
|
|
||||||
repository.size > config.AUTO_DOWNLOAD_REPO_SIZE &&
|
|
||||||
repo.model.source.type == "GitHubDownload"
|
|
||||||
) {
|
|
||||||
repo.model.source.type = "GitHubStream";
|
|
||||||
} else if (
|
|
||||||
repository.size < config.AUTO_DOWNLOAD_REPO_SIZE &&
|
|
||||||
repo.model.source.type == "GitHubStream"
|
|
||||||
) {
|
|
||||||
repo.model.source.type = "GitHubDownload";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeRepoFromConference = async (conferenceID: string) => {
|
const removeRepoFromConference = async (conferenceID: string) => {
|
||||||
const conf = await ConferenceModel.findOne({
|
const conf = await ConferenceModel.findOne({
|
||||||
conferenceID,
|
conferenceID,
|
||||||
@@ -528,25 +513,6 @@ router.post("/", async (req: express.Request, res: express.Response) => {
|
|||||||
repo.source.accessToken = user.accessToken;
|
repo.source.accessToken = user.accessToken;
|
||||||
repo.source.repositoryId = repository.model.id;
|
repo.source.repositoryId = repository.model.id;
|
||||||
repo.source.repositoryName = repoUpdate.fullName;
|
repo.source.repositoryName = repoUpdate.fullName;
|
||||||
if (
|
|
||||||
repository.size !== undefined &&
|
|
||||||
repository.size < config.AUTO_DOWNLOAD_REPO_SIZE
|
|
||||||
) {
|
|
||||||
repo.source.type = "GitHubDownload";
|
|
||||||
}
|
|
||||||
if (repository.size) {
|
|
||||||
if (
|
|
||||||
repository.size > config.AUTO_DOWNLOAD_REPO_SIZE &&
|
|
||||||
repo.source.type == "GitHubDownload"
|
|
||||||
) {
|
|
||||||
repo.source.type = "GitHubStream";
|
|
||||||
} else if (
|
|
||||||
repository.size < config.AUTO_DOWNLOAD_REPO_SIZE &&
|
|
||||||
repo.source.type == "GitHubStream"
|
|
||||||
) {
|
|
||||||
repo.source.type = "GitHubDownload";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repo.conference = repoUpdate.conference;
|
repo.conference = repoUpdate.conference;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { promisify } from "util";
|
|
||||||
import * as express from "express";
|
import * as express from "express";
|
||||||
import * as stream from "stream";
|
|
||||||
import config from "../../config";
|
import config from "../../config";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
@@ -10,14 +8,14 @@ import AnonymousError from "../../core/AnonymousError";
|
|||||||
import { downloadQueue } from "../../queue";
|
import { downloadQueue } from "../../queue";
|
||||||
import { RepositoryStatus } from "../../core/types";
|
import { RepositoryStatus } from "../../core/types";
|
||||||
import User from "../../core/User";
|
import User from "../../core/User";
|
||||||
|
import { streamAnonymizedZip } from "../../core/zipStream";
|
||||||
|
import gh = require("parse-github-url");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/:repoId/zip",
|
"/:repoId/zip",
|
||||||
async (req: express.Request, res: express.Response) => {
|
async (req: express.Request, res: express.Response) => {
|
||||||
const pipeline = promisify(stream.pipeline);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!config.ENABLE_DOWNLOAD) {
|
if (!config.ENABLE_DOWNLOAD) {
|
||||||
throw new AnonymousError("download_not_enabled", {
|
throw new AnonymousError("download_not_enabled", {
|
||||||
@@ -87,10 +85,28 @@ router.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.attachment(`${repo.repoId}.zip`);
|
res.attachment(`${repo.repoId}.zip`);
|
||||||
|
|
||||||
// cache the file for 6 hours
|
// cache the file for 6 hours
|
||||||
res.header("Cache-Control", "max-age=21600");
|
res.header("Cache-Control", "max-age=21600");
|
||||||
await pipeline(await repo.zip(), res);
|
|
||||||
|
const parsed = gh(repo.model.source.repositoryName || "");
|
||||||
|
if (!parsed?.owner || !parsed?.name) {
|
||||||
|
throw new AnonymousError("repo_not_found", {
|
||||||
|
httpStatus: 404,
|
||||||
|
object: repo.model.source.repositoryName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const anonymizer = repo.generateAnonymizeTransformer("");
|
||||||
|
await streamAnonymizedZip(
|
||||||
|
{
|
||||||
|
repoId: repo.repoId,
|
||||||
|
organization: parsed.owner,
|
||||||
|
repoName: parsed.name,
|
||||||
|
commit: repo.model.source.commit || "HEAD",
|
||||||
|
getToken: () => repo.getToken(),
|
||||||
|
anonymizerOptions: anonymizer.opt,
|
||||||
|
},
|
||||||
|
res
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, res, req);
|
handleError(error, res, req);
|
||||||
}
|
}
|
||||||
@@ -197,6 +213,7 @@ router.get(
|
|||||||
isAdmin: user?.isAdmin === true,
|
isAdmin: user?.isAdmin === true,
|
||||||
isOwner: user?.id == repo.model.owner,
|
isOwner: user?.id == repo.model.owner,
|
||||||
hasWebsite: !!repo.options.page && !!repo.options.pageSource,
|
hasWebsite: !!repo.options.page && !!repo.options.pageSource,
|
||||||
|
truncatedFolders: repo.model.truncatedFolders || [],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, res, req);
|
handleError(error, res, req);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as express from "express";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import UserModel from "../../core/model/users/users.model";
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateToken(): string {
|
||||||
|
return crypto.randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bearerTokenAuth(
|
||||||
|
req: express.Request,
|
||||||
|
_res: express.Response,
|
||||||
|
next: express.NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
if (req.user) return next();
|
||||||
|
|
||||||
|
const header = req.headers["authorization"];
|
||||||
|
if (!header || typeof header !== "string") return next();
|
||||||
|
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (!match) return next();
|
||||||
|
|
||||||
|
const tokenHash = hashToken(match[1].trim());
|
||||||
|
try {
|
||||||
|
const model = await UserModel.findOne({ "apiTokens.tokenHash": tokenHash });
|
||||||
|
if (!model) return next();
|
||||||
|
|
||||||
|
// Mirror the shape produced by passport's verify() in connection.ts
|
||||||
|
// so existing getUser()/route code works unchanged.
|
||||||
|
req.user = {
|
||||||
|
username: model.username,
|
||||||
|
user: model,
|
||||||
|
} as Express.User;
|
||||||
|
|
||||||
|
// fire-and-forget last-used update
|
||||||
|
UserModel.updateOne(
|
||||||
|
{ _id: model._id, "apiTokens.tokenHash": tokenHash },
|
||||||
|
{ $set: { "apiTokens.$.lastUsedAt": new Date() } }
|
||||||
|
).catch((err) => console.error("[token-auth] lastUsedAt update failed", err));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[token-auth] lookup failed", err);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
+12
-72
@@ -1,16 +1,12 @@
|
|||||||
import * as express from "express";
|
import * as express from "express";
|
||||||
import GitHubStream from "../core/source/GitHubStream";
|
import GitHubStream from "../core/source/GitHubStream";
|
||||||
import {
|
import {
|
||||||
anonymizePath,
|
|
||||||
AnonymizeTransformer,
|
AnonymizeTransformer,
|
||||||
isTextFile,
|
isTextFile,
|
||||||
} from "../core/anonymize-utils";
|
} from "../core/anonymize-utils";
|
||||||
import { handleError } from "../server/routes/route-utils";
|
import { handleError } from "../server/routes/route-utils";
|
||||||
import { lookup } from "mime-types";
|
import { lookup } from "mime-types";
|
||||||
import GitHubDownload from "../core/source/GitHubDownload";
|
import { streamAnonymizedZip } from "../core/zipStream";
|
||||||
import got from "got";
|
|
||||||
import { Parse } from "unzip-stream";
|
|
||||||
import archiver = require("archiver");
|
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -24,73 +20,17 @@ router.post(
|
|||||||
const anonymizerOptions = req.body.anonymizerOptions;
|
const anonymizerOptions = req.body.anonymizerOptions;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const source = new GitHubDownload({
|
await streamAnonymizedZip(
|
||||||
repoId,
|
{
|
||||||
organization: repoFullName[0],
|
repoId,
|
||||||
repoName: repoFullName[1],
|
organization: repoFullName[0],
|
||||||
commit: commit,
|
repoName: repoFullName[1],
|
||||||
getToken: () => token,
|
commit,
|
||||||
});
|
getToken: () => token,
|
||||||
const response = await source.getZipUrl();
|
anonymizerOptions,
|
||||||
const downloadStream = got.stream(response.url);
|
},
|
||||||
|
res
|
||||||
res.on("error", (error) => {
|
);
|
||||||
console.error(error);
|
|
||||||
downloadStream.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on("close", () => {
|
|
||||||
downloadStream.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
const archive = archiver("zip", {});
|
|
||||||
downloadStream
|
|
||||||
.on("error", (error) => {
|
|
||||||
console.error(error);
|
|
||||||
try {
|
|
||||||
archive.finalize();
|
|
||||||
} catch { /* ignored */ }
|
|
||||||
})
|
|
||||||
.on("close", () => {
|
|
||||||
try {
|
|
||||||
archive.finalize();
|
|
||||||
} catch { /* ignored */ }
|
|
||||||
})
|
|
||||||
.pipe(Parse())
|
|
||||||
.on("entry", (entry) => {
|
|
||||||
if (entry.type === "File") {
|
|
||||||
try {
|
|
||||||
const fileName = anonymizePath(
|
|
||||||
entry.path.substring(entry.path.indexOf("/") + 1),
|
|
||||||
anonymizerOptions.terms || []
|
|
||||||
);
|
|
||||||
const anonymizer = new AnonymizeTransformer(anonymizerOptions);
|
|
||||||
anonymizer.opt.filePath = fileName;
|
|
||||||
const st = entry.pipe(anonymizer);
|
|
||||||
archive.append(st, { name: fileName });
|
|
||||||
} catch (error) {
|
|
||||||
entry.autodrain();
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
entry.autodrain();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on("error", (error) => {
|
|
||||||
console.error(error);
|
|
||||||
try {
|
|
||||||
archive.finalize();
|
|
||||||
} catch { /* ignored */ }
|
|
||||||
})
|
|
||||||
.on("finish", () => {
|
|
||||||
try {
|
|
||||||
archive.finalize();
|
|
||||||
} catch { /* ignored */ }
|
|
||||||
});
|
|
||||||
archive.pipe(res).on("error", (error) => {
|
|
||||||
console.error(error);
|
|
||||||
res.end();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, res);
|
handleError(error, res);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const { expect } = require("chai");
|
const { expect } = require("chai");
|
||||||
|
const { Transform } = require("stream");
|
||||||
|
const { StringDecoder } = require("string_decoder");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for the core anonymization utilities.
|
* Tests for the core anonymization utilities.
|
||||||
@@ -393,6 +395,120 @@ describe("ContentAnonimizer", function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AnonymizeTransformer (streaming) — replica of src/core/anonymize-utils.ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AnonymizeTransformer extends Transform {
|
||||||
|
constructor(opt) {
|
||||||
|
super();
|
||||||
|
this.opt = opt || {};
|
||||||
|
this.isText = true; // tests always feed text
|
||||||
|
this.anonimizer = new ContentAnonimizer(this.opt);
|
||||||
|
this.decoder = new StringDecoder("utf8");
|
||||||
|
this.pending = "";
|
||||||
|
}
|
||||||
|
static OVERLAP = 4096;
|
||||||
|
|
||||||
|
_transform(chunk, encoding, callback) {
|
||||||
|
if (!this.isText) {
|
||||||
|
this.push(chunk);
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
this.pending += this.decoder.write(chunk);
|
||||||
|
if (this.pending.length > AnonymizeTransformer.OVERLAP) {
|
||||||
|
let split = this.pending.length - AnonymizeTransformer.OVERLAP;
|
||||||
|
const code = this.pending.charCodeAt(split);
|
||||||
|
if (code >= 0xdc00 && code <= 0xdfff) split -= 1;
|
||||||
|
const toProcess = this.pending.slice(0, split);
|
||||||
|
this.pending = this.pending.slice(split);
|
||||||
|
const out = this.anonimizer.anonymize(toProcess);
|
||||||
|
this.push(Buffer.from(out, "utf8"));
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
_flush(callback) {
|
||||||
|
if (this.isText) {
|
||||||
|
this.pending += this.decoder.end();
|
||||||
|
if (this.pending) {
|
||||||
|
const out = this.anonimizer.anonymize(this.pending);
|
||||||
|
this.pending = "";
|
||||||
|
this.push(Buffer.from(out, "utf8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStream(input, chunkSize, opt) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = new AnonymizeTransformer(opt);
|
||||||
|
const out = [];
|
||||||
|
t.on("data", (b) => out.push(Buffer.from(b)));
|
||||||
|
t.on("end", () => resolve(Buffer.concat(out).toString("utf8")));
|
||||||
|
t.on("error", reject);
|
||||||
|
const buf = Buffer.from(input, "utf8");
|
||||||
|
for (let i = 0; i < buf.length; i += chunkSize) {
|
||||||
|
t.write(buf.slice(i, Math.min(i + chunkSize, buf.length)));
|
||||||
|
}
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AnonymizeTransformer (streaming)", function () {
|
||||||
|
it("replaces all occurrences of a term across many small chunks", async function () {
|
||||||
|
// Reproduces the bug: 'Created by Alice at YYYY/MM/DD' lines split across
|
||||||
|
// chunk boundaries previously failed to match after the first ~14
|
||||||
|
// occurrences when the stream's default 16 KiB chunking aligned mid-term.
|
||||||
|
const line = "Created by Alice at 2025/01/01\n" + "x".repeat(1000) + "\n";
|
||||||
|
const input = line.repeat(50);
|
||||||
|
const expectedCount = 50;
|
||||||
|
|
||||||
|
const result = await runStream(input, 1024, { terms: ["Alice"] });
|
||||||
|
const matches = result.match(/XXXX-1/g) || [];
|
||||||
|
expect(matches.length).to.equal(expectedCount);
|
||||||
|
expect(result).to.not.include("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a term that lands exactly on a chunk boundary", async function () {
|
||||||
|
// Force the term 'Alice' to be split between two writes.
|
||||||
|
const prefix = "header ";
|
||||||
|
const term = "Alice";
|
||||||
|
const suffix = " trailer";
|
||||||
|
const input = prefix + term + suffix;
|
||||||
|
|
||||||
|
// First chunk ends after 'Ali', second starts at 'ce'
|
||||||
|
const splitAt = prefix.length + 3;
|
||||||
|
const t = new AnonymizeTransformer({ terms: ["Alice"] });
|
||||||
|
const out = [];
|
||||||
|
const done = new Promise((resolve, reject) => {
|
||||||
|
t.on("data", (b) => out.push(Buffer.from(b)));
|
||||||
|
t.on("end", () => resolve(Buffer.concat(out).toString("utf8")));
|
||||||
|
t.on("error", reject);
|
||||||
|
});
|
||||||
|
t.write(Buffer.from(input.slice(0, splitAt), "utf8"));
|
||||||
|
t.write(Buffer.from(input.slice(splitAt), "utf8"));
|
||||||
|
t.end();
|
||||||
|
|
||||||
|
const result = await done;
|
||||||
|
expect(result).to.equal("header XXXX-1 trailer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves byte content for non-anonymized streams", async function () {
|
||||||
|
const input = "no terms match here\n".repeat(100);
|
||||||
|
const result = await runStream(input, 64, { terms: ["zzzz"] });
|
||||||
|
expect(result).to.equal(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes remaining buffered content on end", async function () {
|
||||||
|
// Total input smaller than OVERLAP — must still be processed in _flush.
|
||||||
|
const input = "Created by Alice at 2025/01/01";
|
||||||
|
const result = await runStream(input, 8, { terms: ["Alice"] });
|
||||||
|
expect(result).to.equal("Created by XXXX-1 at 2025/01/01");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// anonymizePath
|
// anonymizePath
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user