Add per-application API Keys

This commit is contained in:
Frédéric Guillot 2020-03-01 17:38:29 -08:00
parent d1afe13a1c
commit 25cc0d2447
35 changed files with 940 additions and 71 deletions

View file

@ -17,7 +17,9 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool, feedHa
handler := &handler{store, pool, feedHandler}
sr := router.PathPrefix("/v1").Subrouter()
sr.Use(newMiddleware(store).serve)
middleware := newMiddleware(store)
sr.Use(middleware.apiKeyAuth)
sr.Use(middleware.basicAuth)
sr.HandleFunc("/users", handler.createUser).Methods("POST")
sr.HandleFunc("/users", handler.users).Methods("GET")
sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByID).Methods("GET")

View file

@ -22,39 +22,81 @@ func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
// BasicAuth handles HTTP basic authentication.
func (m *middleware) serve(next http.Handler) http.Handler {
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
token := r.Header.Get("X-Auth-Token")
if token == "" {
logger.Debug("[API][TokenAuth] [ClientIP=%s] No API Key provided, go to the next middleware", clientIP)
next.ServeHTTP(w, r)
return
}
user, err := m.store.UserByAPIKey(token)
if err != nil {
logger.Error("[API][TokenAuth] %v", err)
json.ServerError(w, r, err)
return
}
if user == nil {
logger.Error("[API][TokenAuth] [ClientIP=%s] No user found with the given API key", clientIP)
json.Unauthorized(w, r)
return
}
logger.Info("[API][TokenAuth] [ClientIP=%s] User authenticated: %s", clientIP, user.Username)
m.store.SetLastLogin(user.ID)
m.store.SetAPIKeyUsedTimestamp(user.ID, token)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *middleware) basicAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if request.IsAuthenticated(r) {
next.ServeHTTP(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
clientIP := request.ClientIP(r)
username, password, authOK := r.BasicAuth()
if !authOK {
logger.Debug("[API] No authentication headers sent")
logger.Debug("[API][BasicAuth] [ClientIP=%s] No authentication headers sent", clientIP)
json.Unauthorized(w, r)
return
}
if err := m.store.CheckPassword(username, password); err != nil {
logger.Error("[API] [ClientIP=%s] Invalid username or password: %s", clientIP, username)
logger.Error("[API][BasicAuth] [ClientIP=%s] Invalid username or password: %s", clientIP, username)
json.Unauthorized(w, r)
return
}
user, err := m.store.UserByUsername(username)
if err != nil {
logger.Error("[API] %v", err)
logger.Error("[API][BasicAuth] %v", err)
json.ServerError(w, r, err)
return
}
if user == nil {
logger.Error("[API] [ClientIP=%s] User not found: %s", clientIP, username)
logger.Error("[API][BasicAuth] [ClientIP=%s] User not found: %s", clientIP, username)
json.Unauthorized(w, r)
return
}
logger.Info("[API] User authenticated: %s", username)
logger.Info("[API][BasicAuth] [ClientIP=%s] User authenticated: %s", clientIP, username)
m.store.SetLastLogin(user.ID)
ctx := r.Context()

View file

@ -24,8 +24,12 @@ import (
)
func main() {
// Authentication with username/password:
client := miniflux.New("https://api.example.org", "admin", "secret")
// Authentication with an API Key:
client := miniflux.New("https://api.example.org", "my-secret-token")
// Fetch all feeds.
feeds, err := client.Feeds()
if err != nil {

View file

@ -18,6 +18,14 @@ type Client struct {
request *request
}
// New returns a new Miniflux client.
func New(endpoint string, credentials ...string) *Client {
if len(credentials) == 2 {
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
}
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
}
// Me returns the logged user information.
func (c *Client) Me() (*User, error) {
body, err := c.request.Get("/v1/me")
@ -448,11 +456,6 @@ func (c *Client) ToggleBookmark(entryID int64) error {
return nil
}
// New returns a new Miniflux client.
func New(endpoint, username, password string) *Client {
return &Client{request: &request{endpoint: endpoint, username: username, password: password}}
}
func buildFilterQueryString(path string, filter *Filter) string {
if filter != nil {
values := url.Values{}

View file

@ -38,6 +38,7 @@ type request struct {
endpoint string
username string
password string
apiKey string
}
func (r *request) Get(path string) (io.ReadCloser, error) {
@ -75,7 +76,10 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
Method: method,
Header: r.buildHeaders(),
}
if r.username != "" && r.password != "" {
request.SetBasicAuth(r.username, r.password)
}
if data != nil {
switch data.(type) {
@ -131,6 +135,9 @@ func (r *request) buildHeaders() http.Header {
headers.Add("User-Agent", userAgent)
headers.Add("Content-Type", "application/json")
headers.Add("Accept", "application/json")
if r.apiKey != "" {
headers.Add("X-Auth-Token", r.apiKey)
}
return headers
}

View file

@ -12,7 +12,7 @@ import (
"miniflux.app/logger"
)
const schemaVersion = 26
const schemaVersion = 27
// Migrate executes database migrations.
func Migrate(db *sql.DB) {

View file

@ -156,6 +156,17 @@ UPDATE users SET theme='dark_serif' WHERE theme='black';
"schema_version_26": `alter table entries add column changed_at timestamp with time zone;
update entries set changed_at = published_at;
alter table entries alter column changed_at set not null;
`,
"schema_version_27": `create table api_keys (
id serial not null,
user_id int not null references users(id) on delete cascade,
token text not null unique,
description text not null,
last_used_at timestamp with time zone,
created_at timestamp with time zone default now(),
primary key(id),
unique (user_id, description)
);
`,
"schema_version_3": `create table tokens (
id text not null,
@ -211,6 +222,7 @@ var SqlMapChecksums = map[string]string{
"schema_version_24": "1224754c5b9c6b4038599852bbe72656d21b09cb018d3970bd7c00f0019845bf",
"schema_version_25": "5262d2d4c88d637b6603a1fcd4f68ad257bd59bd1adf89c58a18ee87b12050d7",
"schema_version_26": "64f14add40691f18f514ac0eed10cd9b19c83a35e5c3d8e0bce667e0ceca9094",
"schema_version_27": "4235396b37fd7f52ff6f7526416042bb1649701233e2d99f0bcd583834a0a967",
"schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
"schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",
"schema_version_5": "46397e2f5f2c82116786127e9f6a403e975b14d2ca7b652a48cd1ba843e6a27c",

View file

@ -0,0 +1,10 @@
create table api_keys (
id serial not null,
user_id int not null references users(id) on delete cascade,
token text not null unique,
description text not null,
last_used_at timestamp with time zone,
created_at timestamp with time zone default now(),
primary key(id),
unique (user_id, description)
);

View file

@ -49,6 +49,8 @@ var translations = map[string]string{
"menu.add_user": "Benutzer anlegen",
"menu.flush_history": "Verlauf leeren",
"menu.feed_entries": "Artikel",
"menu.api_keys": "API-Schlüssel",
"menu.create_api_key": "Erstellen Sie einen neuen API-Schlüssel",
"search.label": "Suche",
"search.placeholder": "Suche...",
"pagination.next": "Nächste",
@ -176,6 +178,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "Benutzeragent",
"page.sessions.table.actions": "Aktionen",
"page.sessions.table.current_session": "Aktuelle Sitzung",
"page.api_keys.title": "API-Schlüssel",
"page.api_keys.table.description": "Beschreibung",
"page.api_keys.table.token": "Zeichen",
"page.api_keys.table.last_used_at": "Zuletzt verwendeten",
"page.api_keys.table.created_at": "Erstellungsdatum",
"page.api_keys.table.actions": "Aktionen",
"page.api_keys.never_used": "Nie benutzt",
"page.new_api_key.title": "Neuer API-Schlüssel",
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
@ -213,6 +223,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
"error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
"error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.",
"error.unable_to_create_api_key": "Dieser API-Schlüssel kann nicht erstellt werden.",
"form.feed.label.title": "Titel",
"form.feed.label.site_url": "Webseite-URL",
"form.feed.label.feed_url": "Abonnement-URL",
@ -262,6 +274,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
"form.api_key.label.description": "API-Schlüsselbezeichnung",
"form.submit.loading": "Lade...",
"form.submit.saving": "Speichern...",
"time_elapsed.not_yet": "noch nicht",
@ -359,6 +372,8 @@ var translations = map[string]string{
"menu.add_user": "Add user",
"menu.flush_history": "Flush history",
"menu.feed_entries": "Entries",
"menu.api_keys": "API Keys",
"menu.create_api_key": "Create a new API key",
"search.label": "Search",
"search.placeholder": "Search...",
"pagination.next": "Next",
@ -486,6 +501,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Actions",
"page.sessions.table.current_session": "Current Session",
"page.api_keys.title": "API Keys",
"page.api_keys.table.description": "Description",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Last Used",
"page.api_keys.table.created_at": "Creation Date",
"page.api_keys.table.actions": "Actions",
"page.api_keys.never_used": "Never Used",
"page.new_api_key.title": "New API Key",
"alert.no_bookmark": "There is no bookmark at the moment.",
"alert.no_category": "There is no category.",
"alert.no_category_entry": "There are no articles in this category.",
@ -523,6 +546,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.",
"error.feed_mandatory_fields": "The URL and the category are mandatory.",
"error.user_mandatory_fields": "The username is mandatory.",
"error.api_key_already_exists": "This API Key already exists.",
"error.unable_to_create_api_key": "Unable to create this API Key.",
"form.feed.label.title": "Title",
"form.feed.label.site_url": "Site URL",
"form.feed.label.feed_url": "Feed URL",
@ -572,6 +597,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "Save articles to Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.api_key.label.description": "API Key Label",
"form.submit.loading": "Loading...",
"form.submit.saving": "Saving...",
"time_elapsed.not_yet": "not yet",
@ -649,6 +675,8 @@ var translations = map[string]string{
"menu.add_user": "Agregar usuario",
"menu.flush_history": "Borrar historial",
"menu.feed_entries": "Artículos",
"menu.api_keys": "Claves API",
"menu.create_api_key": "Crear una nueva clave API",
"search.label": "Buscar",
"search.placeholder": "Búsqueda...",
"pagination.next": "Siguiente",
@ -776,6 +804,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "Agente de usuario",
"page.sessions.table.actions": "Acciones",
"page.sessions.table.current_session": "Sesión actual",
"page.api_keys.title": "Claves API",
"page.api_keys.table.description": "Descripción",
"page.api_keys.table.token": "simbólico",
"page.api_keys.table.last_used_at": "Último utilizado",
"page.api_keys.table.created_at": "Fecha de creación",
"page.api_keys.table.actions": "Acciones",
"page.api_keys.never_used": "Nunca usado",
"page.new_api_key.title": "Nueva clave API",
"alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.",
"alert.no_category_entry": "No hay artículos en esta categoria.",
@ -813,6 +849,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
"error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
"error.user_mandatory_fields": "El nombre de usuario es obligatorio.",
"error.api_key_already_exists": "Esta clave API ya existe.",
"error.unable_to_create_api_key": "No se puede crear esta clave API.",
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL del sitio",
"form.feed.label.feed_url": "URL de la fuente",
@ -862,6 +900,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "Guardar artículos a Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Extremo de API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
"form.api_key.label.description": "Etiqueta de clave API",
"form.submit.loading": "Cargando...",
"form.submit.saving": "Guardando...",
"time_elapsed.not_yet": "todavía no",
@ -939,6 +978,8 @@ var translations = map[string]string{
"menu.add_user": "Ajouter un utilisateur",
"menu.flush_history": "Supprimer l'historique",
"menu.feed_entries": "Articles",
"menu.api_keys": "Clés d'API",
"menu.create_api_key": "Créer une nouvelle clé d'API",
"search.label": "Recherche",
"search.placeholder": "Recherche...",
"pagination.next": "Suivant",
@ -1066,6 +1107,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "Navigateur Web",
"page.sessions.table.actions": "Actions",
"page.sessions.table.current_session": "Session actuelle",
"page.api_keys.title": "Clés d'API",
"page.api_keys.table.description": "Description",
"page.api_keys.table.token": "Jeton",
"page.api_keys.table.last_used_at": "Dernière utilisation",
"page.api_keys.table.created_at": "Date de création",
"page.api_keys.table.actions": "Actions",
"page.api_keys.never_used": "Jamais utilisé",
"page.new_api_key.title": "Nouvelle clé d'API",
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
"alert.no_category": "Il n'y a aucune catégorie.",
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
@ -1103,6 +1152,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
"error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
"error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
"error.api_key_already_exists": "Cette clé d'API existe déjà.",
"error.unable_to_create_api_key": "Impossible de créer cette clé d'API.",
"form.feed.label.title": "Titre",
"form.feed.label.site_url": "URL du site web",
"form.feed.label.feed_url": "URL du flux",
@ -1152,6 +1203,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "Sauvegarder les articles vers Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "URL de l'API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clé d'API de Nunux Keeper",
"form.api_key.label.description": "Libellé de la clé d'API",
"form.submit.loading": "Chargement...",
"form.submit.saving": "Sauvegarde en cours...",
"time_elapsed.not_yet": "pas encore",
@ -1249,6 +1301,8 @@ var translations = map[string]string{
"menu.add_user": "Aggiungi utente",
"menu.flush_history": "Svuota la cronologia",
"menu.feed_entries": "Articoli",
"menu.api_keys": "Chiavi API",
"menu.create_api_key": "Crea una nuova chiave API",
"search.label": "Cerca",
"search.placeholder": "Cerca...",
"pagination.next": "Successivo",
@ -1376,6 +1430,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Azioni",
"page.sessions.table.current_session": "Sessione corrente",
"page.api_keys.title": "Chiavi API",
"page.api_keys.table.description": "Descrizione",
"page.api_keys.table.token": "Gettone",
"page.api_keys.table.last_used_at": "Ultimo uso",
"page.api_keys.table.created_at": "Data di creazione",
"page.api_keys.table.actions": "Azioni",
"page.api_keys.never_used": "Mai usato",
"page.new_api_key.title": "Nuova chiave API",
"alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
@ -1413,6 +1475,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.",
"error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
"error.user_mandatory_fields": "Il nome utente è obbligatorio.",
"error.api_key_already_exists": "Questa chiave API esiste già.",
"error.unable_to_create_api_key": "Impossibile creare questa chiave API.",
"form.feed.label.title": "Titolo",
"form.feed.label.site_url": "URL del sito",
"form.feed.label.feed_url": "URL del feed",
@ -1462,6 +1526,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "Salva gli articoli su Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Endpoint dell'API di Nunux Keeper",
"form.integration.nunux_keeper_api_key": "API key dell'account Nunux Keeper",
"form.api_key.label.description": "Etichetta chiave API",
"form.submit.loading": "Caricamento in corso...",
"form.submit.saving": "Salvataggio in corso...",
"time_elapsed.not_yet": "non ancora",
@ -1539,6 +1604,8 @@ var translations = map[string]string{
"menu.add_user": "ユーザーを追加",
"menu.flush_history": "履歴を更新",
"menu.feed_entries": "記事一覧",
"menu.api_keys": "APIキー",
"menu.create_api_key": "新しいAPIキーを作成する",
"search.label": "検索",
"search.placeholder": "…を検索",
"pagination.next": "次",
@ -1666,6 +1733,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "アクション",
"page.sessions.table.current_session": "現在のセッション",
"page.api_keys.title": "APIキー",
"page.api_keys.table.description": "説明",
"page.api_keys.table.token": "トークン",
"page.api_keys.table.last_used_at": "最終使用",
"page.api_keys.table.created_at": "作成日",
"page.api_keys.table.actions": "アクション",
"page.api_keys.never_used": "使われたことがない",
"page.new_api_key.title": "新しいAPIキー",
"alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。",
@ -1703,6 +1778,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンの全てが必要です。",
"error.feed_mandatory_fields": "URL と カテゴリが必要です。",
"error.user_mandatory_fields": "ユーザー名が必要です。",
"error.api_key_already_exists": "このAPIキーは既に存在します。",
"error.unable_to_create_api_key": "このAPIキーを作成できません。",
"form.feed.label.title": "タイトル",
"form.feed.label.site_url": "サイト URL",
"form.feed.label.feed_url": "フィード URL",
@ -1752,6 +1829,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "Nunux Keeper に記事を保存する",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper の API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper の API key",
"form.api_key.label.description": "APIキーラベル",
"form.submit.loading": "読み込み中…",
"form.submit.saving": "保存中…",
"time_elapsed.not_yet": "未来",
@ -1829,6 +1907,8 @@ var translations = map[string]string{
"menu.add_user": "Gebruiker toevoegen",
"menu.flush_history": "Verwijder geschiedenis",
"menu.feed_entries": "Lidwoord",
"menu.api_keys": "API-sleutels",
"menu.create_api_key": "Maak een nieuwe API-sleutel",
"search.label": "Zoeken",
"search.placeholder": "Zoeken...",
"pagination.next": "Volgende",
@ -1956,6 +2036,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "User-agent",
"page.sessions.table.actions": "Acties",
"page.sessions.table.current_session": "Huidige sessie",
"page.api_keys.title": "API-sleutels",
"page.api_keys.table.description": "Beschrijving",
"page.api_keys.table.token": "Blijk",
"page.api_keys.table.last_used_at": "Laatst gebruikt",
"page.api_keys.table.created_at": "Aanmaakdatum",
"page.api_keys.table.actions": "Acties",
"page.api_keys.never_used": "Nooit gebruikt",
"page.new_api_key.title": "Nieuwe API-sleutel",
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
@ -1993,6 +2081,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
"error.api_key_already_exists": "This API Key already exists.",
"error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
"form.feed.label.title": "Naam",
"form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL",
@ -2042,6 +2132,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
"form.api_key.label.description": "API-sleutellabel",
"form.submit.loading": "Laden...",
"form.submit.saving": "Opslaag...",
"time_elapsed.not_yet": "in de toekomst",
@ -2137,6 +2228,8 @@ var translations = map[string]string{
"menu.add_user": "Dodaj użytkownika",
"menu.flush_history": "Usuń historię",
"menu.feed_entries": "Artykuły",
"menu.api_keys": "Klucze API",
"menu.create_api_key": "Utwórz nowy klucz API",
"search.label": "Szukaj",
"search.placeholder": "Szukaj...",
"pagination.next": "Następny",
@ -2266,6 +2359,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "Agent użytkownika",
"page.sessions.table.actions": "Działania",
"page.sessions.table.current_session": "Bieżąca sesja",
"page.api_keys.title": "Klucze API",
"page.api_keys.table.description": "Opis",
"page.api_keys.table.token": "Znak",
"page.api_keys.table.last_used_at": "Ostatnio używane",
"page.api_keys.table.created_at": "Data utworzenia",
"page.api_keys.table.actions": "Działania",
"page.api_keys.never_used": "Nigdy nie używany",
"page.new_api_key.title": "Nowy klucz API",
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!",
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
@ -2303,6 +2404,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
"error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
"form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału",
@ -2352,6 +2455,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.api_key.label.description": "Etykieta klucza API",
"form.submit.loading": "Ładowanie...",
"form.submit.saving": "Zapisywanie...",
"time_elapsed.not_yet": "jeszcze nie",
@ -2453,6 +2557,8 @@ var translations = map[string]string{
"menu.add_user": "Добавить пользователя",
"menu.flush_history": "Отчистить историю",
"menu.feed_entries": "статьи",
"menu.api_keys": "API-ключи",
"menu.create_api_key": "Создать новый ключ API",
"search.label": "Поиск",
"search.placeholder": "Поиск…",
"pagination.next": "Следующая",
@ -2582,6 +2688,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Действия",
"page.sessions.table.current_session": "Текущая сессия",
"page.api_keys.title": "API-ключи",
"page.api_keys.table.description": "описание",
"page.api_keys.table.token": "знак",
"page.api_keys.table.last_used_at": "Последний раз был использован",
"page.api_keys.table.created_at": "Дата создания",
"page.api_keys.table.actions": "Действия",
"page.api_keys.never_used": "Никогда не использовался",
"page.new_api_key.title": "Новый ключ API",
"alert.no_bookmark": "Нет закладок на данный момент.",
"alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.",
@ -2619,6 +2733,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
"error.feed_mandatory_fields": "URL и категория обязательны.",
"error.user_mandatory_fields": "Имя пользователя обязательно.",
"error.api_key_already_exists": "Этот ключ API уже существует.",
"error.unable_to_create_api_key": "Невозможно создать этот ключ API.",
"form.feed.label.title": "Название",
"form.feed.label.site_url": "URL сайта",
"form.feed.label.feed_url": "URL подписки",
@ -2668,6 +2784,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "Сохранять статьи в Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Конечная точка Nunux Keeper API",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.api_key.label.description": "APIキーラベル",
"form.submit.loading": "Загрузка…",
"form.submit.saving": "Сохранение…",
"time_elapsed.not_yet": "ещё нет",
@ -2751,6 +2868,8 @@ var translations = map[string]string{
"menu.add_user": "新建用户",
"menu.flush_history": "清理历史",
"menu.feed_entries": "文章",
"menu.api_keys": "API密钥",
"menu.create_api_key": "创建一个新的API密钥",
"search.label": "搜索",
"search.placeholder": "搜索…",
"pagination.next": "下一页",
@ -2876,6 +2995,14 @@ var translations = map[string]string{
"page.sessions.table.user_agent": "User-Agent",
"page.sessions.table.actions": "操作",
"page.sessions.table.current_session": "当前会话",
"page.api_keys.title": "API密钥",
"page.api_keys.table.description": "描述",
"page.api_keys.table.token": "代币",
"page.api_keys.table.last_used_at": "最后使用",
"page.api_keys.table.created_at": "创立日期",
"page.api_keys.table.actions": "操作",
"page.api_keys.never_used": "没用过",
"page.new_api_key.title": "新的API密钥",
"alert.no_bookmark": "目前没有书签",
"alert.no_category": "目前没有分类",
"alert.no_category_entry": "该分类下没有文章",
@ -2913,6 +3040,8 @@ var translations = map[string]string{
"error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区",
"error.feed_mandatory_fields": "必须填写 URL 和分类",
"error.user_mandatory_fields": "必须填写用户名",
"error.api_key_already_exists": "此API密钥已存在。",
"error.unable_to_create_api_key": "无法创建此API密钥。",
"form.feed.label.title": "标题",
"form.feed.label.site_url": "站点 URL",
"form.feed.label.feed_url": "源 URL",
@ -2962,6 +3091,7 @@ var translations = map[string]string{
"form.integration.nunux_keeper_activate": "保存文章到 Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",
"form.api_key.label.description": "API密钥标签",
"form.submit.loading": "载入中…",
"form.submit.saving": "保存中…",
"time_elapsed.not_yet": "尚未",
@ -3009,14 +3139,14 @@ var translations = map[string]string{
}
var translationsChecksums = map[string]string{
"de_DE": "2269a754f4af398fe6af44324eda8ed7daa708a11eb50f7bb0b779d6ed482ad8",
"en_US": "5256a170a5be7ba8e79ed0897475c416ce755797e9ab1173375dc5113515c2d8",
"es_ES": "19f48e44422712789a3736399e5d5fe9f88cc7fa3e4c228fdceec03f5d3666cd",
"fr_FR": "e6032bfec564e86f12182ea79f0ed61ec133ed0c04525571ab71e923cc5de276",
"it_IT": "39a466b969ffadf27e4bc3054ab36fe8b2bceb0d9c0a68d940d76a418d999073",
"ja_JP": "598e7257528a90125c14c5169663d44d2a7a0afb86354fe654bc68469216251d",
"nl_NL": "fc10720566f37e88da60add9eaefa6f79cb6b021e9f3c192e50dfc5720553d69",
"pl_PL": "fc99fbde29904f3680e95ed337e7d9b2c0755cc8137c2694d8b781c91007ae19",
"ru_RU": "a01fc70baedd9555370e29827ef8c9aba32a4fb8f07942feb7474bcac232a2fe",
"zh_CN": "3bd2c9841413c072d1977dc500d8adecef4f947b28f3a8d3e8d4f0e5c39584ad",
"de_DE": "75ccff01dcd27613e2d130c5b6abdb6bb2645029c93373c7b96d8754298002cd",
"en_US": "f6ac2959fbe86b273ca3cd95031741dbfc4db25e8b61d6b29b798a9faefae4c6",
"es_ES": "a3a494acf1864b2cc6573f9627e5bd2f07fa96a14a39619f310e87e66a4f2c01",
"fr_FR": "9162d348af1c6d30bb6f16bb85468d394a353e9def08cf77adc47404889e6e78",
"it_IT": "ad12b1282ed9b3d1a785f92af70c07f3d7aecf49e8a5d1f023742636b24a366b",
"ja_JP": "a9994611dc3b6a6dd763b6bd1c89bc7c5ec9985a04059f6c45342077d42a3e05",
"nl_NL": "54e9b6cd6758ee3e699028104f25704d6569e5ed8793ff17e817ad80f1ef7bd2",
"pl_PL": "6a95a4f7e8bce0d0d0e0f56d46e69b4577a44609d15511d9fa11c81cb981b5d7",
"ru_RU": "cb024cd742298206634be390a19b7371a797ab8484615a69af7d8fdbea9b58f8",
"zh_CN": "a5f32c5e4714bce8638f7fd19b6c3e54937d9ab00b08ab655076d7be35ef76bd",
}

View file

@ -44,6 +44,8 @@
"menu.add_user": "Benutzer anlegen",
"menu.flush_history": "Verlauf leeren",
"menu.feed_entries": "Artikel",
"menu.api_keys": "API-Schlüssel",
"menu.create_api_key": "Erstellen Sie einen neuen API-Schlüssel",
"search.label": "Suche",
"search.placeholder": "Suche...",
"pagination.next": "Nächste",
@ -171,6 +173,14 @@
"page.sessions.table.user_agent": "Benutzeragent",
"page.sessions.table.actions": "Aktionen",
"page.sessions.table.current_session": "Aktuelle Sitzung",
"page.api_keys.title": "API-Schlüssel",
"page.api_keys.table.description": "Beschreibung",
"page.api_keys.table.token": "Zeichen",
"page.api_keys.table.last_used_at": "Zuletzt verwendeten",
"page.api_keys.table.created_at": "Erstellungsdatum",
"page.api_keys.table.actions": "Aktionen",
"page.api_keys.never_used": "Nie benutzt",
"page.new_api_key.title": "Neuer API-Schlüssel",
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
@ -208,6 +218,8 @@
"error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
"error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
"error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.",
"error.unable_to_create_api_key": "Dieser API-Schlüssel kann nicht erstellt werden.",
"form.feed.label.title": "Titel",
"form.feed.label.site_url": "Webseite-URL",
"form.feed.label.feed_url": "Abonnement-URL",
@ -257,6 +269,7 @@
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
"form.api_key.label.description": "API-Schlüsselbezeichnung",
"form.submit.loading": "Lade...",
"form.submit.saving": "Speichern...",
"time_elapsed.not_yet": "noch nicht",

View file

@ -44,6 +44,8 @@
"menu.add_user": "Add user",
"menu.flush_history": "Flush history",
"menu.feed_entries": "Entries",
"menu.api_keys": "API Keys",
"menu.create_api_key": "Create a new API key",
"search.label": "Search",
"search.placeholder": "Search...",
"pagination.next": "Next",
@ -171,6 +173,14 @@
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Actions",
"page.sessions.table.current_session": "Current Session",
"page.api_keys.title": "API Keys",
"page.api_keys.table.description": "Description",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Last Used",
"page.api_keys.table.created_at": "Creation Date",
"page.api_keys.table.actions": "Actions",
"page.api_keys.never_used": "Never Used",
"page.new_api_key.title": "New API Key",
"alert.no_bookmark": "There is no bookmark at the moment.",
"alert.no_category": "There is no category.",
"alert.no_category_entry": "There are no articles in this category.",
@ -208,6 +218,8 @@
"error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.",
"error.feed_mandatory_fields": "The URL and the category are mandatory.",
"error.user_mandatory_fields": "The username is mandatory.",
"error.api_key_already_exists": "This API Key already exists.",
"error.unable_to_create_api_key": "Unable to create this API Key.",
"form.feed.label.title": "Title",
"form.feed.label.site_url": "Site URL",
"form.feed.label.feed_url": "Feed URL",
@ -257,6 +269,7 @@
"form.integration.nunux_keeper_activate": "Save articles to Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.api_key.label.description": "API Key Label",
"form.submit.loading": "Loading...",
"form.submit.saving": "Saving...",
"time_elapsed.not_yet": "not yet",

View file

@ -44,6 +44,8 @@
"menu.add_user": "Agregar usuario",
"menu.flush_history": "Borrar historial",
"menu.feed_entries": "Artículos",
"menu.api_keys": "Claves API",
"menu.create_api_key": "Crear una nueva clave API",
"search.label": "Buscar",
"search.placeholder": "Búsqueda...",
"pagination.next": "Siguiente",
@ -171,6 +173,14 @@
"page.sessions.table.user_agent": "Agente de usuario",
"page.sessions.table.actions": "Acciones",
"page.sessions.table.current_session": "Sesión actual",
"page.api_keys.title": "Claves API",
"page.api_keys.table.description": "Descripción",
"page.api_keys.table.token": "simbólico",
"page.api_keys.table.last_used_at": "Último utilizado",
"page.api_keys.table.created_at": "Fecha de creación",
"page.api_keys.table.actions": "Acciones",
"page.api_keys.never_used": "Nunca usado",
"page.new_api_key.title": "Nueva clave API",
"alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.",
"alert.no_category_entry": "No hay artículos en esta categoria.",
@ -208,6 +218,8 @@
"error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
"error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
"error.user_mandatory_fields": "El nombre de usuario es obligatorio.",
"error.api_key_already_exists": "Esta clave API ya existe.",
"error.unable_to_create_api_key": "No se puede crear esta clave API.",
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL del sitio",
"form.feed.label.feed_url": "URL de la fuente",
@ -257,6 +269,7 @@
"form.integration.nunux_keeper_activate": "Guardar artículos a Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Extremo de API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
"form.api_key.label.description": "Etiqueta de clave API",
"form.submit.loading": "Cargando...",
"form.submit.saving": "Guardando...",
"time_elapsed.not_yet": "todavía no",

View file

@ -44,6 +44,8 @@
"menu.add_user": "Ajouter un utilisateur",
"menu.flush_history": "Supprimer l'historique",
"menu.feed_entries": "Articles",
"menu.api_keys": "Clés d'API",
"menu.create_api_key": "Créer une nouvelle clé d'API",
"search.label": "Recherche",
"search.placeholder": "Recherche...",
"pagination.next": "Suivant",
@ -171,6 +173,14 @@
"page.sessions.table.user_agent": "Navigateur Web",
"page.sessions.table.actions": "Actions",
"page.sessions.table.current_session": "Session actuelle",
"page.api_keys.title": "Clés d'API",
"page.api_keys.table.description": "Description",
"page.api_keys.table.token": "Jeton",
"page.api_keys.table.last_used_at": "Dernière utilisation",
"page.api_keys.table.created_at": "Date de création",
"page.api_keys.table.actions": "Actions",
"page.api_keys.never_used": "Jamais utilisé",
"page.new_api_key.title": "Nouvelle clé d'API",
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
"alert.no_category": "Il n'y a aucune catégorie.",
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
@ -208,6 +218,8 @@
"error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
"error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
"error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
"error.api_key_already_exists": "Cette clé d'API existe déjà.",
"error.unable_to_create_api_key": "Impossible de créer cette clé d'API.",
"form.feed.label.title": "Titre",
"form.feed.label.site_url": "URL du site web",
"form.feed.label.feed_url": "URL du flux",
@ -257,6 +269,7 @@
"form.integration.nunux_keeper_activate": "Sauvegarder les articles vers Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "URL de l'API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clé d'API de Nunux Keeper",
"form.api_key.label.description": "Libellé de la clé d'API",
"form.submit.loading": "Chargement...",
"form.submit.saving": "Sauvegarde en cours...",
"time_elapsed.not_yet": "pas encore",

View file

@ -44,6 +44,8 @@
"menu.add_user": "Aggiungi utente",
"menu.flush_history": "Svuota la cronologia",
"menu.feed_entries": "Articoli",
"menu.api_keys": "Chiavi API",
"menu.create_api_key": "Crea una nuova chiave API",
"search.label": "Cerca",
"search.placeholder": "Cerca...",
"pagination.next": "Successivo",
@ -171,6 +173,14 @@
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Azioni",
"page.sessions.table.current_session": "Sessione corrente",
"page.api_keys.title": "Chiavi API",
"page.api_keys.table.description": "Descrizione",
"page.api_keys.table.token": "Gettone",
"page.api_keys.table.last_used_at": "Ultimo uso",
"page.api_keys.table.created_at": "Data di creazione",
"page.api_keys.table.actions": "Azioni",
"page.api_keys.never_used": "Mai usato",
"page.new_api_key.title": "Nuova chiave API",
"alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
@ -208,6 +218,8 @@
"error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.",
"error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
"error.user_mandatory_fields": "Il nome utente è obbligatorio.",
"error.api_key_already_exists": "Questa chiave API esiste già.",
"error.unable_to_create_api_key": "Impossibile creare questa chiave API.",
"form.feed.label.title": "Titolo",
"form.feed.label.site_url": "URL del sito",
"form.feed.label.feed_url": "URL del feed",
@ -257,6 +269,7 @@
"form.integration.nunux_keeper_activate": "Salva gli articoli su Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Endpoint dell'API di Nunux Keeper",
"form.integration.nunux_keeper_api_key": "API key dell'account Nunux Keeper",
"form.api_key.label.description": "Etichetta chiave API",
"form.submit.loading": "Caricamento in corso...",
"form.submit.saving": "Salvataggio in corso...",
"time_elapsed.not_yet": "non ancora",

View file

@ -44,6 +44,8 @@
"menu.add_user": "ユーザーを追加",
"menu.flush_history": "履歴を更新",
"menu.feed_entries": "記事一覧",
"menu.api_keys": "APIキー",
"menu.create_api_key": "新しいAPIキーを作成する",
"search.label": "検索",
"search.placeholder": "…を検索",
"pagination.next": "次",
@ -171,6 +173,14 @@
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "アクション",
"page.sessions.table.current_session": "現在のセッション",
"page.api_keys.title": "APIキー",
"page.api_keys.table.description": "説明",
"page.api_keys.table.token": "トークン",
"page.api_keys.table.last_used_at": "最終使用",
"page.api_keys.table.created_at": "作成日",
"page.api_keys.table.actions": "アクション",
"page.api_keys.never_used": "使われたことがない",
"page.new_api_key.title": "新しいAPIキー",
"alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。",
@ -208,6 +218,8 @@
"error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンの全てが必要です。",
"error.feed_mandatory_fields": "URL と カテゴリが必要です。",
"error.user_mandatory_fields": "ユーザー名が必要です。",
"error.api_key_already_exists": "このAPIキーは既に存在します。",
"error.unable_to_create_api_key": "このAPIキーを作成できません。",
"form.feed.label.title": "タイトル",
"form.feed.label.site_url": "サイト URL",
"form.feed.label.feed_url": "フィード URL",
@ -257,6 +269,7 @@
"form.integration.nunux_keeper_activate": "Nunux Keeper に記事を保存する",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper の API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper の API key",
"form.api_key.label.description": "APIキーラベル",
"form.submit.loading": "読み込み中…",
"form.submit.saving": "保存中…",
"time_elapsed.not_yet": "未来",

View file

@ -44,6 +44,8 @@
"menu.add_user": "Gebruiker toevoegen",
"menu.flush_history": "Verwijder geschiedenis",
"menu.feed_entries": "Lidwoord",
"menu.api_keys": "API-sleutels",
"menu.create_api_key": "Maak een nieuwe API-sleutel",
"search.label": "Zoeken",
"search.placeholder": "Zoeken...",
"pagination.next": "Volgende",
@ -171,6 +173,14 @@
"page.sessions.table.user_agent": "User-agent",
"page.sessions.table.actions": "Acties",
"page.sessions.table.current_session": "Huidige sessie",
"page.api_keys.title": "API-sleutels",
"page.api_keys.table.description": "Beschrijving",
"page.api_keys.table.token": "Blijk",
"page.api_keys.table.last_used_at": "Laatst gebruikt",
"page.api_keys.table.created_at": "Aanmaakdatum",
"page.api_keys.table.actions": "Acties",
"page.api_keys.never_used": "Nooit gebruikt",
"page.new_api_key.title": "Nieuwe API-sleutel",
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
@ -208,6 +218,8 @@
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
"error.api_key_already_exists": "This API Key already exists.",
"error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
"form.feed.label.title": "Naam",
"form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL",
@ -257,6 +269,7 @@
"form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
"form.api_key.label.description": "API-sleutellabel",
"form.submit.loading": "Laden...",
"form.submit.saving": "Opslaag...",
"time_elapsed.not_yet": "in de toekomst",

View file

@ -44,6 +44,8 @@
"menu.add_user": "Dodaj użytkownika",
"menu.flush_history": "Usuń historię",
"menu.feed_entries": "Artykuły",
"menu.api_keys": "Klucze API",
"menu.create_api_key": "Utwórz nowy klucz API",
"search.label": "Szukaj",
"search.placeholder": "Szukaj...",
"pagination.next": "Następny",
@ -173,6 +175,14 @@
"page.sessions.table.user_agent": "Agent użytkownika",
"page.sessions.table.actions": "Działania",
"page.sessions.table.current_session": "Bieżąca sesja",
"page.api_keys.title": "Klucze API",
"page.api_keys.table.description": "Opis",
"page.api_keys.table.token": "Znak",
"page.api_keys.table.last_used_at": "Ostatnio używane",
"page.api_keys.table.created_at": "Data utworzenia",
"page.api_keys.table.actions": "Działania",
"page.api_keys.never_used": "Nigdy nie używany",
"page.new_api_key.title": "Nowy klucz API",
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!",
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
@ -210,6 +220,8 @@
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
"error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
"form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału",
@ -259,6 +271,7 @@
"form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.api_key.label.description": "Etykieta klucza API",
"form.submit.loading": "Ładowanie...",
"form.submit.saving": "Zapisywanie...",
"time_elapsed.not_yet": "jeszcze nie",

View file

@ -44,6 +44,8 @@
"menu.add_user": "Добавить пользователя",
"menu.flush_history": "Отчистить историю",
"menu.feed_entries": "статьи",
"menu.api_keys": "API-ключи",
"menu.create_api_key": "Создать новый ключ API",
"search.label": "Поиск",
"search.placeholder": "Поиск…",
"pagination.next": "Следующая",
@ -173,6 +175,14 @@
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Действия",
"page.sessions.table.current_session": "Текущая сессия",
"page.api_keys.title": "API-ключи",
"page.api_keys.table.description": "описание",
"page.api_keys.table.token": "знак",
"page.api_keys.table.last_used_at": "Последний раз был использован",
"page.api_keys.table.created_at": "Дата создания",
"page.api_keys.table.actions": "Действия",
"page.api_keys.never_used": "Никогда не использовался",
"page.new_api_key.title": "Новый ключ API",
"alert.no_bookmark": "Нет закладок на данный момент.",
"alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.",
@ -210,6 +220,8 @@
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
"error.feed_mandatory_fields": "URL и категория обязательны.",
"error.user_mandatory_fields": "Имя пользователя обязательно.",
"error.api_key_already_exists": "Этот ключ API уже существует.",
"error.unable_to_create_api_key": "Невозможно создать этот ключ API.",
"form.feed.label.title": "Название",
"form.feed.label.site_url": "URL сайта",
"form.feed.label.feed_url": "URL подписки",
@ -259,6 +271,7 @@
"form.integration.nunux_keeper_activate": "Сохранять статьи в Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Конечная точка Nunux Keeper API",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.api_key.label.description": "APIキーラベル",
"form.submit.loading": "Загрузка…",
"form.submit.saving": "Сохранение…",
"time_elapsed.not_yet": "ещё нет",

View file

@ -44,6 +44,8 @@
"menu.add_user": "新建用户",
"menu.flush_history": "清理历史",
"menu.feed_entries": "文章",
"menu.api_keys": "API密钥",
"menu.create_api_key": "创建一个新的API密钥",
"search.label": "搜索",
"search.placeholder": "搜索…",
"pagination.next": "下一页",
@ -169,6 +171,14 @@
"page.sessions.table.user_agent": "User-Agent",
"page.sessions.table.actions": "操作",
"page.sessions.table.current_session": "当前会话",
"page.api_keys.title": "API密钥",
"page.api_keys.table.description": "描述",
"page.api_keys.table.token": "代币",
"page.api_keys.table.last_used_at": "最后使用",
"page.api_keys.table.created_at": "创立日期",
"page.api_keys.table.actions": "操作",
"page.api_keys.never_used": "没用过",
"page.new_api_key.title": "新的API密钥",
"alert.no_bookmark": "目前没有书签",
"alert.no_category": "目前没有分类",
"alert.no_category_entry": "该分类下没有文章",
@ -206,6 +216,8 @@
"error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区",
"error.feed_mandatory_fields": "必须填写 URL 和分类",
"error.user_mandatory_fields": "必须填写用户名",
"error.api_key_already_exists": "此API密钥已存在。",
"error.unable_to_create_api_key": "无法创建此API密钥。",
"form.feed.label.title": "标题",
"form.feed.label.site_url": "站点 URL",
"form.feed.label.feed_url": "源 URL",
@ -255,6 +267,7 @@
"form.integration.nunux_keeper_activate": "保存文章到 Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",
"form.api_key.label.description": "API密钥标签",
"form.submit.loading": "载入中…",
"form.submit.saving": "保存中…",
"time_elapsed.not_yet": "尚未",

33
model/api_key.go Normal file
View file

@ -0,0 +1,33 @@
// Copyright 2020 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package model // import "miniflux.app/model"
import (
"time"
"miniflux.app/crypto"
)
// APIKey represents an application API key.
type APIKey struct {
ID int64
UserID int64
Token string
Description string
LastUsedAt *time.Time
CreatedAt time.Time
}
// NewAPIKey initializes a new APIKey.
func NewAPIKey(userID int64, description string) *APIKey {
return &APIKey{
UserID: userID,
Token: crypto.GenerateRandomString(32),
Description: description,
}
}
// APIKeys represents a collection of API Key.
type APIKeys []*APIKey

104
storage/api_key.go Normal file
View file

@ -0,0 +1,104 @@
// Copyright 2020 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package storage // import "miniflux.app/storage"
import (
"fmt"
"miniflux.app/model"
)
// APIKeyExists checks if an API Key with the same description exists.
func (s *Storage) APIKeyExists(userID int64, description string) bool {
var result bool
query := `SELECT true FROM api_keys WHERE user_id=$1 AND lower(description)=lower($2) LIMIT 1`
s.db.QueryRow(query, userID, description).Scan(&result)
return result
}
// SetAPIKeyUsedTimestamp updates the last used date of an API Key.
func (s *Storage) SetAPIKeyUsedTimestamp(userID int64, token string) error {
query := `UPDATE api_keys SET last_used_at=now() WHERE user_id=$1 and token=$2`
_, err := s.db.Exec(query, userID, token)
if err != nil {
return fmt.Errorf(`store: unable to update last used date for API key: %v`, err)
}
return nil
}
// APIKeys returns all API Keys that belongs to the given user.
func (s *Storage) APIKeys(userID int64) (model.APIKeys, error) {
query := `
SELECT
id, user_id, token, description, last_used_at, created_at
FROM
api_keys
WHERE
user_id=$1
ORDER BY description ASC
`
rows, err := s.db.Query(query, userID)
if err != nil {
return nil, fmt.Errorf(`store: unable to fetch API Keys: %v`, err)
}
defer rows.Close()
apiKeys := make(model.APIKeys, 0)
for rows.Next() {
var apiKey model.APIKey
if err := rows.Scan(
&apiKey.ID,
&apiKey.UserID,
&apiKey.Token,
&apiKey.Description,
&apiKey.LastUsedAt,
&apiKey.CreatedAt,
); err != nil {
return nil, fmt.Errorf(`store: unable to fetch API Key row: %v`, err)
}
apiKeys = append(apiKeys, &apiKey)
}
return apiKeys, nil
}
// CreateAPIKey inserts a new API key.
func (s *Storage) CreateAPIKey(apiKey *model.APIKey) error {
query := `
INSERT INTO api_keys
(user_id, token, description)
VALUES
($1, $2, $3)
RETURNING
id, created_at
`
err := s.db.QueryRow(
query,
apiKey.UserID,
apiKey.Token,
apiKey.Description,
).Scan(
&apiKey.ID,
&apiKey.CreatedAt,
)
if err != nil {
return fmt.Errorf(`store: unable to create category: %v`, err)
}
return nil
}
// RemoveAPIKey deletes an API Key.
func (s *Storage) RemoveAPIKey(userID, keyID int64) error {
query := `DELETE FROM api_keys WHERE id = $1 AND user_id = $2`
_, err := s.db.Exec(query, keyID, userID)
if err != nil {
return fmt.Errorf(`store: unable to remove this API Key: %v`, err)
}
return nil
}

View file

@ -253,6 +253,30 @@ func (s *Storage) UserByExtraField(field, value string) (*model.User, error) {
return s.fetchUser(query, field, value)
}
// UserByAPIKey returns a User from an API Key.
func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
query := `
SELECT
u.id,
u.username,
u.is_admin,
u.theme,
u.language,
u.timezone,
u.entry_direction,
u.keyboard_shortcuts,
u.last_login_at,
u.extra
FROM
users u
LEFT JOIN
api_keys ON api_keys.user_id=u.id
WHERE
api_keys.token = $1
`
return s.fetchUser(query, token)
}
func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, error) {
var extra hstore.Hstore

View file

@ -7,7 +7,7 @@ var templateCommonMap = map[string]string{
<div class="pagination">
<div class="pagination-prev">
{{ if .prevEntry }}
<a href="{{ .prevEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .prevEntry.Title }}" data-page="previous">{{ t "pagination.previous" }}</a>
<a href="{{ .prevEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .prevEntry.Title }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
{{ else }}
{{ t "pagination.previous" }}
{{ end }}
@ -15,13 +15,14 @@ var templateCommonMap = map[string]string{
<div class="pagination-next">
{{ if .nextEntry }}
<a href="{{ .nextEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .nextEntry.Title }}" data-page="next">{{ t "pagination.next" }}</a>
<a href="{{ .nextEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .nextEntry.Title }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
{{ else }}
{{ t "pagination.next" }}
{{ end }}
</div>
</div>
{{ end }}`,
{{ end }}
`,
"feed_list": `{{ define "feed_list" }}
<div class="items">
{{ range .feeds }}
@ -311,7 +312,7 @@ var templateCommonMap = map[string]string{
<div class="pagination">
<div class="pagination-prev">
{{ if .ShowPrev }}
<a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="previous">{{ t "pagination.previous" }}</a>
<a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
{{ else }}
{{ t "pagination.previous" }}
{{ end }}
@ -319,7 +320,7 @@ var templateCommonMap = map[string]string{
<div class="pagination-next">
{{ if .ShowNext }}
<a href="{{ .Route }}?offset={{ .NextOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}" data-page="next">{{ t "pagination.next" }}</a>
<a href="{{ .Route }}?offset={{ .NextOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
{{ else }}
{{ t "pagination.next" }}
{{ end }}
@ -335,6 +336,9 @@ var templateCommonMap = map[string]string{
<li>
<a href="{{ route "integrations" }}">{{ t "menu.integrations" }}</a>
</li>
<li>
<a href="{{ route "apiKeys" }}">{{ t "menu.api_keys" }}</a>
</li>
<li>
<a href="{{ route "sessions" }}">{{ t "menu.sessions" }}</a>
</li>
@ -342,9 +346,6 @@ var templateCommonMap = map[string]string{
<li>
<a href="{{ route "users" }}">{{ t "menu.users" }}</a>
</li>
<li>
<a href="{{ route "createUser" }}">{{ t "menu.add_user" }}</a>
</li>
{{ end }}
<li>
<a href="{{ route "about" }}">{{ t "menu.about" }}</a>
@ -354,11 +355,11 @@ var templateCommonMap = map[string]string{
}
var templateCommonMapChecksums = map[string]string{
"entry_pagination": "4faa91e2eae150c5e4eab4d258e039dfdd413bab7602f0009360e6d52898e353",
"entry_pagination": "cdca9cf12586e41e5355190b06d9168f57f77b85924d1e63b13524bc15abcbf6",
"feed_list": "db406e7cb81292ce1d974d63f63270384a286848b2e74fe36bf711b4eb5717dd",
"feed_menu": "318d8662dda5ca9dfc75b909c8461e79c86fb5082df1428f67aaf856f19f4b50",
"item_meta": "d046305e8935ecd8643a94d28af384df29e40fc7ce334123cd057a6522bac23f",
"layout": "a1f67b8908745ee4f9cee6f7bbbb0b242d4dcc101207ad4a9d67242b45683299",
"pagination": "3386e90c6e1230311459e9a484629bc5d5bf39514a75ef2e73bbbc61142f7abb",
"settings_menu": "78e5a487ede18610b23db74184dab023170f9e083cc0625bc2c874d1eea1a4ce",
"pagination": "7b61288e86283c4cf0dc83bcbf8bf1c00c7cb29e60201c8c0b633b2450d2911f",
"settings_menu": "e2b777630c0efdbc529800303c01d6744ed3af80ec505ac5a5b3f99c9b989156",
}

View file

@ -0,0 +1,72 @@
{{ define "title"}}{{ t "page.api_keys.title" }}{{ end }}
{{ define "content"}}
<section class="page-header">
<h1>{{ t "page.api_keys.title" }}</h1>
{{ template "settings_menu" dict "user" .user }}
</section>
{{ if .apiKeys }}
{{ range .apiKeys }}
<table>
<tr>
<th class="column-25">{{ t "page.api_keys.table.description" }}</th>
<td>{{ .Description }}</td>
</tr>
<tr>
<th>{{ t "page.api_keys.table.token" }}</th>
<td>{{ .Token }}</td>
</tr>
<tr>
<th>{{ t "page.api_keys.table.last_used_at" }}</th>
<td>
{{ if .LastUsedAt }}
<time datetime="{{ isodate .LastUsedAt }}" title="{{ isodate .LastUsedAt }}">{{ elapsed $.user.Timezone .LastUsedAt }}</time>
{{ else }}
{{ t "page.api_keys.never_used" }}
{{ end }}
</td>
</tr>
<tr>
<th>{{ t "page.api_keys.table.created_at" }}</th>
<td>
<time datetime="{{ isodate .CreatedAt }}" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</time>
</td>
</tr>
<tr>
<th>{{ t "page.api_keys.table.actions" }}</th>
<td>
<a href="#"
data-confirm="true"
data-label-question="{{ t "confirm.question" }}"
data-label-yes="{{ t "confirm.yes" }}"
data-label-no="{{ t "confirm.no" }}"
data-label-loading="{{ t "confirm.loading" }}"
data-url="{{ route "removeAPIKey" "keyID" .ID }}">{{ t "action.remove" }}</a>
</td>
</tr>
</table>
<br>
{{ end }}
{{ end }}
<h3>{{ t "page.integration.miniflux_api" }}</h3>
<div class="panel">
<ul>
<li>
{{ t "page.integration.miniflux_api_endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
</li>
<li>
{{ t "page.integration.miniflux_api_username" }} = <strong>{{ .user.Username }}</strong>
</li>
<li>
{{ t "page.integration.miniflux_api_password" }} = <strong>{{ t "page.integration.miniflux_api_password_value" }}</strong>
</li>
</ul>
</div>
<p>
<a href="{{ route "createAPIKey" }}" class="button button-primary">{{ t "menu.create_api_key" }}</a>
</p>
{{ end }}

View file

@ -6,6 +6,9 @@
<li>
<a href="{{ route "integrations" }}">{{ t "menu.integrations" }}</a>
</li>
<li>
<a href="{{ route "apiKeys" }}">{{ t "menu.api_keys" }}</a>
</li>
<li>
<a href="{{ route "sessions" }}">{{ t "menu.sessions" }}</a>
</li>
@ -13,9 +16,6 @@
<li>
<a href="{{ route "users" }}">{{ t "menu.users" }}</a>
</li>
<li>
<a href="{{ route "createUser" }}">{{ t "menu.add_user" }}</a>
</li>
{{ end }}
<li>
<a href="{{ route "about" }}">{{ t "menu.about" }}</a>

View file

@ -0,0 +1,23 @@
{{ define "title"}}{{ t "page.new_api_key.title" }}{{ end }}
{{ define "content"}}
<section class="page-header">
<h1>{{ t "page.new_api_key.title" }}</h1>
{{ template "settings_menu" dict "user" .user }}
</section>
<form action="{{ route "saveAPIKey" }}" method="post" autocomplete="off">
<input type="hidden" name="csrf" value="{{ .csrf }}">
{{ if .errorMessage }}
<div class="alert alert-error">{{ t .errorMessage }}</div>
{{ end }}
<label for="form-description">{{ t "form.api_key.label.description" }}</label>
<input type="text" name="description" id="form-description" value="{{ .form.Description }}" required autofocus>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ route "apiKeys" }}">{{ t "action.cancel" }}</a>
</div>
</form>
{{ end }}

View file

@ -117,21 +117,6 @@
</div>
</form>
<h3>{{ t "page.integration.miniflux_api" }}</h3>
<div class="panel">
<ul>
<li>
{{ t "page.integration.miniflux_api_endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
</li>
<li>
{{ t "page.integration.miniflux_api_username" }} = <strong>{{ .user.Username }}</strong>
</li>
<li>
{{ t "page.integration.miniflux_api_password" }} = <strong>{{ t "page.integration.miniflux_api_password_value" }}</strong>
</li>
</ul>
</div>
<h3>{{ t "page.integration.bookmarklet" }}</h3>
<div class="panel">
<p>{{ t "page.integration.bookmarklet.help" }}</p>

View file

@ -42,6 +42,11 @@
{{ end }}
{{ end }}
</table>
<br>
{{ end }}
<p>
<a href="{{ route "createUser" }}" class="button button-primary">{{ t "menu.add_user" }}</a>
</p>
{{ end }}

View file

@ -92,6 +92,79 @@ var templateViewsMap = map[string]string{
</form>
{{ end }}
{{ end }}
`,
"api_keys": `{{ define "title"}}{{ t "page.api_keys.title" }}{{ end }}
{{ define "content"}}
<section class="page-header">
<h1>{{ t "page.api_keys.title" }}</h1>
{{ template "settings_menu" dict "user" .user }}
</section>
{{ if .apiKeys }}
{{ range .apiKeys }}
<table>
<tr>
<th class="column-25">{{ t "page.api_keys.table.description" }}</th>
<td>{{ .Description }}</td>
</tr>
<tr>
<th>{{ t "page.api_keys.table.token" }}</th>
<td>{{ .Token }}</td>
</tr>
<tr>
<th>{{ t "page.api_keys.table.last_used_at" }}</th>
<td>
{{ if .LastUsedAt }}
<time datetime="{{ isodate .LastUsedAt }}" title="{{ isodate .LastUsedAt }}">{{ elapsed $.user.Timezone .LastUsedAt }}</time>
{{ else }}
{{ t "page.api_keys.never_used" }}
{{ end }}
</td>
</tr>
<tr>
<th>{{ t "page.api_keys.table.created_at" }}</th>
<td>
<time datetime="{{ isodate .CreatedAt }}" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</time>
</td>
</tr>
<tr>
<th>{{ t "page.api_keys.table.actions" }}</th>
<td>
<a href="#"
data-confirm="true"
data-label-question="{{ t "confirm.question" }}"
data-label-yes="{{ t "confirm.yes" }}"
data-label-no="{{ t "confirm.no" }}"
data-label-loading="{{ t "confirm.loading" }}"
data-url="{{ route "removeAPIKey" "keyID" .ID }}">{{ t "action.remove" }}</a>
</td>
</tr>
</table>
<br>
{{ end }}
{{ end }}
<h3>{{ t "page.integration.miniflux_api" }}</h3>
<div class="panel">
<ul>
<li>
{{ t "page.integration.miniflux_api_endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
</li>
<li>
{{ t "page.integration.miniflux_api_username" }} = <strong>{{ .user.Username }}</strong>
</li>
<li>
{{ t "page.integration.miniflux_api_password" }} = <strong>{{ t "page.integration.miniflux_api_password_value" }}</strong>
</li>
</ul>
</div>
<p>
<a href="{{ route "createAPIKey" }}" class="button button-primary">{{ t "menu.create_api_key" }}</a>
</p>
{{ end }}
`,
"bookmark_entries": `{{ define "title"}}{{ t "page.starred.title" }} ({{ .total }}){{ end }}
@ -317,6 +390,30 @@ var templateViewsMap = map[string]string{
</div>
</form>
{{ end }}
`,
"create_api_key": `{{ define "title"}}{{ t "page.new_api_key.title" }}{{ end }}
{{ define "content"}}
<section class="page-header">
<h1>{{ t "page.new_api_key.title" }}</h1>
{{ template "settings_menu" dict "user" .user }}
</section>
<form action="{{ route "saveAPIKey" }}" method="post" autocomplete="off">
<input type="hidden" name="csrf" value="{{ .csrf }}">
{{ if .errorMessage }}
<div class="alert alert-error">{{ t .errorMessage }}</div>
{{ end }}
<label for="form-description">{{ t "form.api_key.label.description" }}</label>
<input type="text" name="description" id="form-description" value="{{ .form.Description }}" required autofocus>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ route "apiKeys" }}">{{ t "action.cancel" }}</a>
</div>
</form>
{{ end }}
`,
"create_category": `{{ define "title"}}{{ t "page.new_category.title" }}{{ end }}
@ -992,21 +1089,6 @@ var templateViewsMap = map[string]string{
</div>
</form>
<h3>{{ t "page.integration.miniflux_api" }}</h3>
<div class="panel">
<ul>
<li>
{{ t "page.integration.miniflux_api_endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
</li>
<li>
{{ t "page.integration.miniflux_api_username" }} = <strong>{{ .user.Username }}</strong>
</li>
<li>
{{ t "page.integration.miniflux_api_password" }} = <strong>{{ t "page.integration.miniflux_api_password_value" }}</strong>
</li>
</ul>
</div>
<h3>{{ t "page.integration.bookmarklet" }}</h3>
<div class="panel">
<p>{{ t "page.integration.bookmarklet.help" }}</p>
@ -1302,8 +1384,13 @@ var templateViewsMap = map[string]string{
{{ end }}
{{ end }}
</table>
<br>
{{ end }}
<p>
<a href="{{ route "createUser" }}" class="button button-primary">{{ t "menu.add_user" }}</a>
</p>
{{ end }}
`,
}
@ -1311,11 +1398,13 @@ var templateViewsMap = map[string]string{
var templateViewsMapChecksums = map[string]string{
"about": "4035658497363d7af7f79be83190404eb21ec633fe8ec636bdfc219d9fc78cfc",
"add_subscription": "0dbea93b6fc07423fa066122ad960c69616b829533371a2dbadec1e22d4f1ae0",
"api_keys": "27d401b31a72881d5232486ba17eb47edaf5246eaedce81de88698c15ebb2284",
"bookmark_entries": "65588da78665699dd3f287f68325e9777d511f1a57fee4131a5bb6d00bb68df8",
"categories": "2c5dd0ed6355bd5acc393bbf6117d20458b5581aab82036008324f6bbbe2af75",
"category_entries": "dee7b9cd60c6c46f01dd4289940679df31c1fce28ce4aa7249fa459023e1eeb4",
"category_feeds": "527c2ffbc4fcec775071424ba1022ae003525dba53a28cc41f48fb7b30aa984b",
"choose_subscription": "84c9730cadd78e6ee5a6b4c499aab33acddb4324ac01924d33387543eec4d702",
"create_api_key": "5f74d4e92a6684927f5305096378c8be278159a5cd88ce652c7be3280a7d1685",
"create_category": "6b22b5ce51abf4e225e23a79f81be09a7fb90acb265e93a8faf9446dff74018d",
"create_user": "9b73a55233615e461d1f07d99ad1d4d3b54532588ab960097ba3e090c85aaf3a",
"edit_category": "b1c0b38f1b714c5d884edcd61e5b5295a5f1c8b71c469b35391e4dcc97cc6d36",
@ -1326,11 +1415,11 @@ var templateViewsMapChecksums = map[string]string{
"feeds": "ec7d3fa96735bd8422ba69ef0927dcccddc1cc51327e0271f0312d3f881c64fd",
"history_entries": "87e17d39de70eb3fdbc4000326283be610928758eae7924e4b08dcb446f3b6a9",
"import": "1b59b3bd55c59fcbc6fbb346b414dcdd26d1b4e0c307e437bb58b3f92ef01ad1",
"integrations": "6104ff6ff3ac3c1ae5e850c78250aab6e99e2342a337589f3848459fa333766a",
"integrations": "30329452743b35c668278f519245fd9be05c1726856e0384ba542f7c307f2788",
"login": "0657174d13229bb6d0bc470ccda06bb1f15c1af65c86b20b41ffa5c819eef0cc",
"search_entries": "274950d03298c24f3942e209c0faed580a6d57be9cf76a6c236175a7e766ac6a",
"sessions": "5d5c677bddbd027e0b0c9f7a0dd95b66d9d95b4e130959f31fb955b926c2201c",
"settings": "56f7c06f24eef317353582b0191aa9a5985f46ed755accf97e723ceb4bba4469",
"unread_entries": "e38f7ffce17dfad3151b08cd33771a2cefe8ca9db42df04fc98bd1d675dd6075",
"users": "17d0b7c760557e20f888d83d6a1b0d4506dab071a593cc42080ec0dbf16adf9e",
"users": "d7ff52efc582bbad10504f4a04fa3adcc12d15890e45dff51cac281e0c446e45",
}

34
ui/api_key_create.go Normal file
View file

@ -0,0 +1,34 @@
// Copyright 2020 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package ui // import "miniflux.app/ui"
import (
"net/http"
"miniflux.app/http/request"
"miniflux.app/http/response/html"
"miniflux.app/ui/form"
"miniflux.app/ui/session"
"miniflux.app/ui/view"
)
func (h *handler) showCreateAPIKeyPage(w http.ResponseWriter, r *http.Request) {
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
html.ServerError(w, r, err)
return
}
view.Set("form", &form.APIKeyForm{})
view.Set("menu", "settings")
view.Set("user", user)
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
view.Set("countErrorFeeds", h.store.CountErrorFeeds(user.ID))
html.OK(w, r, view.Render("create_api_key"))
}

39
ui/api_key_list.go Normal file
View file

@ -0,0 +1,39 @@
// Copyright 2020 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package ui // import "miniflux.app/ui"
import (
"net/http"
"miniflux.app/http/request"
"miniflux.app/http/response/html"
"miniflux.app/ui/session"
"miniflux.app/ui/view"
)
func (h *handler) showAPIKeysPage(w http.ResponseWriter, r *http.Request) {
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
html.ServerError(w, r, err)
return
}
apiKeys, err := h.store.APIKeys(user.ID)
if err != nil {
html.ServerError(w, r, err)
return
}
view.Set("apiKeys", apiKeys)
view.Set("menu", "settings")
view.Set("user", user)
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
view.Set("countErrorFeeds", h.store.CountErrorFeeds(user.ID))
html.OK(w, r, view.Render("api_keys"))
}

24
ui/api_key_remove.go Normal file
View file

@ -0,0 +1,24 @@
// Copyright 2020 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package ui // import "miniflux.app/ui"
import (
"net/http"
"miniflux.app/http/request"
"miniflux.app/http/response/html"
"miniflux.app/http/route"
"miniflux.app/logger"
)
func (h *handler) removeAPIKey(w http.ResponseWriter, r *http.Request) {
keyID := request.RouteInt64Param(r, "keyID")
err := h.store.RemoveAPIKey(request.UserID(r), keyID)
if err != nil {
logger.Error("[UI:RemoveAPIKey] %v", err)
}
html.Redirect(w, r, route.Path(h.router, "apiKeys"))
}

58
ui/api_key_save.go Normal file
View file

@ -0,0 +1,58 @@
// Copyright 2020 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package ui // import "miniflux.app/ui"
import (
"net/http"
"miniflux.app/http/request"
"miniflux.app/http/response/html"
"miniflux.app/http/route"
"miniflux.app/logger"
"miniflux.app/model"
"miniflux.app/ui/form"
"miniflux.app/ui/session"
"miniflux.app/ui/view"
)
func (h *handler) saveAPIKey(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
html.ServerError(w, r, err)
return
}
apiKeyForm := form.NewAPIKeyForm(r)
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
view.Set("form", apiKeyForm)
view.Set("menu", "settings")
view.Set("user", user)
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
view.Set("countErrorFeeds", h.store.CountErrorFeeds(user.ID))
if err := apiKeyForm.Validate(); err != nil {
view.Set("errorMessage", err.Error())
html.OK(w, r, view.Render("create_api_key"))
return
}
if h.store.APIKeyExists(user.ID, apiKeyForm.Description) {
view.Set("errorMessage", "error.api_key_already_exists")
html.OK(w, r, view.Render("create_api_key"))
return
}
apiKey := model.NewAPIKey(user.ID, apiKeyForm.Description)
if err = h.store.CreateAPIKey(apiKey); err != nil {
logger.Error("[UI:SaveAPIKey] %v", err)
view.Set("errorMessage", "error.unable_to_create_api_key")
html.OK(w, r, view.Render("create_api_key"))
return
}
html.Redirect(w, r, route.Path(h.router, "apiKeys"))
}

32
ui/form/api_key.go Normal file
View file

@ -0,0 +1,32 @@
// Copyright 2020 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package form // import "miniflux.app/ui/form"
import (
"net/http"
"miniflux.app/errors"
)
// APIKeyForm represents the API Key form.
type APIKeyForm struct {
Description string
}
// Validate makes sure the form values are valid.
func (a APIKeyForm) Validate() error {
if a.Description == "" {
return errors.NewLocalizedError("error.fields_mandatory")
}
return nil
}
// NewAPIKeyForm returns a new APIKeyForm.
func NewAPIKeyForm(r *http.Request) *APIKeyForm {
return &APIKeyForm{
Description: r.FormValue("description"),
}
}

View file

@ -109,6 +109,12 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool, feedHa
uiRouter.HandleFunc("/sessions", handler.showSessionsPage).Name("sessions").Methods("GET")
uiRouter.HandleFunc("/sessions/{sessionID}/remove", handler.removeSession).Name("removeSession").Methods("POST")
// API Keys pages.
uiRouter.HandleFunc("/keys", handler.showAPIKeysPage).Name("apiKeys").Methods("GET")
uiRouter.HandleFunc("/keys/{keyID}/remove", handler.removeAPIKey).Name("removeAPIKey").Methods("POST")
uiRouter.HandleFunc("/keys/create", handler.showCreateAPIKeyPage).Name("createAPIKey").Methods("GET")
uiRouter.HandleFunc("/keys/save", handler.saveAPIKey).Name("saveAPIKey").Methods("POST")
// OPML pages.
uiRouter.HandleFunc("/export", handler.exportFeeds).Name("export").Methods("GET")
uiRouter.HandleFunc("/import", handler.showImportPage).Name("import").Methods("GET")