Merge pull request #48 from studiokaiji/feature-#8-image-upload

Feature #8 image upload
This commit is contained in:
kaiji
2023-10-26 18:28:13 +09:00
committed by GitHub
11 changed files with 550 additions and 210 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,41 +1,15 @@
import { useEffect, useMemo } from "react";
import { H1 } from "./components/H1";
import { Link } from "./components/Link";
function App() {
const htmlId = useMemo(() => window.location.pathname.split("/")[2], []);
const cssId = useMemo(
() => Array.from(document.styleSheets)[0].href?.split("/").slice(-1)[0],
[]
);
const jsId = useMemo(
() => Array.from(document.scripts)[0].src?.split("/").slice(-1)[0],
[]
);
useEffect(() => {
console.log(Array.from(document.styleSheets));
}, []);
return (
<div className="font-body font-medium">
<div className="h-screen relative w-full bg-primary text-white space-y-7 p-7">
<div className="font-body font-medium p-7">
<div className="h-screen relative w-full text-white space-y-7">
<H1 className="!leading-[0.8]">
Nostr
<br />
WebHost
</H1>
<div>
<p>
Relay URL:{" "}
<Link href="wss://hostr.cc">
wss://hostr.cc
</Link>
</p>
<p>HTML ID: {htmlId}</p>
<p>CSS ID: {cssId}</p>
<p>Javascript ID: {jsId}</p>
</div>
<div>
<p>
GitHub:{" "}
@@ -56,10 +30,36 @@ function App() {
https://github.com/studiokaiji
</Link>
</p>
<p>Lightning Address: floppystore07@walletofsatoshi.com</p>
</div>
<div className="text-6xl font-extrabold bg-rainbow-gradient bg-clip-text text-transparent">
CUTE OSTRICHES GALLERY
</div>
<div className="flex flex-wrap">
<img
src="/DALL·E Ostrich on Jamaican Beach.png"
width={512}
height={512}
/>
<img src="/DALL·E Ostrich Hero.png" width={512} height={512} />
<img
src="/DALL·E Ostrich Illustration (1).png"
width={512}
height={512}
/>
<img
src="/DALL·E Ostrich Illustration (2).png"
width={512}
height={512}
/>
<img
src="/DALL·E Ostrich Illustration.png"
width={512}
height={512}
/>
</div>
<p className="absolute right-7 bottom-7">
😩{"<"}Hmmm... I want to ride an ostrich...
</p>
</div>
</div>
);

View File

@@ -5,4 +5,5 @@
body {
cursor: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMjAiIGN5PSIyMCIgcj0iMjAiIGZpbGw9IiM4RTMwRUIiLz4KPC9zdmc+Cg==, auto);
height: 100vh;
@apply bg-primary;
}

View File

@@ -9,6 +9,10 @@ export default {
colors: {
primary: "#8e30eb",
},
backgroundImage: {
"rainbow-gradient":
"linear-gradient(to right,#e60000,#f39800,#fff100,#009944,#0068b7,#1d2088,#920783,#e60000)",
},
},
},
plugins: [],

View File

@@ -3,144 +3,26 @@ package deploy
import (
"bufio"
"bytes"
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/studiokaiji/nostr-webhost/hostr/cmd/consts"
"github.com/studiokaiji/nostr-webhost/hostr/cmd/keystore"
"github.com/studiokaiji/nostr-webhost/hostr/cmd/relays"
"github.com/studiokaiji/nostr-webhost/hostr/cmd/tools"
"golang.org/x/exp/slices"
"golang.org/x/net/html"
)
func pathToKind(path string, replaceable bool) (int, error) {
// パスを分割
separatedPath := strings.Split(path, ".")
// 拡張子を取得
ex := separatedPath[len(separatedPath)-1]
// replaceable(NIP-33)の場合はReplaceableなkindを返す
switch ex {
case "html":
if replaceable {
return consts.KindWebhostHTML, nil
} else {
return consts.KindWebhostReplaceableHTML, nil
}
case "css":
if replaceable {
return consts.KindWebhostReplaceableCSS, nil
} else {
return consts.KindWebhostCSS, nil
}
case "js":
if replaceable {
return consts.KindWebhostReplaceableJS, nil
} else {
return consts.KindWebhostJS, nil
}
default:
return 0, fmt.Errorf("Invalid path")
}
}
// Replaceableにする場合のidentifier(dタグ)を取得
func getReplaceableIdentifier(indexHtmlIdentifier, filePath string) string {
return indexHtmlIdentifier + "/" + filePath[1:]
}
var nostrEventsQueue []*nostr.Event
func addNostrEventQueue(event *nostr.Event) {
nostrEventsQueue = append(nostrEventsQueue, event)
}
var allRelays []string
func publishEventsFromQueue(replaceable bool) (string, string) {
ctx := context.Background()
fmt.Println("Publishing...")
// 各リレーに接続
var relays []*nostr.Relay
for _, url := range allRelays {
relay, err := nostr.RelayConnect(ctx, url)
if err != nil {
fmt.Println("❌ Failed to connect to:", url)
continue
}
relays = append(relays, relay)
}
// Publishの進捗状況を表示
allEventsCount := len(nostrEventsQueue)
uploadedFilesCount := 0
var wg sync.WaitGroup
go func() {
wg.Add(1)
tools.DisplayProgressBar(&uploadedFilesCount, &allEventsCount)
wg.Done()
}()
var mutex sync.Mutex
// リレーへpublish
for _, ev := range nostrEventsQueue {
wg.Add(1)
go func(event *nostr.Event) {
for _, relay := range relays {
_, err := relay.Publish(ctx, *event)
if err != nil {
fmt.Println(err)
continue
}
}
mutex.Lock() // ロックして排他制御
uploadedFilesCount++ // カウントアップ
mutex.Unlock() // ロック解除
wg.Done() // ゴルーチンの終了を通知
}(ev)
}
wg.Wait()
if uploadedFilesCount < allEventsCount {
fmt.Println("Failed to deploy", allEventsCount-uploadedFilesCount, "files.")
}
indexEvent := nostrEventsQueue[len(nostrEventsQueue)-1]
encoded := ""
if !replaceable {
if enc, err := nip19.EncodeEvent(indexEvent.ID, allRelays, indexEvent.PubKey); err == nil {
encoded = enc
} else {
fmt.Println("❌ Failed to covert nevent:", err)
}
}
return indexEvent.ID, encoded
}
func isExternalURL(urlStr string) bool {
u, err := url.Parse(urlStr)
return err == nil && u.Scheme != "" && u.Host != ""
}
func isValidFileType(str string) bool {
return strings.HasSuffix(str, ".html") || strings.HasSuffix(str, ".css") || strings.HasSuffix(str, ".js")
}
func Deploy(basePath string, replaceable bool, htmlIdentifier string) (string, string, error) {
// 引数からデプロイしたいサイトのパスを受け取る。
filePath := filepath.Join(basePath, "index.html")
@@ -187,6 +69,15 @@ func Deploy(basePath string, replaceable bool, htmlIdentifier string) (string, s
// リレーを取得
allRelays, err = relays.GetAllRelays()
if err != nil {
fmt.Println("❌ Failed to get all relays:", err)
return "", "", err
}
// basePath以下のMedia Fileのパスを全て羅列しアップロード
err = uploadAllValidStaticMediaFiles(priKey, pubKey, basePath)
if err != nil {
fmt.Println("❌ Failed to upload media:", err)
return "", "", err
}
@@ -225,56 +116,93 @@ func Deploy(basePath string, replaceable bool, htmlIdentifier string) (string, s
return eventId, encoded, err
}
func convertLinks(priKey, pubKey, basePath string, replaceable bool, indexHtmlIdentifier string, n *html.Node) {
// <link> と <script> タグを対象とする
if n.Type == html.ElementNode && (n.Data == "link" || n.Data == "script") {
for i, a := range n.Attr {
// href,srcのうち、外部URLでないものかつ. html, .css, .js のみ変換する
if (a.Key == "href" || a.Key == "src") && !isExternalURL(a.Val) && isValidFileType(a.Val) {
filePath := filepath.Join(basePath, a.Val)
func convertLinks(
priKey, pubKey, basePath string,
replaceable bool,
indexHtmlIdentifier string,
n *html.Node,
) {
if n.Type == html.ElementNode {
if n.Data == "link" || n.Data == "script" {
// <link> と <script> タグを対象としてNostr Eventを作成
for i, a := range n.Attr {
// href,srcのうち、外部URLでないものかつ. html, .css, .js のみ変換する
if (a.Key == "href" || a.Key == "src") && !isExternalURL(a.Val) && isValidBasicFileType(a.Val) {
filePath := filepath.Join(basePath, a.Val)
// kindを取得
kind, err := pathToKind(filePath, replaceable)
if err != nil {
continue
}
// contentを取得
bytesContent, err := os.ReadFile(filePath)
if err != nil {
fmt.Println("❌ Failed to read", filePath, ":", err)
continue
}
// Tagsを追加
tags := nostr.Tags{}
// 置き換え可能なイベントの場合
if replaceable {
fileIdentifier := getReplaceableIdentifier(indexHtmlIdentifier, a.Val)
tags = tags.AppendUnique(nostr.Tag{"d", fileIdentifier})
// 元のパスをfileIdentifierに置き換える
n.Attr[i].Val = fileIdentifier
}
// Eventを生成し、キューに追加
event, err := getEvent(priKey, pubKey, string(bytesContent), kind, tags)
if err != nil {
fmt.Println("❌ Failed to get event for", filePath, ":", err)
break
}
addNostrEventQueue(event)
fmt.Println("Added", filePath, "event to publish queue")
// 置き換え可能なイベントでない場合
if !replaceable {
// neventを指定
nevent, err := nip19.EncodeEvent(event.ID, allRelays, pubKey)
// kindを取得
kind, err := pathToKind(filePath, replaceable)
if err != nil {
fmt.Println("❌ Failed to encode event", filePath, ":", err)
break
}
n.Attr[i].Val = nevent
// contentを取得
bytesContent, err := os.ReadFile(filePath)
if err != nil {
fmt.Println("❌ Failed to read", filePath, ":", err)
continue
}
content := string(bytesContent)
// Tagsを追加
tags := nostr.Tags{}
// 置き換え可能なイベントの場合
if replaceable {
fileIdentifier := getReplaceableIdentifier(indexHtmlIdentifier, a.Val)
tags = tags.AppendUnique(nostr.Tag{"d", fileIdentifier})
// 元のパスをfileIdentifierに置き換える
n.Attr[i].Val = fileIdentifier
}
// jsファイルを解析する
if strings.HasSuffix(a.Val, ".js") {
// アップロード済みファイルの元パスとURLを取得
for path, url := range uploadedMediaFiles {
// JS内に該当ファイルがあったら置換
content = strings.ReplaceAll(content, path, url)
}
}
event, err := getEvent(priKey, pubKey, content, kind, tags)
if err != nil {
fmt.Println("❌ Failed to get event for", filePath, ":", err)
break
}
addNostrEventQueue(event)
fmt.Println("Added", filePath, "event to publish queue")
// 置き換え可能なイベントでない場合
if !replaceable {
// neventを指定
nevent, err := nip19.EncodeEvent(event.ID, allRelays, pubKey)
if err != nil {
fmt.Println("❌ Failed to encode event", filePath, ":", err)
break
}
n.Attr[i].Val = nevent
}
}
}
} else if slices.Contains(availableMediaHtmlTags, n.Data) {
// 内部mediaファイルを対象にUpload Requestを作成
for _, a := range n.Attr {
if (a.Key == "href" || a.Key == "src" || a.Key == "data") && !isExternalURL(a.Val) && isValidMediaFileType(a.Val) {
filePath := filepath.Join(basePath, a.Val)
// contentを取得
bytesContent, err := os.ReadFile(filePath)
if err != nil {
fmt.Println("❌ Failed to read", filePath, ":", err)
continue
}
content := string(bytesContent)
if url, ok := uploadedMediaFiles[filePath]; ok {
content = strings.ReplaceAll(content, filePath, url)
}
}
}
}
@@ -286,19 +214,6 @@ func convertLinks(priKey, pubKey, basePath string, replaceable bool, indexHtmlId
}
}
func getEvent(priKey, pubKey, content string, kind int, tags nostr.Tags) (*nostr.Event, error) {
ev := nostr.Event{
PubKey: pubKey,
CreatedAt: nostr.Now(),
Kind: kind,
Content: content,
Tags: tags,
}
func convertLinksFromJS() {
err := ev.Sign(priKey)
if err != nil {
return nil, err
}
return &ev, err
}

275
hostr/cmd/deploy/media.go Normal file
View File

@@ -0,0 +1,275 @@
package deploy
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/nbd-wtf/go-nostr"
"github.com/studiokaiji/nostr-webhost/hostr/cmd/tools"
)
var availableContentTypes = []string{
"image/png",
"image/jpg",
"image/jpeg",
"image/gif",
"image/webp",
"video/mp4",
"video/quicktime",
"video/mpeg",
"video/webm",
"audio/mpeg",
"audio/mpg",
"audio/mpeg3",
"audio/mp3",
}
var availableContentSuffixes = []string{
".png",
".jpg",
".jpeg",
".gif",
".webp",
".mp4",
".quicktime",
".mpeg",
".webm",
".mpeg",
".mpg",
".mpeg3",
".mp3",
}
var availableMediaHtmlTags = []string{
"img",
"audio",
"video",
"source",
"object",
"embed",
}
func isValidMediaFileType(path string) bool {
for _, suffix := range availableContentSuffixes {
if strings.HasSuffix(path, suffix) {
return true
}
}
return false
}
const uploadEndpoint = "https://nostrcheck.me/api/v1/media"
type MediaResult struct {
Result bool `json:"result,omitempty"`
Description string `json:"description"`
Status string `json:"status,omitempty"`
Id int `json:"id,omitempty"`
Pubkey string `json:"pubkey,omitempty"`
Url string `json:"url,omitempty"`
Hash string `json:"hash,omitempty"`
Magnet string `json:"magnet,omitempty"`
Tags []string `json:"tags,omitempty"`
}
// [元パス]:[URL]の形で記録する
var uploadedMediaFiles = map[string]string{}
func uploadMediaFiles(filePaths []string, requests []*http.Request) {
client := &http.Client{}
var uploadedMediaFilesCount = 0
var allMediaFilesCount = len(requests)
var wg sync.WaitGroup
go func() {
wg.Add(1)
tools.DisplayProgressBar(&uploadedMediaFilesCount, &allMediaFilesCount)
wg.Done()
}()
var mutex sync.Mutex
// アップロードを並列処理
for i, req := range requests {
wg.Add(1)
filePath := filePaths[i]
go func(filePath string, req *http.Request) {
defer wg.Done()
response, err := client.Do(req)
// リクエストを送信
if err != nil {
fmt.Println("\n❌ Error sending request:", filePath, err)
return
}
defer response.Body.Close()
if !strings.HasPrefix(fmt.Sprint(response.StatusCode), "2") {
fmt.Println("\n❌ Failed to upload:", response.StatusCode, filePath)
return
}
var result *MediaResult
// ResultのDecode
err = json.NewDecoder(response.Body).Decode(&result)
if err != nil {
fmt.Println("\n❌ Error decoding response:", err)
return
}
// アップロードに失敗した場合
if !result.Result {
fmt.Println("\n❌ Failed to upload file:", filePath, err)
return
}
mutex.Lock() // ロックして排他制御
uploadedMediaFilesCount++ // カウントアップ
uploadedMediaFiles[filePath] = result.Url
mutex.Unlock() // ロック解除
}(filePath, req)
}
wg.Wait()
}
func filePathToUploadMediaRequest(basePath, filePath, priKey, pubKey string) (*http.Request, error) {
// ファイルを開く
file, err := os.Open(filepath.Join(basePath, filePath))
if err != nil {
return nil, fmt.Errorf("Failed to read %s: %w", filePath, err)
}
defer file.Close()
// リクエストボディのバッファを初期化
var requestBody bytes.Buffer
// multipart writerを作成
writer := multipart.NewWriter(&requestBody)
// mediafileフィールドを作成
part, err := writer.CreateFormFile("mediafile", filePath)
if err != nil {
return nil, fmt.Errorf("Error creating form file: %w", err)
}
// ファイルの内容をpartにコピー
_, err = io.Copy(part, file)
if err != nil {
return nil, fmt.Errorf("Error copying file: %w", err)
}
// uploadtypeフィールドを設定
err = writer.WriteField("uploadtype", "media")
if err != nil {
return nil, fmt.Errorf("Error writing field: %w", err)
}
// writerを閉じてリクエストボディを完成させる
err = writer.Close()
if err != nil {
return nil, fmt.Errorf("Error closing writer: %w", err)
}
// タグを追加
tags := nostr.Tags{
nostr.Tag{"u", uploadEndpoint},
nostr.Tag{"method", "POST"},
nostr.Tag{"payload", ""},
}
// イベントを生成
ev, err := getEvent(priKey, pubKey, "", 27235, tags)
if err != nil {
return nil, fmt.Errorf("Error get event: %d", err)
}
// イベントをJSONにマーシャル
evJson, err := ev.MarshalJSON()
if err != nil {
return nil, fmt.Errorf("Error marshaling event: %d", err)
}
// HTTPリクエストを作成
request, err := http.NewRequest("POST", uploadEndpoint, &requestBody)
if err != nil {
return nil, fmt.Errorf("Error creating request: %d", err)
}
// ヘッダーを設定
request.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evJson))
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", writer.FormDataContentType())
return request, nil
}
// basePath以下のMedia Fileのパスを全て羅列する
func listAllValidStaticMediaFilePaths(basePath string) ([]string, error) {
mediaFilePaths := []string{}
err := filepath.Walk(basePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// ディレクトリはスキップ
if !info.IsDir() {
// 各サフィックスに対してマッチングを試みる
for _, suffix := range availableContentSuffixes {
// ファイル名とサフィックスがマッチした場合
if strings.HasSuffix(strings.ToLower(info.Name()), strings.ToLower(suffix)) {
// フルパスからbasePathまでの相対パスを計算
relPath, err := filepath.Rel(basePath, path)
if err != nil {
fmt.Println("❌ Error calculating relative path:", err)
continue
}
// マッチするファイルの相対パスをスライスに追加
mediaFilePaths = append(mediaFilePaths, "/"+relPath)
break
}
}
}
return nil
})
if err != nil {
return nil, err
}
return mediaFilePaths, nil
}
// basePath以下のMedia Fileのパスを全て羅列しアップロード
func uploadAllValidStaticMediaFiles(priKey, pubKey, basePath string) error {
filesPaths, err := listAllValidStaticMediaFilePaths(basePath)
if err != nil {
return err
}
requests := []*http.Request{}
for _, filePath := range filesPaths {
request, err := filePathToUploadMediaRequest(basePath, filePath, priKey, pubKey)
if err != nil {
return err
}
requests = append(requests, request)
}
uploadMediaFiles(filesPaths, requests)
return nil
}

145
hostr/cmd/deploy/nostr.go Normal file
View File

@@ -0,0 +1,145 @@
package deploy
import (
"context"
"fmt"
"strings"
"sync"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/studiokaiji/nostr-webhost/hostr/cmd/consts"
"github.com/studiokaiji/nostr-webhost/hostr/cmd/tools"
)
var allRelays []string
func getEvent(priKey, pubKey, content string, kind int, tags nostr.Tags) (*nostr.Event, error) {
ev := nostr.Event{
PubKey: pubKey,
CreatedAt: nostr.Now(),
Kind: kind,
Content: content,
Tags: tags,
}
err := ev.Sign(priKey)
if err != nil {
return nil, err
}
return &ev, err
}
func isValidBasicFileType(str string) bool {
return strings.HasSuffix(str, ".html") || strings.HasSuffix(str, ".css") || strings.HasSuffix(str, ".js")
}
func publishEventsFromQueue(replaceable bool) (string, string) {
ctx := context.Background()
fmt.Println("Publishing...")
// 各リレーに接続
var relays []*nostr.Relay
for _, url := range allRelays {
relay, err := nostr.RelayConnect(ctx, url)
if err != nil {
fmt.Println("❌ Failed to connect to:", url)
continue
}
relays = append(relays, relay)
}
// Publishの進捗状況を表示
allEventsCount := len(nostrEventsQueue)
uploadedMediaFilesCount := 0
var wg sync.WaitGroup
go func() {
wg.Add(1)
tools.DisplayProgressBar(&uploadedMediaFilesCount, &allEventsCount)
wg.Done()
}()
var mutex sync.Mutex
// リレーへpublish
for _, ev := range nostrEventsQueue {
wg.Add(1)
go func(event *nostr.Event) {
for _, relay := range relays {
_, err := relay.Publish(ctx, *event)
if err != nil {
fmt.Println(err)
continue
}
}
mutex.Lock() // ロックして排他制御
uploadedMediaFilesCount++ // カウントアップ
mutex.Unlock() // ロック解除
wg.Done() // ゴルーチンの終了を通知
}(ev)
}
wg.Wait()
if uploadedMediaFilesCount < allEventsCount {
fmt.Println("Failed to deploy", allEventsCount-uploadedMediaFilesCount, "files.")
}
indexEvent := nostrEventsQueue[len(nostrEventsQueue)-1]
encoded := ""
if !replaceable {
if enc, err := nip19.EncodeEvent(indexEvent.ID, allRelays, indexEvent.PubKey); err == nil {
encoded = enc
} else {
fmt.Println("❌ Failed to covert nevent:", err)
}
}
return indexEvent.ID, encoded
}
func pathToKind(path string, replaceable bool) (int, error) {
// パスを分割
separatedPath := strings.Split(path, ".")
// 拡張子を取得
ex := separatedPath[len(separatedPath)-1]
// replaceable(NIP-33)の場合はReplaceableなkindを返す
switch ex {
case "html":
if replaceable {
return consts.KindWebhostHTML, nil
} else {
return consts.KindWebhostReplaceableHTML, nil
}
case "css":
if replaceable {
return consts.KindWebhostReplaceableCSS, nil
} else {
return consts.KindWebhostCSS, nil
}
case "js":
if replaceable {
return consts.KindWebhostReplaceableJS, nil
} else {
return consts.KindWebhostJS, nil
}
default:
return 0, fmt.Errorf("Invalid path")
}
}
// Replaceableにする場合のidentifier(dタグ)を取得
func getReplaceableIdentifier(indexHtmlIdentifier, filePath string) string {
return indexHtmlIdentifier + "/" + filePath[1:]
}
var nostrEventsQueue []*nostr.Event
func addNostrEventQueue(event *nostr.Event) {
nostrEventsQueue = append(nostrEventsQueue, event)
}