Skip to content

Commit 01eb6ef

Browse files
authored
feat(): clean up CI scripts and add PNA middleware (#393)
1 parent a4e35df commit 01eb6ef

File tree

11 files changed

+181
-18
lines changed

11 files changed

+181
-18
lines changed

.github/workflows/build.yml

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,15 @@ on:
66
workflow_dispatch:
77
inputs:
88
tag:
9-
description: 'Tag/version to build (e.g. v2.7.0 or test-build)'
9+
description: 'Tag/version to build (e.g. v2.7.0). Leave empty for commit-based version.'
1010
required: false
1111
type: string
12-
default: 'dev'
12+
default: ''
1313
test_build:
14-
description: 'Test build only (no release created)'
14+
description: 'Test build only (no release created, uses test-signing)'
1515
required: false
1616
type: boolean
1717
default: false
18-
signing_policy:
19-
description: 'SignPath signing policy'
20-
required: false
21-
type: string
22-
default: 'release-signing'
2318
workflow_call:
2419
inputs:
2520
tag:
@@ -127,6 +122,10 @@ jobs:
127122
- name: Write release version
128123
run: |
129124
VERSION="${BUILD_TAG#v}"
125+
# For test builds without a real version tag, use commit hash
126+
if [[ ! "$VERSION" =~ ^[0-9] ]]; then
127+
VERSION="$(git rev-parse --short HEAD)-dev"
128+
fi
130129
echo "Version: $VERSION"
131130
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
132131
- name: Set up Go
@@ -191,36 +190,36 @@ jobs:
191190
path: _build/windows_${{matrix.arch}}/Output/zaparoo-setup.exe
192191
retention-days: 30
193192
- name: Sign exe file
194-
if: matrix.platform == 'windows' && !inputs.test_build
193+
if: matrix.platform == 'windows'
195194
uses: signpath/github-action-submit-signing-request@v2
196195
with:
197196
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
198197
organization-id: '${{ secrets.SIGNPATH_ORGANIZATION_ID }}'
199198
project-slug: 'zaparoo-core'
200-
signing-policy-slug: '${{ inputs.signing_policy || ''release-signing'' }}'
199+
signing-policy-slug: '${{ inputs.test_build && ''test-signing'' || ''release-signing'' }}'
201200
artifact-configuration-slug: 'windows-zip'
202201
github-artifact-id: '${{ steps.upload-unsigned-exe.outputs.artifact-id }}'
203202
wait-for-completion: true
204203
output-artifact-directory: '_build/signed/exe_${{matrix.arch}}'
205204
- name: Sign installer EXE
206-
if: matrix.platform == 'windows' && !inputs.test_build
205+
if: matrix.platform == 'windows'
207206
uses: signpath/github-action-submit-signing-request@v2
208207
with:
209208
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
210209
organization-id: '${{ secrets.SIGNPATH_ORGANIZATION_ID }}'
211210
project-slug: 'zaparoo-core'
212-
signing-policy-slug: '${{ inputs.signing_policy || ''release-signing'' }}'
211+
signing-policy-slug: '${{ inputs.test_build && ''test-signing'' || ''release-signing'' }}'
213212
artifact-configuration-slug: 'windows-installer'
214213
github-artifact-id: '${{ steps.upload-unsigned-installer.outputs.artifact-id }}'
215214
wait-for-completion: true
216215
output-artifact-directory: '_build/signed/installer_${{matrix.arch}}'
217216
- name: Rename signed installer back to versioned name
218-
if: matrix.platform == 'windows' && !inputs.test_build
217+
if: matrix.platform == 'windows'
219218
run: |
220219
mv "_build/signed/installer_${{matrix.arch}}/zaparoo-setup.exe" \
221220
"_build/signed/installer_${{matrix.arch}}/zaparoo-${{matrix.arch}}-${VERSION}-setup.exe"
222221
- name: Repackage distribution ZIP with signed exe
223-
if: matrix.platform == 'windows' && !inputs.test_build
222+
if: matrix.platform == 'windows'
224223
run: |
225224
# Replace unsigned exe with signed exe in build directory
226225
cp "_build/signed/exe_${{matrix.arch}}/Zaparoo.exe" "_build/${{matrix.platform}}_${{matrix.arch}}/Zaparoo.exe"
@@ -300,6 +299,10 @@ jobs:
300299
- name: Write release version
301300
run: |
302301
VERSION="${BUILD_TAG#v}"
302+
# For test builds without a real version tag, use commit hash
303+
if [[ ! "$VERSION" =~ ^[0-9] ]]; then
304+
VERSION="$(git rev-parse --short HEAD)-dev"
305+
fi
303306
echo "Version: $VERSION"
304307
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
305308
- name: Install dependencies

pkg/api/methods/media.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,9 @@ func GenerateMediaDB(
223223
Indexing: true,
224224
})
225225

226+
db.MediaDB.TrackBackgroundOperation()
226227
go func() {
228+
defer db.MediaDB.BackgroundOperationDone()
227229
total, err := mediascanner.NewNamesIndex(indexCtx, pl, cfg, systems, db, func(status mediascanner.IndexStatus) {
228230
var desc string
229231
switch status.Step {

pkg/api/methods/methods_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,8 @@ func TestHandleGenerateMedia_SystemFiltering(t *testing.T) {
608608
mockMediaDB.On("UpdateLastGenerated").Return(nil).Maybe()
609609
mockMediaDB.On("Truncate").Return(nil).Maybe()
610610
mockMediaDB.On("RunBackgroundOptimization", mock.Anything).Return(nil).Maybe()
611+
mockMediaDB.On("TrackBackgroundOperation").Return().Maybe()
612+
mockMediaDB.On("BackgroundOperationDone").Return().Maybe()
611613

612614
// Mock total media count
613615
mockMediaDB.On("GetTotalMediaCount").Return(0, nil).Maybe()

pkg/api/server.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,21 @@ func buildDynamicAllowedOrigins(baseOrigins, localIPs []string, port int, custom
502502
return result
503503
}
504504

505+
// privateNetworkAccessMiddleware adds the Access-Control-Allow-Private-Network
506+
// header for preflight requests. This allows HTTPS websites (like zaparoo.app)
507+
// to connect to Zaparoo Core running on local network IPs.
508+
// See: https://wicg.github.io/private-network-access/
509+
func privateNetworkAccessMiddleware(next http.Handler) http.Handler {
510+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
511+
// Check if this is a preflight request asking for private network access
512+
if r.Method == http.MethodOptions &&
513+
r.Header.Get("Access-Control-Request-Private-Network") == "true" {
514+
w.Header().Set("Access-Control-Allow-Private-Network", "true")
515+
}
516+
next.ServeHTTP(w, r)
517+
})
518+
}
519+
505520
// broadcastNotifications consumes and broadcasts all incoming API
506521
// notifications to all connected clients.
507522
func broadcastNotifications(
@@ -790,6 +805,7 @@ func Start(
790805
AllowedHeaders: []string{"Accept", "Content-Type"},
791806
ExposedHeaders: []string{},
792807
}))
808+
r.Use(privateNetworkAccessMiddleware)
793809

794810
// Rate limiting only for API routes, not static assets
795811
apiRateLimitMiddleware := apimiddleware.HTTPRateLimitMiddleware(rateLimiter)

pkg/api/server_pna_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Zaparoo Core
2+
// Copyright (c) 2025 The Zaparoo Project Contributors.
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
//
5+
// This file is part of Zaparoo Core.
6+
//
7+
// Zaparoo Core is free software: you can redistribute it and/or modify
8+
// it under the terms of the GNU General Public License as published by
9+
// the Free Software Foundation, either version 3 of the License, or
10+
// (at your option) any later version.
11+
//
12+
// Zaparoo Core is distributed in the hope that it will be useful,
13+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
// GNU General Public License for more details.
16+
//
17+
// You should have received a copy of the GNU General Public License
18+
// along with Zaparoo Core. If not, see <http://www.gnu.org/licenses/>.
19+
20+
package api
21+
22+
import (
23+
"net/http"
24+
"net/http/httptest"
25+
"testing"
26+
27+
"github.com/stretchr/testify/assert"
28+
)
29+
30+
// TestPrivateNetworkAccessMiddleware verifies that the middleware correctly
31+
// handles Private Network Access preflight requests as specified in:
32+
// https://wicg.github.io/private-network-access/
33+
func TestPrivateNetworkAccessMiddleware(t *testing.T) {
34+
t.Parallel()
35+
36+
handler := privateNetworkAccessMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
37+
w.WriteHeader(http.StatusOK)
38+
}))
39+
40+
tests := []struct {
41+
name string
42+
method string
43+
requestPNAHeader string
44+
expectPNAResponseHeader bool
45+
}{
46+
{
47+
name: "OPTIONS_with_PNA_request_header",
48+
method: http.MethodOptions,
49+
requestPNAHeader: "true",
50+
expectPNAResponseHeader: true,
51+
},
52+
{
53+
name: "OPTIONS_without_PNA_request_header",
54+
method: http.MethodOptions,
55+
requestPNAHeader: "",
56+
expectPNAResponseHeader: false,
57+
},
58+
{
59+
name: "GET_with_PNA_request_header",
60+
method: http.MethodGet,
61+
requestPNAHeader: "true",
62+
expectPNAResponseHeader: false,
63+
},
64+
{
65+
name: "POST_with_PNA_request_header",
66+
method: http.MethodPost,
67+
requestPNAHeader: "true",
68+
expectPNAResponseHeader: false,
69+
},
70+
{
71+
name: "OPTIONS_with_PNA_false",
72+
method: http.MethodOptions,
73+
requestPNAHeader: "false",
74+
expectPNAResponseHeader: false,
75+
},
76+
}
77+
78+
for _, tt := range tests {
79+
t.Run(tt.name, func(t *testing.T) {
80+
t.Parallel()
81+
82+
req := httptest.NewRequest(tt.method, "/api", http.NoBody)
83+
if tt.requestPNAHeader != "" {
84+
req.Header.Set("Access-Control-Request-Private-Network", tt.requestPNAHeader)
85+
}
86+
87+
rec := httptest.NewRecorder()
88+
handler.ServeHTTP(rec, req)
89+
90+
pnaResponse := rec.Header().Get("Access-Control-Allow-Private-Network")
91+
if tt.expectPNAResponseHeader {
92+
assert.Equal(t, "true", pnaResponse,
93+
"expected Access-Control-Allow-Private-Network: true header")
94+
} else {
95+
assert.Empty(t, pnaResponse,
96+
"expected no Access-Control-Allow-Private-Network header")
97+
}
98+
})
99+
}
100+
}

pkg/database/database.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,8 @@ type MediaDBI interface {
309309
GetOptimizationStep() (string, error)
310310
RunBackgroundOptimization(statusCallback func(optimizing bool))
311311
WaitForBackgroundOperations()
312+
TrackBackgroundOperation()
313+
BackgroundOperationDone()
312314

313315
InvalidateCountCache() error
314316

pkg/database/mediadb/mediadb.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,19 @@ func (db *MediaDB) WaitForBackgroundOperations() {
17751775
db.backgroundOps.Wait()
17761776
}
17771777

1778+
// TrackBackgroundOperation increments the background operations counter.
1779+
// Call BackgroundOperationDone when the operation completes.
1780+
// This allows external code (like the indexing goroutine) to be tracked.
1781+
func (db *MediaDB) TrackBackgroundOperation() {
1782+
db.backgroundOps.Add(1)
1783+
}
1784+
1785+
// BackgroundOperationDone decrements the background operations counter.
1786+
// This should be called when an operation started with TrackBackgroundOperation completes.
1787+
func (db *MediaDB) BackgroundOperationDone() {
1788+
db.backgroundOps.Done()
1789+
}
1790+
17781791
// GetLaunchCommandForMedia generates a title-based launch command for the given media.
17791792
func (db *MediaDB) GetLaunchCommandForMedia(ctx context.Context, systemID, path string) (string, error) {
17801793
db.sqlMu.RLock()

pkg/readers/pn532/pn532.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,6 @@ func (r *Reader) processNewTag(detectedTag *pn532.DetectedTag, iq chan<- readers
405405

406406
func (r *Reader) Close() error {
407407
r.mutex.Lock()
408-
defer r.mutex.Unlock()
409408

410409
if r.cancel != nil {
411410
r.cancel()
@@ -414,13 +413,20 @@ func (r *Reader) Close() error {
414413
if r.session != nil {
415414
err := r.session.Close()
416415
if err != nil {
416+
r.mutex.Unlock()
417417
return fmt.Errorf("failed to close PN532 session: %w", err)
418418
}
419419
}
420420

421-
// Wait for session goroutine to complete
421+
r.mutex.Unlock()
422+
423+
// Wait for session goroutine to complete outside of lock to avoid deadlock.
424+
// The goroutines may need to acquire the mutex before they can exit.
422425
r.wg.Wait()
423426

427+
r.mutex.Lock()
428+
defer r.mutex.Unlock()
429+
424430
// Close the underlying device to release hardware resources
425431
if r.device != nil {
426432
err := r.device.Close()

pkg/testing/helpers/db_mocks.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,14 @@ func (m *MockMediaDBI) WaitForBackgroundOperations() {
10211021
m.Called()
10221022
}
10231023

1024+
func (m *MockMediaDBI) TrackBackgroundOperation() {
1025+
m.Called()
1026+
}
1027+
1028+
func (m *MockMediaDBI) BackgroundOperationDone() {
1029+
m.Called()
1030+
}
1031+
10241032
func (m *MockMediaDBI) SetIndexingStatus(status string) error {
10251033
args := m.Called(status)
10261034
if err := args.Error(0); err != nil {

scripts/tasks/utils/windowsiss/main.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ func main() {
4949
}
5050

5151
outputVersion := *version
52-
if strings.Contains(*version, "-") {
52+
// Inno Setup VersionInfoVersion requires a valid numeric version (e.g., 1.2.3)
53+
// Fall back to 0.0.0 for non-semver versions like "dev" or pre-release versions
54+
if strings.Contains(*version, "-") || !isValidSemver(*version) {
5355
*version = "0.0.0"
5456
}
5557

@@ -111,3 +113,12 @@ func generateSetupFile(
111113

112114
return nil
113115
}
116+
117+
// isValidSemver checks if a version string starts with a digit (basic semver check).
118+
// Returns false for versions like "dev", "latest", etc.
119+
func isValidSemver(version string) bool {
120+
if version == "" {
121+
return false
122+
}
123+
return version[0] >= '0' && version[0] <= '9'
124+
}

0 commit comments

Comments
 (0)