├── Procfile ├── .gitignore ├── requirements.txt ├── static ├── githublogo.png ├── lightningaddress.png └── icons │ ├── icon_144x144.png │ ├── icon_192x192.png │ └── icon_512x512.png ├── decentralizeGifUrl.py ├── templates ├── sw.js ├── logo.svg ├── original.html ├── dev.html └── index.html ├── decentralizeGifUpload.py ├── manifest.json ├── LICENSE.txt ├── README.md ├── gifsearch.py ├── nip96.py ├── publish.py ├── nostrgifsearch.py ├── getevent.py ├── nip94.py ├── nip98.py └── api.py /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn -t 600 api:app -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | buddy/ 2 | __pycache__/ 3 | test.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/believethehype/gifbuddy/main/requirements.txt -------------------------------------------------------------------------------- /static/githublogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/believethehype/gifbuddy/main/static/githublogo.png -------------------------------------------------------------------------------- /static/lightningaddress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/believethehype/gifbuddy/main/static/lightningaddress.png -------------------------------------------------------------------------------- /static/icons/icon_144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/believethehype/gifbuddy/main/static/icons/icon_144x144.png -------------------------------------------------------------------------------- /static/icons/icon_192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/believethehype/gifbuddy/main/static/icons/icon_192x192.png -------------------------------------------------------------------------------- /static/icons/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/believethehype/gifbuddy/main/static/icons/icon_512x512.png -------------------------------------------------------------------------------- /decentralizeGifUrl.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from nip98 import decentralizeGifUrl 3 | 4 | if len(sys.argv) > 1: 5 | file_url = sys.argv[1] 6 | summary = sys.argv[2] 7 | alt = sys.argv[3] 8 | image = sys.argv[4] 9 | preview = sys.argv[5] 10 | 11 | decentralizeGifUrl(file_url, summary, alt, image, preview) -------------------------------------------------------------------------------- /templates/sw.js: -------------------------------------------------------------------------------- 1 | //sw.js 2 | 3 | self.addEventListener('install', function(event) { 4 | console.log('[Service Worker] Installing Service Worker ...', event); 5 | }); 6 | self.addEventListener('activate', function(event) { 7 | console.log('[Service Worker] Activating Service Worker ...', event); 8 | }); 9 | self.addEventListener('fetch', function(event) { 10 | console.log('[Service Worker] Fetching something ...', event); 11 | }); -------------------------------------------------------------------------------- /templates/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /decentralizeGifUpload.py: -------------------------------------------------------------------------------- 1 | import sys, logging 2 | from nip98 import urlgenerator 3 | from nip94 import nip94, capture_image 4 | 5 | if len(sys.argv) > 1: 6 | filepath = sys.argv[1] 7 | tags = sys.argv[2] 8 | caption = sys.argv[3] 9 | alt = sys.argv[4] 10 | preview = sys.argv[5] 11 | 12 | def backgroundProcessing(filepath, tags, caption, alt, preview): 13 | image_path = capture_image(filepath) 14 | image_url = urlgenerator(image_path, caption, alt, "image/png") 15 | event94 = nip94(tags, alt, caption, image_url, preview) 16 | logging.info(f'NIP94 Event Published: {event94}') 17 | return event94 18 | 19 | backgroundProcessing(filepath, tags, caption, alt, preview) -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Gif Buddy", 3 | "short_name": "Gif Buddy", 4 | "start_url": "/", 5 | "scope": "/", 6 | "display": "standalone", 7 | "theme_color": "#317EFB", 8 | "background_color": "#317EFB", 9 | "icons": [ 10 | { 11 | "src": "/static/icons/icon_144x144.png", 12 | "sizes": "144x144", 13 | "type": "image/png", 14 | "purpose": "any maskable" 15 | }, 16 | { 17 | "src": "/static/icons/icon_192x192.png", 18 | "sizes": "192x192", 19 | "type": "image/png", 20 | "purpose": "any maskable" 21 | }, 22 | { 23 | "src": "/static/icons/icon_512x512.png", 24 | "sizes": "512x512", 25 | "type": "image/png", 26 | "purpose": "any maskable" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 GIF Buddy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gif Buddy 2 | ====== 3 | `draft` 4 | 5 | ## Description 6 | The front end of Gif Buddy is a search engine for gifs powered by the Tenor API. On the back end, for every gif that gets copied/clicked, an API request is made to upload to nostr.build. Upon upload, a NIP94 event is broadcast that includes the gif metadata (sha256 hash, url and fallback url namely). Broadcasting a NIP94 event allows the content to be accessed by any client in the future if developers decide to integrate gifs into their client natively. 7 | 8 | The web address https://gifbuddy.lol is a functional Progressive Web App (PWA) that allows users to download directly to their home screen. 9 | 10 | Now, anyone who searches for gifs using this tool is also helping to build the gif repository for NIP94 and adding fallback urls to nostr.build. And all they did was click to copy #gifs 11 | 12 | ## Development 13 | Gif Buddy is currently hosted on an Heroku server that auto-deploys each time code is pushed to GitHub. Located in the 'templates' folder is a 'dev.html' file that can be used for experimentation without the risk of breaking the app. The development build can be accessed at https://gifbuddy.lol/dev. -------------------------------------------------------------------------------- /gifsearch.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | 5 | # Set the apikey and limit 6 | apikey = os.environ.get('googlecloudvision') # click to set to your apikey 7 | ckey = "app" # set the client_key for the integration and use the same value for all API calls 8 | 9 | def fetch_gifs(search_term,limit, pos=None): 10 | # Construct the URL with pos if provided 11 | url = f"https://tenor.googleapis.com/v2/search?q={search_term}&key={apikey}&client_key={ckey}&limit={limit}" 12 | if pos != None: 13 | url += f"&pos={pos}" 14 | 15 | # API request to Tenor 16 | r = requests.get(url) 17 | 18 | # Load API result 19 | result = json.loads(r.content) 20 | 21 | return result 22 | 23 | if __name__ == "__main__": 24 | output = fetch_gifs('uh oh',1) 25 | print(output) 26 | # CAEQ1ezgotOiiAMaHgoKAD-_wHv0zFJjcRIQMBvy4oDXvi_ZiMP0AAAAADAB 27 | gif = output['results'][0]['media_formats']['gif'] 28 | description = output['results'][0]['content_description'] 29 | tags = output['results'][0]['tags'] 30 | thumb = output['results'][0]['media_formats']['tinygif']['url'] 31 | print(gif) 32 | print(description) 33 | print(tags) 34 | print(thumb) 35 | # gifUrl = gif['url'] 36 | # gifSize = gif['size'] 37 | # gifDims = gif['dims'] 38 | # thumb = output['results'][0]['media_formats']['nanogifpreview']['url'] 39 | # preview = output['results'][0]['media_formats']['tinygifpreview']['url'] 40 | # alt = os.path.basename(gifUrl)[0:-4] 41 | # print(gifUrl, gifSize, gifDims, thumb, preview, alt) 42 | -------------------------------------------------------------------------------- /nip96.py: -------------------------------------------------------------------------------- 1 | import requests, logging 2 | 3 | def filenostrbuildupload(event_base64, filepath, caption, alt): 4 | # Open the file in binary mode 5 | with open(filepath, 'rb') as file: 6 | files = { 7 | "file": (filepath, file, "image/gif"), 8 | } 9 | 10 | # Prepare headers with NIP-98 Authorization 11 | headers = { 12 | "Authorization": f"Nostr {event_base64}" 13 | } 14 | 15 | data = { 16 | "caption": caption, 17 | "expiration": "", # "" for no expiration 18 | "alt": alt, 19 | "content_type": "image/gif", 20 | "no_transform": "false" 21 | } 22 | 23 | # Make the POST request without the Authorization header 24 | api_url = "https://nostr.build/api/v2/nip96/upload" 25 | response = requests.post(api_url, headers=headers, files=files, data=data) 26 | 27 | # Check the response 28 | if response.status_code == 200: 29 | logging.info("File uploaded successfully.") 30 | response = response.json() 31 | else: 32 | logging.info("Failed to upload file.") 33 | logging.info(f"Status code: {response.status_code}") 34 | logging.info(f"Response: {response.text}") 35 | response = response.text 36 | 37 | return response 38 | 39 | def urlnostrbuildupload(event_base64, file_url, caption, alt): 40 | # Prepare headers with NIP-98 Authorization 41 | headers = { 42 | "Authorization": f"Nostr {event_base64}" 43 | } 44 | 45 | data = { 46 | "url": file_url, 47 | "caption": caption, 48 | "expiration": "", # "" for no expiration 49 | "alt": alt, 50 | "content_type": "image/gif", 51 | "no_transform": "false" 52 | } 53 | 54 | # Make the POST request without the Authorization header 55 | api_url = "https://nostr.build/api/v2/nip96/upload" 56 | response = requests.post(api_url, headers=headers, data=data) 57 | 58 | # Check the response 59 | if response.status_code == 200: 60 | logging.info("File uploaded successfully.") 61 | response = response.json() 62 | else: 63 | logging.info("Failed to upload file.") 64 | logging.info(f"Status code: {response.status_code}") 65 | logging.info(f"Response: {response.text}") 66 | response = response.text 67 | 68 | return response -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | import asyncio, os, json, ast 2 | from datetime import timedelta 3 | from nostr_sdk import Keys, Client, Kind, Tag, NostrSigner, Metadata, TagKind, HttpData, HttpMethod, EventId, EventBuilder, Filter, EventSource, init_logger, LogLevel 4 | # init_logger(LogLevel.WARN) 5 | 6 | def hex_to_note(target_eventID): 7 | note = EventId.from_hex(target_eventID).to_bech32() 8 | return note 9 | 10 | # Publish content to nostr 11 | async def nostrpost(private_key, content, kind=None, reply_to=None, url=None, payload=None, tags=[]): 12 | # Initialize with Keys signer 13 | keys = Keys.parse(private_key) 14 | signer = NostrSigner.keys(keys) 15 | client = Client(signer) 16 | 17 | # Add relays and connect 18 | await client.add_relay("wss://relay.damus.io") 19 | await client.add_relay("wss://relay.primal.net") 20 | # await client.add_relay("wss://relay.nostr.band") 21 | # await client.add_relay("wss://nostr.fmt.wiz.biz") 22 | await client.connect() 23 | 24 | # Send an event using the Nostr Signer 25 | if content and reply_to: # Replies 26 | # Create Event Object 27 | f = Filter().id(EventId.parse(reply_to)) 28 | source = EventSource.relays(timeout=timedelta(seconds=10)) 29 | reply_to = await client.get_events_of([f], source) 30 | reply_to = reply_to[0] 31 | builder = EventBuilder.text_note_reply(content=content, reply_to=reply_to) 32 | elif url and payload: # NIP98 33 | builder = EventBuilder.http_auth(HttpData(url=url, method=HttpMethod.POST, payload=payload)) 34 | elif kind == 0: # Metadata 35 | builder = EventBuilder.metadata(Metadata.from_json(content)) 36 | elif kind == 1063: 37 | event_tags = [] 38 | for tag in tags: 39 | event_tags.append(Tag.parse(tag)) 40 | builder = EventBuilder(kind=Kind(1063), content=content, tags=event_tags) 41 | else: # Default to Text Note 42 | builder = EventBuilder.text_note(content=content, tags=tags) 43 | await client.send_event_builder(builder) 44 | 45 | # Allow note to send 46 | await asyncio.sleep(2.0) 47 | 48 | # Get event ID from relays 49 | f = Filter().authors([keys.public_key()]).limit(1) 50 | source = EventSource.relays(timedelta(seconds=10)) 51 | events = await client.get_events_of([f], source) 52 | for event in events: 53 | event = event.as_json() 54 | eventID = json.loads(event)['id'] 55 | 56 | return eventID 57 | 58 | if __name__ == "__main__": 59 | private_key = os.environ["nostrdvmprivatekey"] 60 | pubkey = Keys.parse(private_key).public_key() 61 | content = 'test again' 62 | event = '3589d9a28644890fd3904d11854669327a2c19f4123760dd68bbaccd9502fc9e' 63 | eventID = asyncio.run(nostrpost(private_key, content=content, reply_to=event)) 64 | print(eventID) -------------------------------------------------------------------------------- /nostrgifsearch.py: -------------------------------------------------------------------------------- 1 | import json, requests 2 | from datetime import timedelta 3 | from nostr_sdk import Client, Kind, Alphabet, SingleLetterTag, Filter, EventSource, init_logger, LogLevel, \ 4 | NostrDatabase, ClientBuilder, NegentropyOptions, NegentropyDirection 5 | 6 | init_logger(LogLevel.ERROR) 7 | 8 | 9 | async def update_database(db_name): 10 | database = NostrDatabase.lmdb(db_name) 11 | client = ClientBuilder().database(database).build() 12 | 13 | await client.add_relay("wss://relay.damus.io") 14 | await client.add_relay("wss://relay.primal.net") 15 | await client.connect() 16 | 17 | print("Syncing Gif Database.. this might take a moment..") 18 | dbopts = NegentropyOptions().direction(NegentropyDirection.DOWN) 19 | 20 | f = Filter().kind(Kind(1063)).custom_tag(SingleLetterTag.lowercase(Alphabet.M), ["image/gif"]) 21 | await client.reconcile(f, dbopts) 22 | print("Done Syncing Gif Database.") 23 | 24 | 25 | async def get_gifs_from_database(db_name, search_term): 26 | database = NostrDatabase.lmdb(db_name) 27 | 28 | f = Filter().kind(Kind(1063)).custom_tag(SingleLetterTag.lowercase(Alphabet.M), ["image/gif"]) 29 | events = await database.query([f]) 30 | 31 | event_list = [] 32 | for event in events: 33 | event_str = event.as_json() 34 | if search_term in event_str: 35 | event_json = json.loads(event_str) 36 | event_list.append(event_json) 37 | 38 | return event_list 39 | 40 | # Blastr API Endpoint for online relays 41 | def getrelays(): 42 | # Define the URL to send the request to 43 | url = "https://api.nostr.watch/v1/online" 44 | 45 | # Make the GET request 46 | response = requests.get(url) 47 | 48 | # Check if the request was successful 49 | if response.status_code == 200: 50 | # Parse the response to JSON 51 | data = response.json() 52 | 53 | # Pretty-print the JSON data 54 | return json.dumps(data) 55 | else: 56 | return "Request failed with status code:", response.status_code 57 | 58 | # Get event list 59 | async def getgifs(): 60 | # Initialize client without signer 61 | client = Client() 62 | 63 | # Add relays and connect 64 | await client.add_relay("wss://relay.damus.io") 65 | await client.add_relay("wss://relay.primal.net") 66 | await client.connect() 67 | 68 | # Get events from relays 69 | f = Filter().kind(Kind(1063)).custom_tag(SingleLetterTag.lowercase(Alphabet.M), ["image/gif"]) 70 | 71 | source = EventSource.relays(timeout=timedelta(seconds=30)) 72 | events = await client.get_events_of([f], source) 73 | 74 | # Convert objects into list of dictionaries 75 | event_list = [] 76 | for event in events: 77 | event = event.as_json() 78 | if search_term in event: 79 | event = json.loads(event) 80 | event_list.append(event) 81 | 82 | return event_list 83 | 84 | def remove_duplicates_by_hash(dicts): 85 | seen_x_values = set() 86 | unique_dicts = [] 87 | 88 | for d in dicts: 89 | # Extract the 'x' value from the 'tags' list if it exists 90 | x_value = None 91 | for tag in d['tags']: 92 | if tag[0] == 'x': 93 | x_value = tag[1] 94 | break 95 | 96 | # If 'x' tag exists and we haven't seen this value before, keep the dictionary 97 | if x_value and x_value not in seen_x_values: 98 | seen_x_values.add(x_value) 99 | unique_dicts.append(d) 100 | 101 | return unique_dicts 102 | 103 | 104 | if __name__ == "__main__": 105 | import asyncio 106 | search_term = "liotta" 107 | 108 | # Variant with local database 109 | asyncio.run(update_database("gifs")) 110 | output = asyncio.run(get_gifs_from_database("gifs", search_term)) 111 | print("DB: " + str(len(output)), output) 112 | 113 | # Variant with in memory 114 | output = asyncio.run(getgifs(search_term)) 115 | print("Memory: " + str(len(output)), output) 116 | -------------------------------------------------------------------------------- /getevent.py: -------------------------------------------------------------------------------- 1 | import os, json, time, asyncio, logging 2 | from datetime import timedelta 3 | from nostr_sdk import Client, SingleLetterTag, Alphabet, EventId, PublicKey, Kind, Filter, EventSource, init_logger, LogLevel, Timestamp 4 | init_logger(LogLevel.WARN) 5 | 6 | # Initialize private key 7 | private_key = os.environ["nostrdvmprivatekey"] 8 | 9 | # Get event list 10 | async def getevent(id=None, kind=1, pubkey=None, event=None, since=None, author=None, start=1724961480, end=int(time.time())): 11 | # Initialize client without signer 12 | client = Client() 13 | 14 | # Add relays and connect 15 | await client.add_relay("wss://relay.damus.io") 16 | await client.add_relay("wss://relay.primal.net") 17 | # await client.add_relay("wss://relay.nostr.band") 18 | # await client.add_relay("wss://nostr.fmt.wiz.biz") 19 | await client.connect() 20 | 21 | # Get events from relays 22 | if id: # Direct search 23 | f = Filter().id(EventId.parse(id)) 24 | elif pubkey and kind and since: # Mentions 25 | f = Filter().pubkey(PublicKey.from_hex(pubkey)).kind(Kind(kind)).since(since) 26 | elif event and kind and not pubkey: # Zaps 27 | f = Filter().event(EventId.parse(event)).kind(Kind(kind)) 28 | elif kind==0 and author: # Metadata 29 | f = Filter().kind(Kind(kind)).author(PublicKey.from_hex(author)) 30 | elif kind == 1063: # Gif search 31 | f = Filter().kind(Kind(1063)).custom_tag(SingleLetterTag.lowercase(Alphabet.M), ["image/gif"]).author(PublicKey.from_bech32(author)).since(Timestamp.from_secs(start)).until(Timestamp.from_secs(end)) 32 | 33 | else: 34 | raise Exception("Unrecognized request for event retreival") 35 | 36 | source = EventSource.relays(timeout=timedelta(seconds=30)) 37 | events = await client.get_events_of([f], source) 38 | 39 | # Convert objects into list of dictionaries 40 | event_list = [] 41 | for event in events: 42 | event = event.as_json() 43 | event = json.loads(event) 44 | event_list.append(event) 45 | 46 | return event_list 47 | 48 | def gifcounter(): 49 | start_timestamp = 1724961480 # NOTE: UNIX timestamp for gifbuddy launch 50 | interval = 2628288 # NOTE: one month interval 51 | current_timestamp = int(time.time()) 52 | pubkey = 'npub10sa7ya5uwmhv6mrwyunkwgkl4cxc45spsff9x3fp2wuspy7yze2qr5zx5p' 53 | super_list = [] 54 | 55 | # Helper function to fetch events 56 | async def fetch_events(start, end): 57 | return await getevent(kind=1063, author=pubkey, start=start, end=end) 58 | 59 | # Calculate the number of full months 60 | months_passed = (current_timestamp - start_timestamp) // interval 61 | 62 | # Fetch events for each full month 63 | for month in range(months_passed): 64 | end_timestamp = start_timestamp + interval 65 | logging.info(f"Fetching events from {start_timestamp} to {end_timestamp}") 66 | 67 | # Run the async fetch_events function and collect results 68 | eventlist = asyncio.run(fetch_events(start_timestamp, end_timestamp)) 69 | super_list.extend(eventlist) 70 | 71 | # Update start timestamp for the next month 72 | start_timestamp = end_timestamp 73 | 74 | # Handle any remaining time up to the current timestamp 75 | if start_timestamp < current_timestamp: 76 | logging.info(f"Fetching events from {start_timestamp} to {current_timestamp}") 77 | eventlist = asyncio.run(fetch_events(start_timestamp, current_timestamp)) 78 | super_list.extend(eventlist) 79 | 80 | logging.info(f"Total events fetched: {len(super_list)}") 81 | return len(super_list), super_list 82 | 83 | 84 | if __name__ == "__main__": 85 | # Review nip94 events 86 | # pubkey = 'npub10sa7ya5uwmhv6mrwyunkwgkl4cxc45spsff9x3fp2wuspy7yze2qr5zx5p' 87 | # eventlist = asyncio.run(getevent(kind=1063, author=pubkey)) 88 | # print(len(eventlist)) 89 | 90 | # Get specific event 91 | # event_id = '43fe4d4f7d6c6cd2a66151d6f5753a93f420e57a889e5ad94e7c5a2bee282704' 92 | # eventlist = asyncio.run(getevent(id=event_id)) 93 | # print(eventlist) 94 | 95 | # Count all nip94 events since gifbuddy launch 96 | print(gifcounter()[0]) -------------------------------------------------------------------------------- /nip94.py: -------------------------------------------------------------------------------- 1 | from publish import nostrpost 2 | import os, requests, hashlib, asyncio, logging 3 | import blurhash 4 | from PIL import Image 5 | from io import BytesIO 6 | 7 | def capture_image(gif_path): 8 | # Load the GIF 9 | gif = Image.open(gif_path) 10 | 11 | # Specify the frame to save; here we save the first frame 12 | frame_number = 0 13 | gif.seek(frame_number) 14 | 15 | # Save the frame as a .png image 16 | basename = os.path.basename(gif_path)[0:-4] 17 | frame_path = f"uploads/{basename}.png" 18 | gif.save(frame_path, "PNG") 19 | 20 | logging.info(f"Frame saved as {frame_path}") 21 | 22 | return frame_path 23 | 24 | def compute_sha256(url): 25 | # Send a GET request to the URL to fetch the content 26 | response = requests.get(url) 27 | 28 | # Check if the request was successful (status code 200) 29 | if response.status_code == 200: 30 | # Read the binary content from the response 31 | content = response.content 32 | 33 | # Compute SHA-256 hash of the binary content 34 | sha256_hash = hashlib.sha256(content).hexdigest() 35 | 36 | return sha256_hash 37 | else: 38 | # Handle errors if the request fails 39 | logging.info(f"Failed to fetch URL: {url}") 40 | return None 41 | 42 | def gifmetadata(gifUrl, gifSize, gifDims, thumb, preview, alt, searchTerm): 43 | # Blurhash 44 | response = requests.get(preview) 45 | response.raise_for_status() 46 | image = Image.open(BytesIO(response.content)) 47 | blur_hash = blurhash.encode(image, x_components=4, y_components=3) 48 | 49 | # Post 1063 File Metadata Event 50 | private_key = os.environ["nostrdvmprivatekey"] 51 | kind = 1063 52 | 53 | hash = str(compute_sha256(gifUrl)) 54 | if hash is not None: 55 | tags = [["url", gifUrl], 56 | ["m", "image/gif"], 57 | ["x", hash], 58 | ["ox", hash], 59 | ["size", str(gifSize)], 60 | ["dim", str(gifDims)], 61 | ["blurhash", blur_hash], 62 | ["thumb", thumb], 63 | ["image", preview], 64 | ["summary", searchTerm], 65 | ["alt", alt] 66 | ] 67 | 68 | event_id = asyncio.run(nostrpost(private_key=private_key,content=searchTerm, kind=kind, tags=tags)) 69 | logging.info(event_id) 70 | 71 | return event_id 72 | 73 | def nip94(tags, alt, summary, image, thumb): 74 | # Post 1063 File Metadata Event 75 | private_key = os.environ["nostrdvmprivatekey"] 76 | kind = 1063 77 | 78 | tags.append(["summary", summary]) 79 | tags.append(["alt", alt]) 80 | 81 | try: 82 | tags.append(["thumb", thumb, compute_sha256(image)]) 83 | tags.append(["image", image, compute_sha256(image)]) 84 | except: 85 | pass 86 | 87 | logging.info('Attempting to Post NIP94 Event') 88 | event_id = asyncio.run(nostrpost(private_key=private_key,content=f"{summary} {alt}", kind=kind, tags=tags)) 89 | 90 | return event_id 91 | 92 | if __name__ == "__main__": 93 | # from gifsearch import fetch_gifs 94 | # from getevent import getevent 95 | # searchTerm = 'wow' 96 | # output = fetch_gifs(searchTerm,1) 97 | # gif = output['results'][0]['media_formats']['gif'] 98 | # gifUrl = gif['url'] 99 | # gifSize = str(gif['size']) 100 | # gifDims = str(gif['dims']) 101 | # thumb = output['results'][0]['media_formats']['nanogifpreview']['url'] 102 | # preview = output['results'][0]['media_formats']['tinygifpreview']['url'] 103 | # alt = os.path.basename(gifUrl)[0:-4] 104 | # print(gifUrl, gifSize, gifDims, thumb, preview, alt) 105 | # # BUG: not posting whencopying gif :( 106 | # event_id = gifmetadata(gifUrl, gifSize, gifDims, thumb, preview, alt, searchTerm) 107 | # eventlist = getevent(ids=[event_id]) 108 | # print(eventlist, len(eventlist)) 109 | alt = 'cartoon' 110 | searchTerm = 'scrooge mcduck' 111 | tags = [['url', 'https://image.nostr.build/d20d2035280c79a86ef0dc6090beeb29ed5b80a3775f5cf7007956646107835a.gif'], ['ox', 'd20d2035280c79a86ef0dc6090beeb29ed5b80a3775f5cf7007956646107835a'], ['fallback', 'https://media.tenor.com/R69QHouKBoQAAAAC/cartoon.gif'], ['x', '31aa4ef986340768466d806543b206b656a22088b029deb7a93fb3fad392cf6c'], ['m', 'image/gif'], ['dim', '360x258'], ['bh', 'LTJ7v0~QItn,BQ%G$~WV06E4X4WV'], ['blurhash', 'LTJ7v0~QItn,BQ%G$~WV06E4X4WV'], ['thumb', 'https://image.nostr.build/thumb/d20d2035280c79a86ef0dc6090beeb29ed5b80a3775f5cf7007956646107835a.gif']] 112 | print(nip94(tags, alt, searchTerm)) -------------------------------------------------------------------------------- /nip98.py: -------------------------------------------------------------------------------- 1 | import json, base64, os, hashlib, asyncio, time, logging, subprocess 2 | from publish import nostrpost 3 | from nip96 import urlnostrbuildupload, filenostrbuildupload 4 | from getevent import getevent 5 | from nip94 import nip94 6 | 7 | def fallbackurlgenerator(file_url, caption, alt): 8 | # Variables 9 | private_key = os.environ["nostrdvmprivatekey"] 10 | api_url = "https://nostr.build/api/v2/nip96/upload" 11 | 12 | data = { 13 | "url": file_url, 14 | "caption": caption, 15 | "expiration": "", # "" for no expiration 16 | "alt": alt, 17 | "content_type": "image/gif", 18 | "no_transform": "false" 19 | } 20 | 21 | # Compute the SHA256 hash 22 | sha256_hash = hashlib.sha256(json.dumps(data).encode('utf-8')).hexdigest() 23 | 24 | # Post nostr event and capture ID 25 | event_id = asyncio.run(nostrpost(private_key, content="", url=api_url, payload=sha256_hash)) 26 | 27 | # Confirm post and capture event 28 | event = asyncio.run(getevent(id=event_id)) 29 | event = event[0] 30 | 31 | # Apply base64 to event 32 | event_base64 = base64.b64encode(json.dumps(event).encode("utf-8")).decode("utf-8") 33 | 34 | # Initialize url variable 35 | url = None 36 | 37 | # POST to Nostr.Build and pull new URL 38 | response = urlnostrbuildupload(event_base64, file_url, caption, alt) 39 | logging.info(response) 40 | tags = response['nip94_event']['tags'] 41 | for tag in tags: 42 | if tag[0] == 'url': 43 | url = tag[1] 44 | 45 | if url is None: 46 | raise ValueError("URL not found in the response") 47 | 48 | logging.info(f"Nostr Build URL: {url}") 49 | return url, tags 50 | 51 | def urlgenerator(filepath, caption, alt, MIME): 52 | # Variables 53 | private_key = os.environ["nostrdvmprivatekey"] 54 | api_url = "https://nostr.build/api/v2/nip96/upload" 55 | 56 | # Open File 57 | with open(filepath, 'rb') as file: 58 | file_content = file.read() 59 | 60 | # Set data 61 | data = { 62 | "caption": caption, 63 | "expiration": "", # "" for no expiration 64 | "alt": alt, 65 | "content_type": MIME, 66 | "no_transform": "false" 67 | } 68 | 69 | # Combine data into one string, including the file content 70 | combined_data = json.dumps(data).encode('utf-8') + file_content 71 | 72 | # Compute the SHA256 hash 73 | sha256_hash = hashlib.sha256(combined_data).hexdigest() 74 | 75 | # Post nostr event and capture ID 76 | event_id = asyncio.run(nostrpost(private_key, content="", url=api_url, payload=sha256_hash)) 77 | logging.info(f'Nostr.Build Event ID: {event_id}') 78 | 79 | start_time = time.time() 80 | timeout = 30 81 | # Keep trying to get the event until timeout 82 | while True: 83 | event = asyncio.run(getevent(id=event_id)) 84 | 85 | if event: # If event is found, return it 86 | event = event[0] 87 | break 88 | 89 | # Wait a short time before checking again 90 | time.sleep(0.5) 91 | 92 | if time.time() - start_time >= timeout: 93 | # If the loop ends without returning, raise an error due to timeout 94 | raise TimeoutError(f"Failed to fetch event with ID {event_id} within {timeout} seconds.") 95 | 96 | # Apply base64 to event 97 | event_base64 = base64.b64encode(json.dumps(event).encode("utf-8")).decode("utf-8") 98 | 99 | # Initialize url variable 100 | url = None 101 | 102 | # POST to Nostr.Build and pull new URL 103 | response = filenostrbuildupload(event_base64, filepath, caption, alt) 104 | tags = response['nip94_event']['tags'] 105 | for tag in tags: 106 | if tag[0] == 'url': 107 | url = tag[1] 108 | 109 | if url is None: 110 | raise ValueError("URL not found in the response") 111 | 112 | logging.info(f"Nostr Build URL: {url}") 113 | return url, tags 114 | 115 | def decentralizeGifUrl(file_url, summary, alt, image, preview): 116 | caption = f"{summary} {alt}" 117 | url, tags = fallbackurlgenerator(file_url, caption, alt) 118 | 119 | try: 120 | event94 = nip94(tags, alt, summary, image, preview) 121 | logging.info(f'NIP94 Event Published: {event94}') 122 | except: 123 | logging.info('NIP94 Failed') 124 | 125 | return url 126 | 127 | def decentralizeGifUpload(filepath, caption, alt, MIME): 128 | url, tags = urlgenerator(filepath, caption, alt, MIME) 129 | for tag in tags: 130 | if tag[0] == 'thumb': 131 | preview = tag[1] 132 | try: 133 | # image_path = capture_image(filepath) 134 | # image_url = urlgenerator(image_path, caption, alt, "image/png") 135 | # event94 = nip94(tags, alt, caption, image_url, preview) 136 | # Define and start the process 137 | subprocess.Popen(["python", "decentralizeGifUpload.py", filepath, tags, caption, alt, preview]) 138 | 139 | except: 140 | logging.info('NIP94 Failed') 141 | 142 | return url 143 | 144 | if __name__ == "__main__": 145 | # User Input 146 | file_url = "https://media.tenor.com/lDcJViIM2VIAAAAC/what-jurassic-park.gif" 147 | caption = "raptor" 148 | alt = "what-jurassic-park" 149 | 150 | url = asyncio.run(fallbackurlgenerator(file_url, caption, alt)) 151 | print(url) -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, render_template, jsonify, send_file 2 | import os, time, threading, sys, logging, subprocess 3 | from gifsearch import fetch_gifs 4 | from getevent import gifcounter 5 | from nip98 import decentralizeGifUpload 6 | import mimetypes 7 | 8 | # Configure logging to stdout so Heroku can capture it 9 | logging.basicConfig( 10 | stream=sys.stdout, 11 | level=logging.INFO 12 | ) 13 | 14 | app = Flask(__name__) 15 | app.config["SECRET_KEY"] = os.environ.get('flasksecret') 16 | 17 | current_dir = os.path.dirname(os.path.abspath(__file__)) 18 | 19 | # Set up a folder for storing uploaded files 20 | UPLOAD_FOLDER = 'uploads' 21 | os.makedirs(UPLOAD_FOLDER, exist_ok=True) 22 | app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER 23 | 24 | # Cache to store the counter value 25 | cached_counter = {"count": "0"} 26 | 27 | # DVM public key 28 | pubkey = 'npub10sa7ya5uwmhv6mrwyunkwgkl4cxc45spsff9x3fp2wuspy7yze2qr5zx5p' 29 | 30 | def update_counter(): 31 | """Fetches the count periodically and updates the cache.""" 32 | global cached_counter 33 | while True: 34 | try: 35 | eventlist = gifcounter() # Passes count and list 36 | cached_counter["count"] = str(eventlist[0]) 37 | logging.info(f"Counter updated: {cached_counter["count"]}") 38 | except Exception as e: 39 | logging.info(f"Error updating counter: {e}") 40 | 41 | time.sleep(120) # Wait for 2 minutes before updating again 42 | 43 | # Start the background task when the app starts 44 | threading.Thread(target=update_counter, daemon=True).start() 45 | 46 | # Homepage 47 | @app.route("/") 48 | def index(): 49 | return render_template("index.html") 50 | 51 | # Development environment 52 | @app.route("/dev") 53 | def dev(): 54 | return render_template("dev.html") 55 | 56 | # Search API endpoint 57 | @app.route("/search", methods=['POST']) 58 | def search(): 59 | # Capture user data 60 | data = request.get_json() # Get the JSON data from the request body 61 | search = data.get('q') # Extract the search term 62 | pos = data.get('pos') 63 | logging.info(f'Search term: {search}, Position: {pos}') # Debugging 64 | output = fetch_gifs(search,limit=30,pos=pos) 65 | gifs = {} 66 | 67 | for result in output['results']: 68 | gif = result['media_formats']['gif'] 69 | gifURL = gif['url'] 70 | gifSize = gif['size'] 71 | gifDims = gif['dims'] 72 | thumb = result['media_formats']['nanogifpreview']['url'] # not always gif format 73 | preview = result['media_formats']['tinygif']['url'] 74 | image = result['media_formats']['gifpreview']['url'] 75 | basename = os.path.basename(gifURL)[0:-4] 76 | try: 77 | alt = result['content_description'] 78 | tags = result['tags'] 79 | summary = search 80 | for tag in tags: 81 | summary = f"{summary} {tag}" 82 | except: 83 | alt = basename 84 | summary = search 85 | 86 | gifs[basename] = { 87 | 'gifUrl': gifURL, 88 | 'gifSize': gifSize, 89 | 'gifDims': gifDims, 90 | 'thumb': thumb, 91 | 'preview': preview, 92 | 'alt': alt, 93 | 'image': image, 94 | 'summary': summary 95 | } 96 | 97 | # Include the next position token in the response 98 | gifs['next'] = output.get('next', None) 99 | 100 | return jsonify(gifs) 101 | 102 | # Nostr.Build Upload, NIP94 endpoint 103 | @app.route("/gifmetadata", methods=['POST']) 104 | def gif_metadata(): 105 | start = time.time() 106 | # Get the JSON data from the request body 107 | data = request.get_json() 108 | gifUrl = data.get('gifUrl') 109 | gifSize = data.get('gifSize') 110 | gifDims = data.get('gifDims') 111 | thumb = data.get('thumb') 112 | preview = data.get('preview') 113 | alt = data.get('alt') 114 | image = data.get('image') 115 | summary = data.get('summary') 116 | 117 | try: 118 | # Define and start the process 119 | subprocess.Popen(["python", "decentralizeGifUrl.py", gifUrl, summary, alt, image, preview]) 120 | logging.info(f'API Process Time: {round(time.time()-start, 0)}') 121 | # Return a response indicating that the request was accepted 122 | return jsonify({"message": "Task is being processed."}), 202 123 | except Exception as e: 124 | logging.info(f'API Process Time: {round(time.time()-start, 0)}') 125 | # Return an error if process fails to start 126 | return jsonify({"error": str(e)}), 500 127 | 128 | # Nostr.Build Upload, NIP94 endpoint 129 | @app.route("/upload", methods=['POST']) 130 | def upload(): 131 | if 'file' not in request.files: 132 | return jsonify({'error': 'No file part in the request'}), 400 133 | 134 | file = request.files['file'] 135 | 136 | if file.filename == '': 137 | return jsonify({'error': 'No selected file'}), 400 138 | 139 | if file: 140 | # Save the file 141 | filepath = os.path.join(app.config['UPLOAD_FOLDER'], file.filename) 142 | file.save(filepath) 143 | 144 | # Get additional fields 145 | caption = request.form.get('caption', '') 146 | alt = file.filename[0:-4] 147 | logging.info(f"Alt: {alt}") 148 | 149 | mime_type, _ = mimetypes.guess_type(filepath) 150 | 151 | try: 152 | # Process the file and additional fields as needed 153 | url = decentralizeGifUpload(filepath, caption, alt, mime_type) 154 | return jsonify({'message': 'File uploaded successfully!', 'url': url,'filename': file.filename, 'caption': caption, 'alt': alt}), 200 155 | except Exception as e: 156 | # Return an error if process fails to start 157 | return jsonify({"error": str(e)}), 500 158 | 159 | return jsonify({'error': 'Failed to upload file'}), 500 160 | 161 | @app.route("/counter", methods=['GET']) 162 | def get_count(): 163 | """Returns the cached counter value.""" 164 | return jsonify(cached_counter) 165 | 166 | @app.route('/manifest.json') 167 | def serve_manifest(): 168 | return send_file('manifest.json', mimetype='application/manifest+json') 169 | 170 | @app.route('/sw.js') 171 | def serve_sw(): 172 | return send_file('templates/sw.js', mimetype='application/javascript') 173 | 174 | # NOTE: Reserved for future use 175 | @app.route("/privacypolicy") 176 | def policy(): 177 | return render_template("privacypolicy.html") 178 | 179 | @app.route("/termsofservice") 180 | def terms(): 181 | return render_template("termsofservice.html") 182 | 183 | if __name__ == "__main__": 184 | app.run(debug=True) -------------------------------------------------------------------------------- /templates/original.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |
351 |
351 |