1// Copyright 2019 Frédéric Guillot. All rights reserved.
2// Use of this source code is governed by the Apache 2.0
3// license that can be found in the LICENSE file.
4
5package config // import "miniflux.app/config"
6
7import (
8	"bufio"
9	"bytes"
10	"errors"
11	"fmt"
12	"io"
13	url_parser "net/url"
14	"os"
15	"strconv"
16	"strings"
17)
18
19// Parser handles configuration parsing.
20type Parser struct {
21	opts *Options
22}
23
24// NewParser returns a new Parser.
25func NewParser() *Parser {
26	return &Parser{
27		opts: NewOptions(),
28	}
29}
30
31// ParseEnvironmentVariables loads configuration values from environment variables.
32func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
33	err := p.parseLines(os.Environ())
34	if err != nil {
35		return nil, err
36	}
37	return p.opts, nil
38}
39
40// ParseFile loads configuration values from a local file.
41func (p *Parser) ParseFile(filename string) (*Options, error) {
42	fp, err := os.Open(filename)
43	if err != nil {
44		return nil, err
45	}
46	defer fp.Close()
47
48	err = p.parseLines(p.parseFileContent(fp))
49	if err != nil {
50		return nil, err
51	}
52	return p.opts, nil
53}
54
55func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
56	scanner := bufio.NewScanner(r)
57	for scanner.Scan() {
58		line := strings.TrimSpace(scanner.Text())
59		if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
60			lines = append(lines, line)
61		}
62	}
63	return lines
64}
65
66func (p *Parser) parseLines(lines []string) (err error) {
67	var port string
68
69	for _, line := range lines {
70		fields := strings.SplitN(line, "=", 2)
71		key := strings.TrimSpace(fields[0])
72		value := strings.TrimSpace(fields[1])
73
74		switch key {
75		case "LOG_DATE_TIME":
76			p.opts.logDateTime = parseBool(value, defaultLogDateTime)
77		case "DEBUG":
78			p.opts.debug = parseBool(value, defaultDebug)
79		case "SERVER_TIMING_HEADER":
80			p.opts.serverTimingHeader = parseBool(value, defaultTiming)
81		case "BASE_URL":
82			p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
83			if err != nil {
84				return err
85			}
86		case "PORT":
87			port = value
88		case "LISTEN_ADDR":
89			p.opts.listenAddr = parseString(value, defaultListenAddr)
90		case "DATABASE_URL":
91			p.opts.databaseURL = parseString(value, defaultDatabaseURL)
92		case "DATABASE_URL_FILE":
93			p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
94		case "DATABASE_MAX_CONNS":
95			p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
96		case "DATABASE_MIN_CONNS":
97			p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
98		case "DATABASE_CONNECTION_LIFETIME":
99			p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
100		case "RUN_MIGRATIONS":
101			p.opts.runMigrations = parseBool(value, defaultRunMigrations)
102		case "DISABLE_HSTS":
103			p.opts.hsts = !parseBool(value, defaultHSTS)
104		case "HTTPS":
105			p.opts.HTTPS = parseBool(value, defaultHTTPS)
106		case "DISABLE_SCHEDULER_SERVICE":
107			p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
108		case "DISABLE_HTTP_SERVICE":
109			p.opts.httpService = !parseBool(value, defaultHTTPService)
110		case "CERT_FILE":
111			p.opts.certFile = parseString(value, defaultCertFile)
112		case "KEY_FILE":
113			p.opts.certKeyFile = parseString(value, defaultKeyFile)
114		case "CERT_DOMAIN":
115			p.opts.certDomain = parseString(value, defaultCertDomain)
116		case "CLEANUP_FREQUENCY_HOURS":
117			p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
118		case "CLEANUP_ARCHIVE_READ_DAYS":
119			p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
120		case "CLEANUP_ARCHIVE_UNREAD_DAYS":
121			p.opts.cleanupArchiveUnreadDays = parseInt(value, defaultCleanupArchiveUnreadDays)
122		case "CLEANUP_ARCHIVE_BATCH_SIZE":
123			p.opts.cleanupArchiveBatchSize = parseInt(value, defaultCleanupArchiveBatchSize)
124		case "CLEANUP_REMOVE_SESSIONS_DAYS":
125			p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
126		case "WORKER_POOL_SIZE":
127			p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
128		case "POLLING_FREQUENCY":
129			p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
130		case "BATCH_SIZE":
131			p.opts.batchSize = parseInt(value, defaultBatchSize)
132		case "POLLING_SCHEDULER":
133			p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
134		case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
135			p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval)
136		case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
137			p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval)
138		case "POLLING_PARSING_ERROR_LIMIT":
139			p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
140		case "PROXY_IMAGES":
141			p.opts.proxyImages = parseString(value, defaultProxyImages)
142		case "CREATE_ADMIN":
143			p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
144		case "ADMIN_USERNAME":
145			p.opts.adminUsername = parseString(value, defaultAdminUsername)
146		case "ADMIN_USERNAME_FILE":
147			p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
148		case "ADMIN_PASSWORD":
149			p.opts.adminPassword = parseString(value, defaultAdminPassword)
150		case "ADMIN_PASSWORD_FILE":
151			p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
152		case "POCKET_CONSUMER_KEY":
153			p.opts.pocketConsumerKey = parseString(value, defaultPocketConsumerKey)
154		case "POCKET_CONSUMER_KEY_FILE":
155			p.opts.pocketConsumerKey = readSecretFile(value, defaultPocketConsumerKey)
156		case "OAUTH2_USER_CREATION":
157			p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
158		case "OAUTH2_CLIENT_ID":
159			p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
160		case "OAUTH2_CLIENT_ID_FILE":
161			p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
162		case "OAUTH2_CLIENT_SECRET":
163			p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
164		case "OAUTH2_CLIENT_SECRET_FILE":
165			p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
166		case "OAUTH2_REDIRECT_URL":
167			p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
168		case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
169			p.opts.oauth2OidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
170		case "OAUTH2_PROVIDER":
171			p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
172		case "HTTP_CLIENT_TIMEOUT":
173			p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
174		case "HTTP_CLIENT_MAX_BODY_SIZE":
175			p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
176		case "HTTP_CLIENT_PROXY":
177			p.opts.httpClientProxy = parseString(value, defaultHTTPClientProxy)
178		case "HTTP_CLIENT_USER_AGENT":
179			p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
180		case "AUTH_PROXY_HEADER":
181			p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
182		case "AUTH_PROXY_USER_CREATION":
183			p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
184		case "MAINTENANCE_MODE":
185			p.opts.maintenanceMode = parseBool(value, defaultMaintenanceMode)
186		case "MAINTENANCE_MESSAGE":
187			p.opts.maintenanceMessage = parseString(value, defaultMaintenanceMessage)
188		case "METRICS_COLLECTOR":
189			p.opts.metricsCollector = parseBool(value, defaultMetricsCollector)
190		case "METRICS_REFRESH_INTERVAL":
191			p.opts.metricsRefreshInterval = parseInt(value, defaultMetricsRefreshInterval)
192		case "METRICS_ALLOWED_NETWORKS":
193			p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
194		case "FETCH_YOUTUBE_WATCH_TIME":
195			p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
196		case "WATCHDOG":
197			p.opts.watchdog = parseBool(value, defaultWatchdog)
198		case "INVIDIOUS_INSTANCE":
199			p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
200		}
201	}
202
203	if port != "" {
204		p.opts.listenAddr = ":" + port
205	}
206	return nil
207}
208
209func parseBaseURL(value string) (string, string, string, error) {
210	if value == "" {
211		return defaultBaseURL, defaultRootURL, "", nil
212	}
213
214	if value[len(value)-1:] == "/" {
215		value = value[:len(value)-1]
216	}
217
218	url, err := url_parser.Parse(value)
219	if err != nil {
220		return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
221	}
222
223	scheme := strings.ToLower(url.Scheme)
224	if scheme != "https" && scheme != "http" {
225		return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
226	}
227
228	basePath := url.Path
229	url.Path = ""
230	return value, url.String(), basePath, nil
231}
232
233func parseBool(value string, fallback bool) bool {
234	if value == "" {
235		return fallback
236	}
237
238	value = strings.ToLower(value)
239	if value == "1" || value == "yes" || value == "true" || value == "on" {
240		return true
241	}
242
243	return false
244}
245
246func parseInt(value string, fallback int) int {
247	if value == "" {
248		return fallback
249	}
250
251	v, err := strconv.Atoi(value)
252	if err != nil {
253		return fallback
254	}
255
256	return v
257}
258
259func parseString(value string, fallback string) string {
260	if value == "" {
261		return fallback
262	}
263	return value
264}
265
266func parseStringList(value string, fallback []string) []string {
267	if value == "" {
268		return fallback
269	}
270
271	var strList []string
272	items := strings.Split(value, ",")
273	for _, item := range items {
274		strList = append(strList, strings.TrimSpace(item))
275	}
276
277	return strList
278}
279
280func readSecretFile(filename, fallback string) string {
281	data, err := os.ReadFile(filename)
282	if err != nil {
283		return fallback
284	}
285
286	value := string(bytes.TrimSpace(data))
287	if value == "" {
288		return fallback
289	}
290
291	return value
292}
293