mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 14:53:42 +08:00
feat: implement authentication service and notification/settings handlers
This commit is contained in:
@@ -29,23 +29,15 @@ jobs:
|
||||
env:
|
||||
MAX_CHANGED_LINES: "5000"
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted base repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check conflicts and pull request size
|
||||
- name: Check conflicts and pull request size via GitHub API
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Checking PR #${PR_NUMBER}"
|
||||
echo "Base branch: ${BASE_REF}"
|
||||
|
||||
############################################################
|
||||
# Helper: comment on and close rejected PR
|
||||
@@ -94,25 +86,40 @@ jobs:
|
||||
}
|
||||
|
||||
############################################################
|
||||
# Fetch target branch and PR HEAD
|
||||
# Fetch PR metadata from GitHub REST API
|
||||
############################################################
|
||||
|
||||
echo "Fetching base branch and PR head..."
|
||||
echo "Fetching pull request metadata from GitHub API..."
|
||||
|
||||
git fetch --no-tags --force origin \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/base-pr-check" \
|
||||
"+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-${PR_NUMBER}"
|
||||
PR_JSON=""
|
||||
for attempt in {1..10}; do
|
||||
PR_JSON="$(
|
||||
curl \
|
||||
--fail-with-body \
|
||||
--silent \
|
||||
--show-error \
|
||||
--request GET \
|
||||
--header "Accept: application/vnd.github+json" \
|
||||
--header "Authorization: Bearer ${GH_TOKEN}" \
|
||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"
|
||||
)"
|
||||
|
||||
BASE_COMMIT="$(
|
||||
git rev-parse refs/remotes/origin/base-pr-check
|
||||
)"
|
||||
MERGEABLE="$(echo "${PR_JSON}" | jq -r '.mergeable')"
|
||||
if [[ "${MERGEABLE}" != "null" ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
PR_COMMIT="$(
|
||||
git rev-parse refs/remotes/origin/pr-${PR_NUMBER}
|
||||
)"
|
||||
echo "Mergeable state is calculating, waiting 2s (attempt ${attempt}/10)..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Base commit: ${BASE_COMMIT}"
|
||||
echo "PR commit: ${PR_COMMIT}"
|
||||
MERGEABLE="$(echo "${PR_JSON}" | jq -r '.mergeable')"
|
||||
ADDITIONS="$(echo "${PR_JSON}" | jq -r '.additions // 0')"
|
||||
DELETIONS="$(echo "${PR_JSON}" | jq -r '.deletions // 0')"
|
||||
CHANGED_FILES="$(echo "${PR_JSON}" | jq -r '.changed_files // 0')"
|
||||
|
||||
CHANGED_LINES=$((ADDITIONS + DELETIONS))
|
||||
|
||||
############################################################
|
||||
# STEP 1: Reject PRs with merge conflicts
|
||||
@@ -121,19 +128,7 @@ jobs:
|
||||
echo
|
||||
echo "Checking for merge conflicts..."
|
||||
|
||||
set +e
|
||||
|
||||
git merge-tree \
|
||||
--write-tree \
|
||||
--quiet \
|
||||
"${BASE_COMMIT}" \
|
||||
"${PR_COMMIT}"
|
||||
|
||||
MERGE_STATUS=$?
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "${MERGE_STATUS}" -eq 1 ]]; then
|
||||
if [[ "${MERGEABLE}" == "false" ]]; then
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
@@ -144,94 +139,9 @@ jobs:
|
||||
|
||||
reject_pr "This pull request has merge conflicts with the current master branch and cannot be accepted. Please update your branch with the latest master, resolve all merge conflicts locally, and submit a conflict-free pull request."
|
||||
|
||||
elif [[ "${MERGE_STATUS}" -ne 0 ]]; then
|
||||
|
||||
echo "::error::Unable to determine whether the pull request can be merged."
|
||||
echo "git merge-tree returned status ${MERGE_STATUS}."
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Merge conflict check: ⚠️ Error"
|
||||
echo "- Result: Check failed"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
echo "No merge conflicts detected."
|
||||
|
||||
############################################################
|
||||
# STEP 2: Determine merge base
|
||||
############################################################
|
||||
|
||||
if ! MERGE_BASE="$(
|
||||
git merge-base "${BASE_COMMIT}" "${PR_COMMIT}"
|
||||
)"; then
|
||||
|
||||
echo "::error::Unable to determine merge base."
|
||||
|
||||
{
|
||||
echo "### Pull request policy"
|
||||
echo
|
||||
echo "- Merge conflicts: ✅ None"
|
||||
echo "- Diff calculation: ⚠️ Failed"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
echo "Merge base: ${MERGE_BASE}"
|
||||
|
||||
############################################################
|
||||
# STEP 3: Calculate actual PR changed lines
|
||||
############################################################
|
||||
|
||||
NUMSTAT_FILE="$(mktemp)"
|
||||
|
||||
git diff \
|
||||
--no-ext-diff \
|
||||
--no-textconv \
|
||||
--numstat \
|
||||
"${MERGE_BASE}" \
|
||||
"${PR_COMMIT}" \
|
||||
> "${NUMSTAT_FILE}"
|
||||
|
||||
ADDITIONS="$(
|
||||
awk '
|
||||
$1 ~ /^[0-9]+$/ {
|
||||
total += $1
|
||||
}
|
||||
|
||||
END {
|
||||
print total + 0
|
||||
}
|
||||
' "${NUMSTAT_FILE}"
|
||||
)"
|
||||
|
||||
DELETIONS="$(
|
||||
awk '
|
||||
$2 ~ /^[0-9]+$/ {
|
||||
total += $2
|
||||
}
|
||||
|
||||
END {
|
||||
print total + 0
|
||||
}
|
||||
' "${NUMSTAT_FILE}"
|
||||
)"
|
||||
|
||||
CHANGED_FILES="$(
|
||||
awk '
|
||||
END {
|
||||
print NR + 0
|
||||
}
|
||||
' "${NUMSTAT_FILE}"
|
||||
)"
|
||||
|
||||
CHANGED_LINES=$((ADDITIONS + DELETIONS))
|
||||
echo "No merge conflicts detected (mergeable: ${MERGEABLE})."
|
||||
|
||||
############################################################
|
||||
# Action summary
|
||||
@@ -256,7 +166,7 @@ jobs:
|
||||
echo "Limit: ${MAX_CHANGED_LINES}"
|
||||
|
||||
############################################################
|
||||
# STEP 4: Reject oversized PRs
|
||||
# STEP 2: Reject oversized PRs
|
||||
############################################################
|
||||
|
||||
if (( CHANGED_LINES > MAX_CHANGED_LINES )); then
|
||||
|
||||
@@ -324,6 +324,10 @@ func hashPassword(password string, cost int) ([]byte, error) {
|
||||
material := []byte(password)
|
||||
longPassword := len(material) > bcryptPasswordLimit
|
||||
if longPassword {
|
||||
// SHA-256 here is strictly a fixed-length condenser for bcrypt's 72-byte limit,
|
||||
// not a standalone password hash. bcrypt provides the actual adaptive work factor.
|
||||
// codeql[go/weak-cryptographic-hash]
|
||||
// codeql[go/sensitive-data-hasher]
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
}
|
||||
@@ -340,6 +344,8 @@ func hashPassword(password string, cost int) ([]byte, error) {
|
||||
func comparePassword(passwordHash []byte, password string) error {
|
||||
material := []byte(password)
|
||||
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
|
||||
// codeql[go/weak-cryptographic-hash]
|
||||
// codeql[go/sensitive-data-hasher]
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
passwordHash = passwordHash[len(longPasswordHashPrefix):]
|
||||
|
||||
@@ -58,6 +58,8 @@ func writePlainTextMail(
|
||||
// encoded as MIME encoded-words/base64 above. The CodeQL email-injection
|
||||
// query intentionally has no sanitizer model, so document this audited sink.
|
||||
// codeql[go/email-injection]
|
||||
// CodeQL [go/email-injection]
|
||||
// lgtm[go/email-injection]
|
||||
if _, err := io.WriteString(writer, message); err != nil {
|
||||
return fmt.Errorf("write email message: %w", err)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const maxLarkPayloadBytes = 20 << 10
|
||||
@@ -128,7 +130,8 @@ func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := larkWebhookHosts[strings.ToLower(parsed.Hostname())]; !ok {
|
||||
canonicalHost := strings.ToLower(parsed.Hostname())
|
||||
if _, ok := larkWebhookHosts[canonicalHost]; !ok {
|
||||
return nil, errors.New("Lark group bot webhook must use open.feishu.cn or open.larksuite.com")
|
||||
}
|
||||
if parsed.Port() != "" && parsed.Port() != "443" {
|
||||
@@ -140,7 +143,11 @@ func parseLarkWebhookURL(raw string) (*url.URL, error) {
|
||||
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
||||
return nil, errors.New("Lark group bot webhook path is invalid")
|
||||
}
|
||||
return parsed, nil
|
||||
return &url.URL{
|
||||
Scheme: "https",
|
||||
Host: canonicalHost,
|
||||
Path: prefix + url.PathEscape(token),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateLarkWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
@@ -192,9 +199,15 @@ func larkAutomaticTaskValues(message automaticTaskNotification) larkTemplateValu
|
||||
}
|
||||
|
||||
func validateLarkNotificationConfig(config map[string]any) error {
|
||||
if configString(config, "url") == "" {
|
||||
rawURL := configString(config, "url")
|
||||
if rawURL == "" {
|
||||
return errors.New("lark.url is required")
|
||||
}
|
||||
if rawURL != store.SecretMask {
|
||||
if _, err := parseLarkWebhookURL(rawURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
template := configString(config, "payload_template")
|
||||
if template == "" {
|
||||
return errors.New("lark.payload_template is required")
|
||||
@@ -205,12 +218,10 @@ func validateLarkNotificationConfig(config map[string]any) error {
|
||||
return errors.New("lark.secret is required when signing is enabled")
|
||||
}
|
||||
}
|
||||
payload, err := renderLarkPayload(template, larkTestValues(time.Unix(0, 0)))
|
||||
if err != nil {
|
||||
if _, err := renderLarkPayload(template, larkTestValues(time.Now())); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = signLarkPayload(payload, larkSigningSecret(config), time.Unix(0, 0))
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func larkSigningSecret(config map[string]any) string {
|
||||
@@ -222,9 +233,6 @@ func larkSigningSecret(config map[string]any) string {
|
||||
}
|
||||
|
||||
func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error {
|
||||
if err := validateLarkNotificationConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := renderLarkPayload(configString(config, "payload_template"), values)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -251,6 +259,8 @@ func postLarkNotification(ctx context.Context, client *http.Client, endpoint str
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-lark-notification/1")
|
||||
// Target host is restricted to the Lark/Feishu webhook domain whitelist.
|
||||
// codeql[go/uncontrolled-data-in-network-request]
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err))
|
||||
|
||||
@@ -883,6 +883,8 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
|
||||
// Keep this call on one source line: CodeQL reports the interprocedural sink
|
||||
// at the writer argument, and suppression comments bind to that exact line.
|
||||
// codeql[go/email-injection]
|
||||
// CodeQL [go/email-injection]
|
||||
// lgtm[go/email-injection]
|
||||
if err := writePlainTextMail(writer, from, recipients, "vocat notification test", "This is a vocat notification test."); err != nil {
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("write SMTP test message: %w", err)
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
var wecomTemplateVariableNames = []string{
|
||||
@@ -25,6 +28,10 @@ var wecomTemplateVariableNames = []string{
|
||||
"time",
|
||||
}
|
||||
|
||||
var wecomWebhookHosts = map[string]struct{}{
|
||||
"qyapi.weixin.qq.com": {},
|
||||
}
|
||||
|
||||
type wecomTemplateValues map[string]string
|
||||
|
||||
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
||||
@@ -46,6 +53,46 @@ func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, er
|
||||
return []byte(template), nil
|
||||
}
|
||||
|
||||
func parseWecomWebhookURL(raw string) (*url.URL, error) {
|
||||
parsed, err := parseOutboundURL(raw, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonicalHost := strings.ToLower(parsed.Hostname())
|
||||
if _, ok := wecomWebhookHosts[canonicalHost]; !ok {
|
||||
return nil, errors.New("WeCom bot webhook must use qyapi.weixin.qq.com")
|
||||
}
|
||||
if parsed.Port() != "" && parsed.Port() != "443" {
|
||||
return nil, errors.New("WeCom bot webhook must use the default HTTPS port")
|
||||
}
|
||||
if parsed.Path != "/cgi-bin/webhook/send" {
|
||||
return nil, errors.New("WeCom bot webhook path must be /cgi-bin/webhook/send")
|
||||
}
|
||||
key := parsed.Query().Get("key")
|
||||
if key == "" || strings.ContainsAny(key, " \t\r\n/") {
|
||||
return nil, errors.New("WeCom bot webhook key parameter is missing or invalid")
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("key", key)
|
||||
return &url.URL{
|
||||
Scheme: "https",
|
||||
Host: canonicalHost,
|
||||
Path: "/cgi-bin/webhook/send",
|
||||
RawQuery: query.Encode(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateWecomWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
parsed, err := parseWecomWebhookURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateWecomResponse(status int, body []byte) error {
|
||||
var result struct {
|
||||
ErrCode *int `json:"errcode"`
|
||||
@@ -102,6 +149,13 @@ func validateWecomNotificationConfig(config map[string]any) error {
|
||||
if len(urls) > 8 {
|
||||
return errors.New("wecom.urls cannot contain more than 8 URLs")
|
||||
}
|
||||
for _, rawURL := range urls {
|
||||
if rawURL != store.SecretMask {
|
||||
if _, err := parseWecomWebhookURL(rawURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
template := configString(config, "payload_template")
|
||||
if template == "" {
|
||||
return errors.New("wecom.payload_template is required")
|
||||
@@ -120,7 +174,7 @@ func sendWecomNotification(ctx context.Context, config map[string]any, values we
|
||||
return err
|
||||
}
|
||||
for _, destination := range configStrings(config, "urls") {
|
||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||
parsed, err := validateWecomWebhookURL(ctx, destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -130,6 +184,8 @@ func sendWecomNotification(ctx context.Context, config map[string]any, values we
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
|
||||
// Target host is restricted to the WeCom webhook domain whitelist.
|
||||
// codeql[go/uncontrolled-data-in-network-request]
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send WeCom notification: %w", err)
|
||||
|
||||
@@ -263,7 +263,11 @@ func carrierProfilesSnapshot() []carrierProfileRule {
|
||||
}
|
||||
|
||||
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
||||
capacity := len(rule.MatchAny)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
capacity++
|
||||
}
|
||||
matches := make([]carrierProfileMatch, 0, capacity)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
matches = append(matches, rule.Match)
|
||||
}
|
||||
@@ -423,7 +427,11 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) {
|
||||
bestScore := -1
|
||||
bestSource := ""
|
||||
matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny))
|
||||
capacity := len(rule.MatchAny)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
capacity++
|
||||
}
|
||||
matches := make([]carrierProfileMatch, 0, capacity)
|
||||
if !emptyCarrierProfileMatch(rule.Match) {
|
||||
matches = append(matches, rule.Match)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user