Add "Share article" feature

A new "shareCode" field is generated for each entry, and allows
unlogged users to access the entry through the /shared endpoint.
This feature is particularly useful to share articles from miniflux
to third-party users without having them to visit the original source.

The image proxy is disabled and special cache headers are proposed in
the shared page to avoid denial of service.
This commit is contained in:
Lesterpig 2019-10-05 13:30:25 +02:00 committed by Frédéric Guillot
parent 1b86913c00
commit 41a2b7e58e
24 changed files with 243 additions and 26 deletions

View file

@ -133,6 +133,7 @@ type Entry struct {
Date time.Time `json:"published_at"`
Content string `json:"content"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Starred bool `json:"starred"`
Enclosures Enclosures `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`

View file

@ -8,6 +8,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
@ -36,3 +37,8 @@ func GenerateRandomBytes(size int) []byte {
func GenerateRandomString(size int) string {
return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
}
// GenerateRandomStringHex returns a random hexadecimal string.
func GenerateRandomStringHex(size int) string {
return hex.EncodeToString(GenerateRandomBytes(size))
}

View file

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

View file

@ -167,6 +167,9 @@ alter table entries alter column changed_at set not null;
primary key(id),
unique (user_id, description)
);
`,
"schema_version_28": `alter table entries add column share_code text not null default '';
create unique index entries_share_code_idx on entries using btree(share_code) where share_code <> '';
`,
"schema_version_3": `create table tokens (
id text not null,
@ -223,6 +226,7 @@ var SqlMapChecksums = map[string]string{
"schema_version_25": "5262d2d4c88d637b6603a1fcd4f68ad257bd59bd1adf89c58a18ee87b12050d7",
"schema_version_26": "64f14add40691f18f514ac0eed10cd9b19c83a35e5c3d8e0bce667e0ceca9094",
"schema_version_27": "4235396b37fd7f52ff6f7526416042bb1649701233e2d99f0bcd583834a0a967",
"schema_version_28": "a64b5ba0b37fe3f209617b7d0e4dd05018d2b8362d2c9c528ba8cce19b77e326",
"schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
"schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",
"schema_version_5": "46397e2f5f2c82116786127e9f6a403e975b14d2ca7b652a48cd1ba843e6a27c",

View file

@ -0,0 +1,2 @@
alter table entries add column share_code text not null default '';
create unique index entries_share_code_idx on entries using btree(share_code) where share_code <> '';

View file

@ -76,6 +76,8 @@ var translations = map[string]string{
"entry.original.label": "Original-Artikel",
"entry.comments.label": "Kommentare",
"entry.comments.title": "Kommentare anzeigen",
"entry.share.label": "Teilen",
"entry.share.title": "Diesen Artikel teilen",
"page.unread.title": "Ungelesen",
"page.starred.title": "Lesezeichen",
"page.categories.title": "Kategorien",
@ -402,6 +404,8 @@ var translations = map[string]string{
"entry.original.label": "Original",
"entry.comments.label": "Comments",
"entry.comments.title": "View Comments",
"entry.share.label": "Share",
"entry.share.title": "Share this article",
"page.unread.title": "Unread",
"page.starred.title": "Starred",
"page.categories.title": "Categories",
@ -708,6 +712,8 @@ var translations = map[string]string{
"entry.original.label": "Original",
"entry.comments.label": "Comentarios",
"entry.comments.title": "Ver comentarios",
"entry.share.label": "Comparta",
"entry.share.title": "Comparta este articulo",
"page.unread.title": "No leídos",
"page.starred.title": "Marcadores",
"page.categories.title": "Categorias",
@ -1014,6 +1020,8 @@ var translations = map[string]string{
"entry.original.label": "Original",
"entry.comments.label": "Commentaires",
"entry.comments.title": "Voir les commentaires",
"entry.share.label": "Partager",
"entry.share.title": "Partager cet article",
"page.unread.title": "Non lus",
"page.starred.title": "Favoris",
"page.categories.title": "Catégories",
@ -1340,6 +1348,8 @@ var translations = map[string]string{
"entry.original.label": "Contenuto originale",
"entry.comments.label": "Commenti",
"entry.comments.title": "Mostra i commenti",
"entry.share.label": "Condividi",
"entry.share.title": "Condividi questo articolo",
"page.unread.title": "Da leggere",
"page.starred.title": "Preferiti",
"page.categories.title": "Categorie",
@ -1646,6 +1656,8 @@ var translations = map[string]string{
"entry.original.label": "オリジナル",
"entry.comments.label": "コメント",
"entry.comments.title": "コメントを見る",
"entry.share.label": "共有",
"entry.share.title": "この記事を共有する",
"page.unread.title": "未読",
"page.starred.title": "星付き",
"page.categories.title": "カテゴリ",
@ -1952,6 +1964,8 @@ var translations = map[string]string{
"entry.original.label": "Origineel",
"entry.comments.label": "Comments",
"entry.comments.title": "Bekijk de reacties",
"entry.share.label": "Deel",
"entry.share.title": "Deel dit artikel",
"page.unread.title": "Ongelezen",
"page.starred.title": "Favorieten",
"page.categories.title": "Categorieën",
@ -2276,6 +2290,8 @@ var translations = map[string]string{
"entry.original.label": "Oryginalny artykuł",
"entry.comments.label": "Komentarze",
"entry.comments.title": "Zobacz komentarze",
"entry.share.label": "Podzielić się",
"entry.share.title": "Podzielić się ten artykuł",
"page.unread.title": "Nieprzeczytane",
"page.starred.title": "Oznaczone gwiazdką",
"page.categories.title": "Kategorie",
@ -2608,6 +2624,8 @@ var translations = map[string]string{
"entry.original.label": "Оригинал",
"entry.comments.label": "Комментарии",
"entry.comments.title": "Показать комментарии",
"entry.share.label": "поделиться",
"entry.share.title": "поделиться эту статью",
"page.unread.title": "Непрочитанное",
"page.starred.title": "Избранное",
"page.categories.title": "Категории",
@ -2922,6 +2940,8 @@ var translations = map[string]string{
"entry.original.label": "原始内容",
"entry.comments.label": "评论",
"entry.comments.title": "查看评论",
"entry.share.label": "分享",
"entry.share.title": "分享这篇文章",
"page.unread.title": "未读",
"page.starred.title": "星标",
"page.categories.title": "分类",
@ -3169,14 +3189,14 @@ var translations = map[string]string{
}
var translationsChecksums = map[string]string{
"de_DE": "cc826a57cf4bf789df38db4f50626ad8c1c2b84ce34075c2c04de3d1f0dcd2d5",
"en_US": "f7e6db53cdbc2c0d959ac231dbacf0ef4d0ed81248944c4a4f8b83ef000f5349",
"es_ES": "cc727f62eef3a6cba51b65253d70a50161af35bf9c5366281b7984b2fc189961",
"fr_FR": "d3d1a4bf9aa8e4e24bae2f117507dcfc3cf00660a73b44a6c42356e8dbab8ae8",
"it_IT": "5ded991f2c70ec2268e6053bd84a77cf4136ebaea42013d3e79d594f38abb1b3",
"ja_JP": "110d7a7b1c888282b031de340e3318a62cdd62076b05a7fb49759f554c6dbe76",
"nl_NL": "a934ab4b1eff85580425a5859c31fcb227ae8926deba74df4e42b5d4feb67826",
"pl_PL": "6e80c36788723b9a7ff3f372e13a55c68d153727ec0abb56663cadbf6d6e1d9f",
"ru_RU": "d56f9e31f63731d23ce1ea2a8a4cb019f3ab282b23a1f494c47061daea523587",
"zh_CN": "4a5ca40790fceab88257f6742dc05294b79142bee8aad6fc87fbd479d1941292",
"de_DE": "7360a69e038d71e00f64c03891401cd517779687d46a907688f4a9a7b6205146",
"en_US": "92dda79899a673652a43fd8d61c893749713af09909ca03ac6fea06ac617d361",
"es_ES": "813b8cd42907dfbc19ff51f3367e0dbb013d373b013d7854df512e846652ff21",
"fr_FR": "279c52bbf682949cf8782e7e81f2bf5cfd300cebf577d51ce9436d44aaaf6323",
"it_IT": "5e8408e9aee142e1bd7e73f2a91ae96bc9ad0ab61c20416ad9e93b6fe505e8a9",
"ja_JP": "508025c0c7e7f57195ae011c4499ab58a85d043c828565c1740df879fb2376c1",
"nl_NL": "e621a5e7408928624a060a832d9fc36b74026221bd7b07894a4cce267be3cdd1",
"pl_PL": "2383c1a9be451557fe601f346e30bb165a88d9c00d17909a9c747d64864a423d",
"ru_RU": "d7ad59bbd7a150af9d476c4c3034eb85762de7381e2925d75e373584ed45c725",
"zh_CN": "e5f169a3c83c9bd7a41e9737e001e58fec243eee7aa23a71d37bfa8e05d92860",
}

View file

@ -71,6 +71,8 @@
"entry.original.label": "Original-Artikel",
"entry.comments.label": "Kommentare",
"entry.comments.title": "Kommentare anzeigen",
"entry.share.label": "Teilen",
"entry.share.title": "Diesen Artikel teilen",
"page.unread.title": "Ungelesen",
"page.starred.title": "Lesezeichen",
"page.categories.title": "Kategorien",

View file

@ -71,6 +71,8 @@
"entry.original.label": "Original",
"entry.comments.label": "Comments",
"entry.comments.title": "View Comments",
"entry.share.label": "Share",
"entry.share.title": "Share this article",
"page.unread.title": "Unread",
"page.starred.title": "Starred",
"page.categories.title": "Categories",

View file

@ -71,6 +71,8 @@
"entry.original.label": "Original",
"entry.comments.label": "Comentarios",
"entry.comments.title": "Ver comentarios",
"entry.share.label": "Comparta",
"entry.share.title": "Comparta este articulo",
"page.unread.title": "No leídos",
"page.starred.title": "Marcadores",
"page.categories.title": "Categorias",

View file

@ -71,6 +71,8 @@
"entry.original.label": "Original",
"entry.comments.label": "Commentaires",
"entry.comments.title": "Voir les commentaires",
"entry.share.label": "Partager",
"entry.share.title": "Partager cet article",
"page.unread.title": "Non lus",
"page.starred.title": "Favoris",
"page.categories.title": "Catégories",

View file

@ -71,6 +71,8 @@
"entry.original.label": "Contenuto originale",
"entry.comments.label": "Commenti",
"entry.comments.title": "Mostra i commenti",
"entry.share.label": "Condividi",
"entry.share.title": "Condividi questo articolo",
"page.unread.title": "Da leggere",
"page.starred.title": "Preferiti",
"page.categories.title": "Categorie",

View file

@ -71,6 +71,8 @@
"entry.original.label": "オリジナル",
"entry.comments.label": "コメント",
"entry.comments.title": "コメントを見る",
"entry.share.label": "共有",
"entry.share.title": "この記事を共有する",
"page.unread.title": "未読",
"page.starred.title": "星付き",
"page.categories.title": "カテゴリ",

View file

@ -71,6 +71,8 @@
"entry.original.label": "Origineel",
"entry.comments.label": "Comments",
"entry.comments.title": "Bekijk de reacties",
"entry.share.label": "Deel",
"entry.share.title": "Deel dit artikel",
"page.unread.title": "Ongelezen",
"page.starred.title": "Favorieten",
"page.categories.title": "Categorieën",

View file

@ -71,6 +71,8 @@
"entry.original.label": "Oryginalny artykuł",
"entry.comments.label": "Komentarze",
"entry.comments.title": "Zobacz komentarze",
"entry.share.label": "Podzielić się",
"entry.share.title": "Podzielić się ten artykuł",
"page.unread.title": "Nieprzeczytane",
"page.starred.title": "Oznaczone gwiazdką",
"page.categories.title": "Kategorie",

View file

@ -71,6 +71,8 @@
"entry.original.label": "Оригинал",
"entry.comments.label": "Комментарии",
"entry.comments.title": "Показать комментарии",
"entry.share.label": "поделиться",
"entry.share.title": "поделиться эту статью",
"page.unread.title": "Непрочитанное",
"page.starred.title": "Избранное",
"page.categories.title": "Категории",

View file

@ -71,6 +71,8 @@
"entry.original.label": "原始内容",
"entry.comments.label": "评论",
"entry.comments.title": "查看评论",
"entry.share.label": "分享",
"entry.share.title": "分享这篇文章",
"page.unread.title": "未读",
"page.starred.title": "星标",
"page.categories.title": "分类",

View file

@ -31,6 +31,7 @@ type Entry struct {
Date time.Time `json:"published_at"`
Content string `json:"content"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Starred bool `json:"starred"`
Enclosures EnclosureList `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`

View file

@ -9,6 +9,7 @@ import (
"fmt"
"time"
"miniflux.app/crypto"
"miniflux.app/logger"
"miniflux.app/model"
@ -351,3 +352,35 @@ func (s *Storage) EntryURLExists(feedID int64, entryURL string) bool {
s.db.QueryRow(query, feedID, entryURL).Scan(&result)
return result
}
// GetEntryShareCode returns the share code of the provided entry.
// It generates a new one if not already defined.
func (s *Storage) GetEntryShareCode(userID int64, entryID int64) (shareCode string, err error) {
query := `SELECT share_code FROM entries WHERE user_id=$1 AND id=$2`
err = s.db.QueryRow(query, userID, entryID).Scan(&shareCode)
if err != nil || shareCode != "" {
return
}
shareCode = crypto.GenerateRandomStringHex(16)
query = `UPDATE entries SET share_code = $1 WHERE user_id=$2 AND id=$3`
result, err := s.db.Exec(query, shareCode, userID, entryID)
if err != nil {
err = fmt.Errorf(`store: unable to set share_code for entry #%d: %v`, entryID, err)
return
}
count, err := result.RowsAffected()
if err != nil {
err = fmt.Errorf(`store: unable to set share_code for entry #%d: %v`, entryID, err)
return
}
if count == 0 {
err = errors.New(`store: nothing has been updated`)
}
return
}

View file

@ -128,6 +128,13 @@ func (e *EntryQueryBuilder) WithoutStatus(status string) *EntryQueryBuilder {
return e
}
// WithShareCode set the entry hash.
func (e *EntryQueryBuilder) WithShareCode(shareCode string) *EntryQueryBuilder {
e.conditions = append(e.conditions, fmt.Sprintf("e.share_code = $%d", len(e.args)+1))
e.args = append(e.args, shareCode)
return e
}
// WithOrder set the sorting order.
func (e *EntryQueryBuilder) WithOrder(order string) *EntryQueryBuilder {
e.order = order
@ -198,6 +205,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
e.url,
e.comments_url,
e.author,
e.share_code,
e.content,
e.status,
e.starred,
@ -255,6 +263,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
&entry.URL,
&entry.CommentsURL,
&entry.Author,
&entry.ShareCode,
&entry.Content,
&entry.Status,
&entry.Starred,
@ -358,3 +367,10 @@ func NewEntryQueryBuilder(store *Storage, userID int64) *EntryQueryBuilder {
conditions: []string{"e.user_id = $1"},
}
}
// NewAnonymousQueryBuilder returns a new EntryQueryBuilder suitable for anonymous users.
func NewAnonymousQueryBuilder(store *Storage) *EntryQueryBuilder {
return &EntryQueryBuilder{
store: store,
}
}

View file

@ -6,6 +6,7 @@
<h1>
<a href="{{ .entry.URL | safeURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .entry.Title }}</a>
</h1>
{{ if .user }}
<div class="entry-actions">
<ul>
<li>
@ -43,6 +44,12 @@
>{{ t "entry.save.title" }}</a>
</li>
{{ end }}
<li>
<a href="{{ route "shareGenerate" "entryID" .entry.ID }}"
title="{{ t "entry.share.title" }}"
target="_blank"
>{{ t "entry.share.label" }}</a>
</li>
<li>
<a href="#"
title="{{ t "entry.scraper.title" }}"
@ -59,9 +66,10 @@
{{ end }}
</ul>
</div>
{{ end }}
<div class="entry-meta">
<span class="entry-website">
{{ if ne .entry.Feed.Icon.IconID 0 }}
{{ if and .user (ne .entry.Feed.Icon.IconID 0) }}
<img src="{{ route "icon" "iconID" .entry.Feed.Icon.IconID }}" width="16" height="16" loading="lazy" alt="{{ .entry.Feed.Title }}">
{{ end }}
<a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
@ -75,21 +83,33 @@
{{ end }}
</span>
{{ end }}
<span class="category">
<a href="{{ route "categoryEntries" "categoryID" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
</span>
{{ if .user }}
<span class="category">
<a href="{{ route "categoryEntries" "categoryID" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
</span>
{{ end }}
</div>
<div class="entry-date">
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed $.user.Timezone .entry.Date }}</time>
{{ if .user }}
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed $.user.Timezone .entry.Date }}</time>
{{ else }}
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed "UTC" .entry.Date }}</time>
{{ end }}
</div>
</header>
{{ if gt (len .entry.Content) 120 }}
{{ if .user }}
<div class="pagination-top">
{{ template "entry_pagination" . }}
</div>
{{ end }}
{{ end }}
<article class="entry-content">
{{ noescape (proxyFilter .entry.Content) }}
{{ if .user }}
{{ noescape (proxyFilter .entry.Content) }}
{{ else }}
{{ noescape .entry.Content }}
{{ end }}
</article>
{{ if .entry.Enclosures }}
<details class="entry-enclosures">
@ -111,7 +131,11 @@
</div>
{{ else if hasPrefix .MimeType "image/" }}
<div class="enclosure-image">
<img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
{{ if .user }}
<img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
{{ else }}
<img src="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
{{ end }}
</div>
{{ end }}
@ -126,7 +150,9 @@
{{ end }}
</section>
{{ if .user }}
<div class="pagination-bottom">
{{ template "entry_pagination" . }}
</div>
{{ end }}
{{ end }}

View file

@ -653,6 +653,7 @@ var templateViewsMap = map[string]string{
<h1>
<a href="{{ .entry.URL | safeURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .entry.Title }}</a>
</h1>
{{ if .user }}
<div class="entry-actions">
<ul>
<li>
@ -690,6 +691,12 @@ var templateViewsMap = map[string]string{
>{{ t "entry.save.title" }}</a>
</li>
{{ end }}
<li>
<a href="{{ route "shareGenerate" "entryID" .entry.ID }}"
title="{{ t "entry.share.title" }}"
target="_blank"
>{{ t "entry.share.label" }}</a>
</li>
<li>
<a href="#"
title="{{ t "entry.scraper.title" }}"
@ -706,9 +713,10 @@ var templateViewsMap = map[string]string{
{{ end }}
</ul>
</div>
{{ end }}
<div class="entry-meta">
<span class="entry-website">
{{ if ne .entry.Feed.Icon.IconID 0 }}
{{ if and .user (ne .entry.Feed.Icon.IconID 0) }}
<img src="{{ route "icon" "iconID" .entry.Feed.Icon.IconID }}" width="16" height="16" loading="lazy" alt="{{ .entry.Feed.Title }}">
{{ end }}
<a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}">{{ .entry.Feed.Title }}</a>
@ -722,21 +730,33 @@ var templateViewsMap = map[string]string{
{{ end }}
</span>
{{ end }}
<span class="category">
<a href="{{ route "categoryEntries" "categoryID" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
</span>
{{ if .user }}
<span class="category">
<a href="{{ route "categoryEntries" "categoryID" .entry.Feed.Category.ID }}">{{ .entry.Feed.Category.Title }}</a>
</span>
{{ end }}
</div>
<div class="entry-date">
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed $.user.Timezone .entry.Date }}</time>
{{ if .user }}
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed $.user.Timezone .entry.Date }}</time>
{{ else }}
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed "UTC" .entry.Date }}</time>
{{ end }}
</div>
</header>
{{ if gt (len .entry.Content) 120 }}
{{ if .user }}
<div class="pagination-top">
{{ template "entry_pagination" . }}
</div>
{{ end }}
{{ end }}
<article class="entry-content">
{{ noescape (proxyFilter .entry.Content) }}
{{ if .user }}
{{ noescape (proxyFilter .entry.Content) }}
{{ else }}
{{ noescape .entry.Content }}
{{ end }}
</article>
{{ if .entry.Enclosures }}
<details class="entry-enclosures">
@ -758,7 +778,11 @@ var templateViewsMap = map[string]string{
</div>
{{ else if hasPrefix .MimeType "image/" }}
<div class="enclosure-image">
<img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
{{ if .user }}
<img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
{{ else }}
<img src="{{ .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
{{ end }}
</div>
{{ end }}
@ -773,10 +797,12 @@ var templateViewsMap = map[string]string{
{{ end }}
</section>
{{ if .user }}
<div class="pagination-bottom">
{{ template "entry_pagination" . }}
</div>
{{ end }}
{{ end }}
`,
"feed_entries": `{{ define "title"}}{{ .feed.Title }} ({{ .total }}){{ end }}
@ -1422,7 +1448,7 @@ var templateViewsMapChecksums = map[string]string{
"edit_category": "b1c0b38f1b714c5d884edcd61e5b5295a5f1c8b71c469b35391e4dcc97cc6d36",
"edit_feed": "cc0b5dbb73f81398410958b41771ed38246bc7ae4bd548228f0d48c49a598c2a",
"edit_user": "c692db9de1a084c57b93e95a14b041d39bf489846cbb91fc982a62b72b77062a",
"entry": "513183f0f0b11a199630562f5a85eb9a5646051aae278cbc682bac13d62e65cc",
"entry": "ef9cd8bb99c561023c1dcea1dbd7f90c4cdc195ed70e2ed9c88213fec875d770",
"feed_entries": "9c70b82f55e4b311eff20be1641733612e3c1b406ce8010861e4c417d97b6dcc",
"feeds": "ec7d3fa96735bd8422ba69ef0927dcccddc1cc51327e0271f0312d3f881c64fd",
"history_entries": "87e17d39de70eb3fdbc4000326283be610928758eae7924e4b08dcb446f3b6a9",

View file

@ -135,6 +135,7 @@ func (m *middleware) isPublicRoute(r *http.Request) bool {
"favicon",
"webManifest",
"robots",
"share",
"healthcheck":
return true
default:

57
ui/share.go Normal file
View file

@ -0,0 +1,57 @@
// Copyright 2017 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"
"time"
"miniflux.app/http/request"
"miniflux.app/http/response"
"miniflux.app/http/response/html"
"miniflux.app/http/route"
"miniflux.app/storage"
"miniflux.app/ui/session"
"miniflux.app/ui/view"
)
func (h *handler) shareGenerate(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
shareCode, err := h.store.GetEntryShareCode(request.UserID(r), entryID)
if err != nil {
html.ServerError(w, r, err)
return
}
html.Redirect(w, r, route.Path(h.router, "share", "shareCode", shareCode))
}
func (h *handler) sharePage(w http.ResponseWriter, r *http.Request) {
shareCode := request.RouteStringParam(r, "shareCode")
if shareCode == "" {
html.NotFound(w, r)
return
}
etag := shareCode
response.New(w, r).WithCaching(etag, 72*time.Hour, func(b *response.Builder) {
builder := storage.NewAnonymousQueryBuilder(h.store)
builder.WithShareCode(shareCode)
entry, err := builder.GetEntry()
if err != nil || entry == nil {
html.NotFound(w, r)
return
}
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
view.Set("entry", entry)
b.WithHeader("Content-Type", "text/html; charset=utf-8")
b.WithBody(view.Render("entry"))
b.Write()
})
}

View file

@ -88,6 +88,10 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool, feedHa
uiRouter.HandleFunc("/proxy/{encodedURL}", handler.imageProxy).Name("proxy").Methods("GET")
uiRouter.HandleFunc("/entry/bookmark/{entryID}", handler.toggleBookmark).Name("toggleBookmark").Methods("POST")
// Share pages.
uiRouter.HandleFunc("/entry/share/{entryID}", handler.shareGenerate).Name("shareGenerate").Methods("GET")
uiRouter.HandleFunc("/shared/{shareCode}", handler.sharePage).Name("share").Methods("GET")
// User pages.
uiRouter.HandleFunc("/users", handler.showUsersPage).Name("users").Methods("GET")
uiRouter.HandleFunc("/user/create", handler.showCreateUserPage).Name("createUser").Methods("GET")